Compare commits
5 commits
myShaarli_
...
theme_mana
Author | SHA1 | Date | |
---|---|---|---|
0f473eedfc | |||
a197ef5e02 | |||
81b9c01366 | |||
057fb6839c | |||
d33763a409 |
753 changed files with 24350 additions and 90210 deletions
|
@ -1,12 +0,0 @@
|
|||
module.exports = {
|
||||
"extends": "airbnb-base",
|
||||
"env": {
|
||||
"browser": true,
|
||||
},
|
||||
"rules": {
|
||||
"no-param-reassign": 0, // manipulate DOM style properties
|
||||
"no-restricted-globals": 0, // currently Shaarli uses alert/confirm, could be be improved later
|
||||
"no-alert": 0, // currently Shaarli uses alert/confirm, could be be improved later
|
||||
"no-cond-assign": [2, "except-parens"], // assignment in while loops is readable and avoid assignment duplication
|
||||
}
|
||||
};
|
|
@ -1,15 +0,0 @@
|
|||
module.exports = {
|
||||
extends: 'stylelint-config-standard',
|
||||
plugins: [
|
||||
"stylelint-scss"
|
||||
],
|
||||
rules: {
|
||||
"indentation": [2],
|
||||
"number-leading-zero": null,
|
||||
// Replace CSS @ with SASS ones
|
||||
"at-rule-no-unknown": null,
|
||||
"scss/at-rule-no-unknown": true,
|
||||
// not compatible with SASS apparently
|
||||
"no-descending-specificity": null
|
||||
},
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
[global]
|
||||
daemonize = no
|
||||
|
||||
[www]
|
||||
user = nginx
|
||||
group = nginx
|
||||
listen.owner = nginx
|
||||
listen.group = nginx
|
||||
catch_workers_output = yes
|
||||
listen = /var/run/php-fpm.sock
|
||||
pm = dynamic
|
||||
pm.max_children = 20
|
||||
pm.start_servers = 1
|
||||
pm.min_spare_servers = 1
|
||||
pm.max_spare_servers = 3
|
||||
pm.max_requests = 2048
|
|
@ -1,2 +0,0 @@
|
|||
#!/bin/sh
|
||||
/bin/true
|
|
@ -1,2 +0,0 @@
|
|||
#!/bin/execlineb -P
|
||||
nginx
|
|
@ -1,2 +0,0 @@
|
|||
#!/bin/execlineb -P
|
||||
php-fpm8 -F
|
|
@ -1,64 +0,0 @@
|
|||
# Docker-ignore
|
||||
.dev
|
||||
.git
|
||||
.github
|
||||
.gitattributes
|
||||
.gitignore
|
||||
tests
|
||||
|
||||
# Docker related resources are not needed inside the container
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
Dockerfile.armhf
|
||||
|
||||
# Docker Compose resources
|
||||
docker-compose.yml
|
||||
|
||||
# Shaarli runtime resources
|
||||
cache/*
|
||||
data/*
|
||||
pagecache/*
|
||||
tmp/*
|
||||
|
||||
# Shaarli's docs are created during the build
|
||||
doc/html/
|
||||
|
||||
# Eclipse project files
|
||||
.settings
|
||||
.buildpath
|
||||
.project
|
||||
|
||||
# Raintpl generated pages
|
||||
*.rtpl.php
|
||||
|
||||
# 3rd-party dependencies
|
||||
vendor/
|
||||
|
||||
# Release archives
|
||||
*.tar.gz
|
||||
*.zip
|
||||
inc/languages/*/LC_MESSAGES/shaarli.mo
|
||||
|
||||
# Development and test resources
|
||||
coverage
|
||||
doxygen
|
||||
sandbox
|
||||
phpmd.html
|
||||
|
||||
# User plugin configuration
|
||||
plugins/*/config.php
|
||||
|
||||
# 3rd party themes
|
||||
tpl/*
|
||||
!tpl/default
|
||||
!tpl/vintage
|
||||
|
||||
# Front end
|
||||
node_modules
|
||||
tpl/default/js
|
||||
tpl/default/css
|
||||
tpl/default/fonts
|
||||
tpl/default/img
|
||||
tpl/vintage/js
|
||||
tpl/vintage/css
|
||||
tpl/vintage/img
|
|
@ -1,23 +0,0 @@
|
|||
# EditorConfig: http://EditorConfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{htaccess,html,scss,js,json,xml,yml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.php]
|
||||
max_line_length = 120
|
||||
|
||||
[Dockerfile]
|
||||
max_line_length = 80
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
39
.gitattributes
vendored
39
.gitattributes
vendored
|
@ -10,38 +10,21 @@
|
|||
*.php text diff=php
|
||||
Dockerfile text
|
||||
|
||||
# Do not alter images nor minified scripts nor fonts
|
||||
# Do not alter images nor minified scripts
|
||||
*.ico binary
|
||||
*.jpg binary
|
||||
*.png binary
|
||||
*.svg binary
|
||||
*.otf binary
|
||||
*.eot binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.min.css binary
|
||||
*.min.js binary
|
||||
*.mo binary
|
||||
|
||||
# Exclude from Git archives
|
||||
.editorconfig export-ignore
|
||||
.dev export-ignore
|
||||
.gitattributes export-ignore
|
||||
.github export-ignore
|
||||
.gitignore export-ignore
|
||||
.travis.yml export-ignore
|
||||
doc/**/*.json export-ignore
|
||||
doc/**/*.md export-ignore
|
||||
.docker/ export-ignore
|
||||
.dockerignore export-ignore
|
||||
docker-compose.* export-ignore
|
||||
Dockerfile* export-ignore
|
||||
Doxyfile export-ignore
|
||||
Makefile export-ignore
|
||||
node_modules/ export-ignore
|
||||
doc/conf.py export-ignore
|
||||
doc/requirements.txt export-ignore
|
||||
doc/html/.doctrees/ export-ignore
|
||||
phpunit.xml export-ignore
|
||||
tests/ export-ignore
|
||||
.gitattributes export-ignore
|
||||
.gitignore export-ignore
|
||||
.travis.yml export-ignore
|
||||
doc/**/*.json export-ignore
|
||||
doc/**/*.md export-ignore
|
||||
docker/ export-ignore
|
||||
Doxyfile export-ignore
|
||||
Makefile export-ignore
|
||||
phpunit.xml export-ignore
|
||||
tests/ export-ignore
|
||||
|
|
22
.github/mailmap
vendored
22
.github/mailmap
vendored
|
@ -1,22 +0,0 @@
|
|||
ArthurHoaro <arthur@hoa.ro> <arthur.hoareau@wizacha.com>
|
||||
ArthurHoaro <arthur@hoa.ro> Arthur
|
||||
Florian Eula <eula.florian@gmail.com> feula
|
||||
Florian Eula <eula.florian@gmail.com> <mr.pikzen@gmail.com>
|
||||
Immánuel Fodor <immanuelfactor+github@gmail.com>
|
||||
Immánuel Fodor <immanuelfactor+github@gmail.com> Immánuel! <21174107+immanuelfodor@users.noreply.github.com>
|
||||
kalvn <kalvnthereal@gmail.com> <kalvn@users.noreply.github.com>
|
||||
kalvn <kalvnthereal@gmail.com> <kalvn@pm.me>
|
||||
Neros <contact@neros.fr> <NerosTie@users.noreply.github.com>
|
||||
Nicolas Danelon <hi@nicolasmd.com.ar> nicolasm
|
||||
Nicolas Danelon <hi@nicolasmd.com.ar> <nda@3818.com.ar>
|
||||
Nicolas Danelon <hi@nicolasmd.com.ar> <nicolasdanelon@gmail.com>
|
||||
Nicolas Danelon <hi@nicolasmd.com.ar> <nicolasdanelon@users.noreply.github.com>
|
||||
Sébastien Sauvage <sebsauvage@sebsauvage.net>
|
||||
Sébastien NOBILI <code@pipoprods.org> <s-code-github@pipoprods.org>
|
||||
Timo Van Neerden <fire@lehollandaisvolant.net>
|
||||
Timo Van Neerden <fire@lehollandaisvolant.net> lehollandaisvolant <levoltigeurhollandais@gmail.com>
|
||||
VirtualTam <virtualtam@flibidi.net> <tamisier.aurelien@gmail.com>
|
||||
VirtualTam <virtualtam@flibidi.net> <virtualtam+github@flibidi.net>
|
||||
VirtualTam <virtualtam@flibidi.net> <virtualtam@flibidi.org>
|
||||
Willi Eggeling <thewilli@gmail.com> <mail@wje-online.de>
|
||||
Willi Eggeling <thewilli@gmail.com> <thewilli@users.noreply.github.com>
|
106
.github/workflows/ci.yml
vendored
106
.github/workflows/ci.yml
vendored
|
@ -1,106 +0,0 @@
|
|||
name: Shaarli CI
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
php:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php-versions: ['7.4', '8.0', '8.1', '8.2']
|
||||
name: PHP ${{ matrix.php-versions }}
|
||||
steps:
|
||||
- name: Set locales
|
||||
run: |
|
||||
sudo locale-gen de_DE.utf8 && \
|
||||
sudo locale-gen en_US.utf8 && \
|
||||
sudo locale-gen fr_FR.utf8 && \
|
||||
sudo dpkg-reconfigure --frontend=noninteractive locales
|
||||
|
||||
- name: Install Gettext
|
||||
run: sudo apt-get install gettext
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php-versions }}
|
||||
extensions: gd, xml, curl, mbstring, intl, gettext
|
||||
tools: composer:v2
|
||||
|
||||
- name: Check PHP version
|
||||
run: php -v
|
||||
|
||||
- name: Setup Composer from PHP version + update
|
||||
run: composer config --unset platform && composer config platform.php ${{ matrix.php-versions }}
|
||||
|
||||
- name: Update dependencies for PHP 8.x
|
||||
if: ${{ matrix.php-versions == '8.0' || matrix.php-versions == '8.1' }}
|
||||
run: |
|
||||
composer update && \
|
||||
composer remove --dev phpunit/phpunit && \
|
||||
composer require --dev phpunit/php-text-template ^2.0 && \
|
||||
composer require --dev phpunit/phpunit ^9.0
|
||||
|
||||
- name: Update dependencies for PHP 7.x
|
||||
if: ${{ matrix.php-versions != '8.0' && matrix.php-versions != '8.1' }}
|
||||
run: composer update
|
||||
|
||||
- name: Clean up
|
||||
run: make clean
|
||||
|
||||
- name: Check permissions
|
||||
run: make check_permissions
|
||||
|
||||
- name: Run PHPCS
|
||||
run: make code_sniffer
|
||||
|
||||
- name: Run tests
|
||||
run: make all_tests
|
||||
|
||||
node:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14.x'
|
||||
|
||||
- name: Yarn install
|
||||
run: yarnpkg install
|
||||
|
||||
- name: Verify successful frontend builds
|
||||
run: yarnpkg run build
|
||||
|
||||
- name: JS static analysis
|
||||
run: make eslint
|
||||
|
||||
- name: Linter for SASS syntax
|
||||
run: make sasslint
|
||||
|
||||
python:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.8
|
||||
|
||||
- name: Build documentation
|
||||
run: make htmldoc
|
||||
|
||||
trivy-repo:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Run trivy scanner on repository (non-blocking)
|
||||
run: make test_trivy_repo TRIVY_EXIT_CODE=0
|
45
.github/workflows/docker-latest.yml
vendored
45
.github/workflows/docker-latest.yml
vendored
|
@ -1,45 +0,0 @@
|
|||
name: Build/push Docker image (master/latest)
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
jobs:
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set shaarli version to the latest commit hash
|
||||
run: sed -i "s/dev/$(git rev-parse --short HEAD)/" shaarli_version.php
|
||||
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: |
|
||||
${{ secrets.DOCKER_IMAGE }}:latest
|
||||
ghcr.io/${{ secrets.DOCKER_IMAGE }}:latest
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||
- name: Run trivy scanner on latest docker image
|
||||
run: make test_trivy_docker TRIVY_TARGET_DOCKER_IMAGE=ghcr.io/${{ secrets.DOCKER_IMAGE }}:latest
|
21
.github/workflows/docker-pr.yml
vendored
21
.github/workflows/docker-pr.yml
vendored
|
@ -1,21 +0,0 @@
|
|||
name: Build Docker image (Pull Request)
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Build Docker image
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
push: false
|
||||
tags: shaarli/shaarli:pr-${{ github.event.number }}
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
43
.github/workflows/docker-tags.yml
vendored
43
.github/workflows/docker-tags.yml
vendored
|
@ -1,43 +0,0 @@
|
|||
name: Build/push Docker image (tags/releases)
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
branches:
|
||||
- "v*.*"
|
||||
- release
|
||||
jobs:
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the tag name
|
||||
run: echo "REF=${GITHUB_REF##*/}" >> $GITHUB_ENV
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm/v7
|
||||
tags: |
|
||||
${{ secrets.DOCKER_IMAGE }}:${{ env.REF }}
|
||||
ghcr.io/${{ secrets.DOCKER_IMAGE }}:${{ env.REF }}
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
49
.gitignore
vendored
49
.gitignore
vendored
|
@ -13,61 +13,18 @@ pagecache
|
|||
*.rtpl.php
|
||||
|
||||
# 3rd-party dependencies
|
||||
composer.lock
|
||||
vendor/
|
||||
|
||||
# Release archives
|
||||
*.tar.gz
|
||||
*.tar
|
||||
*.zip
|
||||
inc/languages/*/LC_MESSAGES/shaarli.mo
|
||||
|
||||
# Development and test resources
|
||||
coverage
|
||||
doxygen
|
||||
sandbox
|
||||
phpmd.html
|
||||
phpdoc.xml
|
||||
.phpunit.result.cache
|
||||
trivy
|
||||
|
||||
# User plugin configuration
|
||||
plugins/*
|
||||
!addlink_toolbar
|
||||
!archiveorg
|
||||
!default_colors
|
||||
!demo_plugin
|
||||
!isso
|
||||
!myShaarli
|
||||
!piwik
|
||||
!playvideos
|
||||
!pubsubhubbub
|
||||
!qrcode
|
||||
!wallabag
|
||||
plugins/*/config.php
|
||||
plugins/default_colors/default_colors.css
|
||||
|
||||
# HTML documentation
|
||||
doc/html/
|
||||
doc/phpdoc/
|
||||
|
||||
# 3rd party themes
|
||||
tpl/*
|
||||
!tpl/default
|
||||
!tpl/vintage
|
||||
!tpl/myShaarli
|
||||
|
||||
contact.php
|
||||
formStyle.css
|
||||
|
||||
# Front end
|
||||
node_modules
|
||||
tpl/default/js
|
||||
tpl/default/css
|
||||
tpl/default/fonts
|
||||
tpl/default/img
|
||||
tpl/vintage/js
|
||||
tpl/vintage/css
|
||||
tpl/vintage/img
|
||||
|
||||
.composer.lock
|
||||
|
||||
# Documented scripts
|
||||
generate_templates.php
|
||||
|
|
37
.htaccess
37
.htaccess
|
@ -1,37 +0,0 @@
|
|||
# Disable directory listing
|
||||
Options -Indexes
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# Prevent accessing subdirectories not managed by SCM
|
||||
RewriteRule ^(.git|doxygen|vendor) - [F]
|
||||
|
||||
# Forward the "Authorization" HTTP header
|
||||
# fixes JWT token not correctly forwarded on some Apache/FastCGI setups
|
||||
RewriteCond %{HTTP:Authorization} ^(.*)
|
||||
RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]
|
||||
# Alternative (if the 2 lines above don't work)
|
||||
# SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0
|
||||
|
||||
# Slim URL Redirection
|
||||
# Ionos Hosting needs RewriteBase /
|
||||
# RewriteBase /
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^ index.php [QSA,L]
|
||||
|
||||
<LimitExcept GET POST PUT DELETE PATCH OPTIONS>
|
||||
<IfModule version_module>
|
||||
<IfVersion >= 2.4>
|
||||
Require all denied
|
||||
</IfVersion>
|
||||
<IfVersion < 2.4>
|
||||
Allow from none
|
||||
Deny from all
|
||||
</IfVersion>
|
||||
</IfModule>
|
||||
|
||||
<IfModule !version_module>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
</LimitExcept>
|
|
@ -1,23 +0,0 @@
|
|||
# .readthedocs.yml
|
||||
# Read the Docs configuration file
|
||||
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||
|
||||
# Required
|
||||
version: 2
|
||||
|
||||
# Build documentation in the "docs/" directory with Sphinx
|
||||
sphinx:
|
||||
configuration: doc/conf.py
|
||||
builder: html
|
||||
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.11"
|
||||
commands:
|
||||
- pip install sphinx==7.1.0 furo==2023.7.26 myst-parser sphinx-design
|
||||
- sphinx-build -b html -c doc/ doc/md/ _readthedocs/html/
|
||||
|
||||
python:
|
||||
install:
|
||||
- requirements: doc/requirements.txt
|
18
.travis.yml
Normal file
18
.travis.yml
Normal file
|
@ -0,0 +1,18 @@
|
|||
sudo: false
|
||||
language: php
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/.composer/cache
|
||||
php:
|
||||
- 7.0
|
||||
- 5.6
|
||||
- 5.5
|
||||
- 5.4
|
||||
- 5.3
|
||||
install:
|
||||
- composer self-update
|
||||
- composer install --prefer-dist
|
||||
script:
|
||||
- make clean
|
||||
- make check_permissions
|
||||
- make test
|
123
AUTHORS
123
AUTHORS
|
@ -1,123 +0,0 @@
|
|||
1216 ArthurHoaro <arthur@hoa.ro>
|
||||
456 nodiscc <nodiscc@gmail.com>
|
||||
405 VirtualTam <virtualtam@flibidi.net>
|
||||
56 Sébastien Sauvage <sebsauvage@sebsauvage.net>
|
||||
27 dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
|
||||
19 Keith Carangelo <mail@kcaran.com>
|
||||
16 Luce Carević <lcarevic@access42.net>
|
||||
15 Florian Eula <eula.florian@gmail.com>
|
||||
14 Emilien Klein <emilien@klein.st>
|
||||
12 Nicolas Danelon <hi@nicolasmd.com.ar>
|
||||
9 Lucas Cimon <lucas.cimon@gmail.com>
|
||||
9 Willi Eggeling <thewilli@gmail.com>
|
||||
8 Christophe HENRY <christophe.henry@sbgodin.fr>
|
||||
6 Immánuel Fodor <immanuelfactor+github@gmail.com>
|
||||
6 YFdyh000 <yfdyh000@gmail.com>
|
||||
6 kalvn <kalvnthereal@gmail.com>
|
||||
6 B. van Berkum <dev@dotmpe.com>
|
||||
6 Immánuel Fodor <immanuelfactor+github@gmail.com>
|
||||
6 YFdyh000 <yfdyh000@gmail.com>
|
||||
6 kalvn <kalvnthereal@gmail.com>
|
||||
6 llune <llune@users.noreply.github.com>
|
||||
5 Mark Schmitz <kramred@gmail.com>
|
||||
5 Sébastien NOBILI <code@pipoprods.org>
|
||||
4 Alexandre Alapetite <alexandre@alapetite.fr>
|
||||
4 yude <yudesleepy@gmail.com>
|
||||
4 David Sferruzza <david.sferruzza@gmail.com>
|
||||
4 yude <yudesleepy@gmail.com>
|
||||
3 Agurato <mail.vmonot@gmail.com>
|
||||
3 Christoph Stoettner <christoph.stoettner@stoeps.de>
|
||||
3 Olivier <bourreauolivier@gmail.com>
|
||||
3 Teromene <teromene@teromene.fr>
|
||||
3 yudete <yu@yude.moe>
|
||||
2 Alexander Railean <alexandr.railean@arculus.de>
|
||||
2 Alexandre G.-Raymond <alex@ndre.gr>
|
||||
2 Chris Kuethe <chris.kuethe@gmail.com>
|
||||
2 Doug Breaux <25640850+dougbreaux@users.noreply.github.com>
|
||||
2 Felix Bartels <felix@host-consultants.de>
|
||||
2 Ganesh Kandu <kanduganesh@gmail.com>
|
||||
2 Gregory <gregory@nosheep.fr>
|
||||
2 Guillaume Virlet <github@virlet.org>
|
||||
2 Knah Tsaeb <Knah-Tsaeb@knah-tsaeb.org>
|
||||
2 Mathieu Chabanon <git@matchab.fr>
|
||||
2 Miloš Jovanović <mjovanovic@gmail.com>
|
||||
2 Neros <contact@neros.fr>
|
||||
2 Qwerty <champlywood@free.fr>
|
||||
2 Sebastien Wains <sebw@users.noreply.github.com>
|
||||
2 Stephen Muth <smuth4@gmail.com>
|
||||
2 Timo Van Neerden <fire@lehollandaisvolant.net>
|
||||
2 flow.gunso <flow.gunso@gmail.com>
|
||||
2 julienCXX <software@chmodplusx.eu>
|
||||
2 Knah Tsaeb <Knah-Tsaeb@knah-tsaeb.org>
|
||||
2 philipp-r <philipp-r@users.noreply.github.com>
|
||||
2 pips <pips@e5150.fr>
|
||||
2 prog-it <pash.vld@gmail.com>
|
||||
2 trailjeep <trailjeep@gmail.com>
|
||||
1 leyrer <gitlab@leyrer.priv.at>
|
||||
1 locness3 <37651007+locness3@users.noreply.github.com>
|
||||
1 owen bell <66233223+xfnw@users.noreply.github.com>
|
||||
1 philipp <philipp@philipp.PC.Ubuntu>
|
||||
1 rfolo9li <50079896+rfolo9li@users.noreply.github.com>
|
||||
1 sprak3000 <sprak3000+github@gmail.com>
|
||||
1 yudejp <i@yude.jp>
|
||||
1 Rajat Hans <rajathans9@gmail.com>
|
||||
1 Adrien le Maire <adrien@alemaire.be>
|
||||
1 Ajabep <ajabep@users.noreply.github.com>
|
||||
1 Alexis J <alexis@effingo.be>
|
||||
1 Alistair Young <avatar@arkane-systems.net>
|
||||
1 Amadeous <amadeous@users.noreply.github.com>
|
||||
1 Angristan <angristan@users.noreply.github.com>
|
||||
1 Bish Erbas <42714627+bisherbas@users.noreply.github.com>
|
||||
1 BoboTiG <bobotig@gmail.com>
|
||||
1 Brendan M. Sleight <bms.git@barwap.com>
|
||||
1 Bronco <bronco@warriordudimanche.net>
|
||||
1 Buster One <37770318+buster-one@users.noreply.github.com>
|
||||
1 D Low <daniellowtw@gmail.com>
|
||||
1 Daniel Jakots <vigdis@chown.me>
|
||||
1 David <dajare@gmail.com>
|
||||
1 David Foucher <dev@tyjak.net>
|
||||
1 Denis Renning <denis@devtty.de>
|
||||
1 Dennis Verspuij <dennisverspuij@users.noreply.github.com>
|
||||
1 Dimtion <zizou.xena@gmail.com>
|
||||
1 Fanch <fanch-github@qth.fr>
|
||||
1 Felix Kästner <github.com-fpunktk@fpunktk.de>
|
||||
1 Florian Voigt <flvoigt@me.com>
|
||||
1 Franck Kerbiriou <FranckKe@users.noreply.github.com>
|
||||
1 Gary Marigliano <gmarigliano93@gmail.com>
|
||||
1 Hazhar Galeh <78073762+hazhargaleh@users.noreply.github.com>
|
||||
1 Hg <dev@indigo.re>
|
||||
1 Jens Kubieziel <github@kubieziel.de>
|
||||
1 Jonathan Amiez <jonathan.amiez@gmail.com>
|
||||
1 Jonathan Druart <jonathan.druart@gmail.com>
|
||||
1 Julien Pivotto <roidelapluie@inuits.eu>
|
||||
1 Kevin Canévet <kevin@streamroot.io>
|
||||
1 Kevin Masson <kevin.masson@methodinthemadness.eu>
|
||||
1 Knah Tsaeb <knah-tsaeb@knah-tsaeb.org>
|
||||
1 Lionel Martin <renarddesmers@gmail.com>
|
||||
1 Loïc Carr <zizou.xena@gmail.com>
|
||||
1 Mark Gerarts <mark.gerarts@gmail.com>
|
||||
1 Marsup <marsup@gmail.com>
|
||||
1 Nicolas Friedli <nicolas@theologique.ch>
|
||||
1 Nicolas Le Gaillart <nicolas@legaillart.fr>
|
||||
1 Paul van den Burg <github@paulvandenburg.nl>
|
||||
1 Rajat Hans <rajathans9@gmail.com>
|
||||
1 Sbgodin <Sbgodin@users.noreply.github.com>
|
||||
1 ToM <tom@leloop.org>
|
||||
1 TsT <tst2005@gmail.com>
|
||||
1 agentcobra <agentcobra@free.fr>
|
||||
1 aguy <aguytech@users.noreply.github.com>
|
||||
1 bschwede <bschwede@users.noreply.github.com>
|
||||
1 bschwede <gummibando@gmx.net>
|
||||
1 clach04 <clach04@gmail.com>
|
||||
1 dimtion <zizou.xena@gmail.com>
|
||||
1 durcheinandr <jochen@durcheinandr.de>
|
||||
1 heimpogo <hypertexthome@googlemail.com>
|
||||
1 jalr <mail@jalr.de>
|
||||
1 lapineige <lapineige@users.noreply.github.com>
|
||||
1 leyrer <gitlab@leyrer.priv.at>
|
||||
1 locness3 <37651007+locness3@users.noreply.github.com>
|
||||
1 owen bell <66233223+xfnw@users.noreply.github.com>
|
||||
1 philipp <philipp@philipp.PC.Ubuntu>
|
||||
1 rfolo9li <50079896+rfolo9li@users.noreply.github.com>
|
||||
1 sprak3000 <sprak3000+github@gmail.com>
|
||||
1 yudejp <i@yude.jp>
|
725
CHANGELOG.md
725
CHANGELOG.md
|
@ -4,705 +4,8 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [v0.13.0](https://github.com/shaarli/Shaarli/releases/tag/v0.12.3) - 2023-11-22
|
||||
|
||||
> Major changes:
|
||||
> - Security: Fix XSS vulnerability in tag search
|
||||
> - Drop support for PHP 7.1, 7.2 and 7.3
|
||||
|
||||
### Added
|
||||
* Docker build: add ARM64 platform and bump Github action version by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/1965
|
||||
* github actions: build OCI images that contain both amd64 and armv7 by @nodiscc in https://github.com/shaarli/Shaarli/pull/1962
|
||||
* Expose tags_separator config through /info API by @amadeous in https://github.com/shaarli/Shaarli/pull/1997
|
||||
* tools: github actions: build docker images on pull requests by @nodiscc in https://github.com/shaarli/Shaarli/pull/2014
|
||||
* doc: server configuration: add PHP 8.2 to PHP compatibility table by @nodiscc in https://github.com/shaarli/Shaarli/pull/2021
|
||||
* Add shaarli-stack theme to Community-and-related-software.md by @dajare in https://github.com/shaarli/Shaarli/pull/2028
|
||||
* doc: document general.download_max_size/timeout configuration settings by @nodiscc in https://github.com/shaarli/Shaarli/pull/2036
|
||||
* doc: troubleshooting: automatic title retrieval fails when it is set by javascript by @nodiscc in https://github.com/shaarli/Shaarli/pull/2037
|
||||
|
||||
### Changed
|
||||
* doc: update release procedure (merge the latest release to the release branch) + use the release branch for latest release version detection by @nodiscc in https://github.com/shaarli/Shaarli/pull/1960
|
||||
* Update german translation by @bschwede in https://github.com/shaarli/Shaarli/pull/1969
|
||||
* Update Server-configuration.md by @reinboldg in https://github.com/shaarli/Shaarli/pull/1973
|
||||
* Update Community-and-related-software.md by @nlegaillart in https://github.com/shaarli/Shaarli/pull/1984
|
||||
* doc: improve docs on usage of OR operator in tags search by @nodiscc in https://github.com/shaarli/Shaarli/pull/1987
|
||||
* docker: nginx: listen on IPv6 in addition to IPv4 by @cerebrate in https://github.com/shaarli/Shaarli/pull/1983
|
||||
* Doc update, WebSub (formerly PubSubHubbub) plugin by @clach04 in https://github.com/shaarli/Shaarli/pull/2008
|
||||
* doc: community/related software/integration with other platforms: add link to shaarli debian package by @nodiscc in https://github.com/shaarli/Shaarli/pull/2018
|
||||
* replace mkdocs with sphinx/myst-parser for HTML documentation generation, documentation improvements by @nodiscc in https://github.com/shaarli/Shaarli/pull/2025
|
||||
|
||||
### Fixed
|
||||
* Makefile: Use GNU tar if available by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/1957
|
||||
* Support: ignore disk_free_space if the function is unavailable by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/1970
|
||||
* Documentation: fix broken link to 3rd party plugins by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/1975
|
||||
* Fix autofocus: load bulk action input on linklist only by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/1976
|
||||
* doc: fix mkdocs build warnings/relative links by @nodiscc in https://github.com/shaarli/Shaarli/pull/2015
|
||||
* correct usage of hyphens in all occurences of 'super fast, database-free' by @nodiscc in https://github.com/shaarli/Shaarli/pull/2003
|
||||
|
||||
|
||||
### Removed
|
||||
* Drop support for PHP 7.1, 7.2 and 7.3 by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/1958
|
||||
* doc: themes: remove unmaintained themes by @nodiscc in https://github.com/shaarli/Shaarli/pull/2030
|
||||
* doc: remove bountysource badge by @nodiscc in https://github.com/shaarli/Shaarli/pull/2035
|
||||
|
||||
### Security
|
||||
* Fix XSS vulnerability in tag search by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/2039
|
||||
* tools: run trivy vulnerability scanner on the 'latest' docker image by @nodiscc in https://github.com/shaarli/Shaarli/pull/1980
|
||||
* github actions: fix value of TRIVY_TARGET_DOCKER_IMAGE by @nodiscc in https://github.com/shaarli/Shaarli/pull/1989
|
||||
* tools/CI: scan repository with trivy security scanner (yarn.lock, composer.lock) by @nodiscc in https://github.com/shaarli/Shaarli/pull/1998
|
||||
* tools/tests: update trivy to v0.44.0 by @nodiscc in https://github.com/shaarli/Shaarli/pull/2012
|
||||
* docker: update base alpine docker image to 3.16.7 by @nodiscc in https://github.com/shaarli/Shaarli/pull/2024
|
||||
|
||||
**Full Changelog**: https://github.com/shaarli/Shaarli/compare/v0.12.2...v0.12.3
|
||||
|
||||
## [v0.12.2](https://github.com/shaarli/Shaarli/releases/tag/v0.12.2) - 2023-03-18
|
||||
|
||||
> Docker: use `ghcr.io/shaarli/shaarli` as Docker image instead of `shaarli/shaarli`.
|
||||
> The `:master` Docker image has been removed, please use `:latest` instead.
|
||||
> The `:stable` Docker image has been removed, please use `:release` instead.
|
||||
|
||||
## Added
|
||||
|
||||
- Bulk action: add or delete tag to multiple bookmarks
|
||||
- New Core Plugin: ReadItLater
|
||||
- Plugin system: allow plugins to provide custom routes
|
||||
- Support search highlights when matching URL content
|
||||
- Support for OR (~) and optional AND (+) operators for tag search
|
||||
- Russian translation
|
||||
- Chinese translation
|
||||
- Export:
|
||||
- Export: set a bookmark's LAST_MODIFIED attribute to its update timestamp
|
||||
- Export: set a bookmark's PRIVATE attribute using an integer value
|
||||
- Add an additional free disk space check before saving the datastore
|
||||
- curl: support HTTP/2 response code header
|
||||
- CI:
|
||||
- Build and push Docker images through Github Actions
|
||||
- push container images to github registry in addition to dockerhub
|
||||
- Documentation:
|
||||
- Add '206 not acceptable' to the Troubleshooting section
|
||||
- Add mention to Shaarli Archiver
|
||||
- doc: add note to adjust proxy timeouts or PHP max execution time
|
||||
- doc: shaarli configuration: mention file:/// URIs
|
||||
- add "formatter" key to example config.json.php
|
||||
|
||||
## Changed
|
||||
|
||||
- docker latest: replace dev in shaarli_version.php with the latest commit
|
||||
- Daily RSS Cache: invalidate cache base on the date
|
||||
- Update Japanese translations
|
||||
- Update German translations
|
||||
- Templates: Inject current template name
|
||||
- format_date: include timezone in IntlDateFormatter object
|
||||
- Handle pagination through BookmarkService
|
||||
- autocapitalize off for username input
|
||||
- More intuitive label for plugin checkboxes
|
||||
- Simple and uniform localized website title
|
||||
- Use rewrited version of Netscape Bookmark Parser
|
||||
- tests/makefile: rewrite translate target to be compatible with busybox
|
||||
- PubSubHub Plugin: make 1 external call per request
|
||||
- Docker:
|
||||
- newer alpine (for newer PHP) and apk upgrade
|
||||
- Dockerfile.armhf: upgrade python2 -> python3
|
||||
- Dockerfile: add php8-gettext package
|
||||
- update s6 service definition to use php-fpm8
|
||||
- install php8-ldap in Docker images
|
||||
- CI:
|
||||
- use Github Action instead of Travis CI
|
||||
- use the yarnpkg command instead of yarn
|
||||
- tools: github actions: fix PHP 8.0 tests
|
||||
- github actions: add tests for PHP 8.2
|
||||
- Documentation:
|
||||
- apache: explicitely ste index.php as DirectoryIndex
|
||||
- bookmarklet is now working on github.com
|
||||
- LDAP login support, update php requirements list
|
||||
- installation/tests: clarify build tools installation procedure
|
||||
- doc: PHP extensions are also required for development
|
||||
- doc: move OCI images hosting to ghcr.io
|
||||
|
||||
## Fixed
|
||||
|
||||
- Error handling if the datastore mutex is not working
|
||||
- Synchronous metadata retrieval is failing in strict mode
|
||||
- Improve metadata extraction
|
||||
- Typo: 'Authentication' ->
|
||||
- default_colors plugin: update CSS file on color change
|
||||
- API: POST/PUT Link - properly parse tags string
|
||||
- Error when using bulk shaare with a single URL
|
||||
- Bulk Shaare:
|
||||
- use unique HTML ID
|
||||
- error with a single URL
|
||||
- redirection with ending slash
|
||||
- Bug when trying to access ATOM feed without bookmarks
|
||||
- Documentation build
|
||||
- pubsubhubbub hub link in RSS / Atom.
|
||||
- Monthly views previous/next month links during month
|
||||
- Resolve PHP 8.1 deprecation warnings
|
||||
- Fix PHP 8 incompatibility with debug mode enabled
|
||||
- Fixed Roboto-Regular and Roboto-Bold font declarations
|
||||
- template/vintage: fix typo in visibility selection link
|
||||
- Do not display deprecated warnings by default
|
||||
- Fix a bug when using '/' as a tag separator
|
||||
- Fix Logger exception: gracefully handle permission issue
|
||||
- Documentation:
|
||||
- plugins.md: fix link casing
|
||||
|
||||
## Removed
|
||||
|
||||
- Daily RSS: Remove relative description (today, yesterday)
|
||||
- Documentation:
|
||||
- remove the markdown plugin from the plugins list
|
||||
- remove duplicate "general" key in example config.php.json
|
||||
|
||||
## [v0.12.1](https://github.com/shaarli/Shaarli/releases/tag/v0.12.1) - 2020-11-12
|
||||
|
||||
> nginx ([#1628](https://github.com/shaarli/Shaarli/pull/1628)) and Apache ([#1630](https://github.com/shaarli/Shaarli/pull/1630)) configurations have been reviewed. It is recommended that you
|
||||
> update yours using [the documentation](https://shaarli.readthedocs.io/en/master/Server-configuration/).
|
||||
> Users using official Docker image will receive updated configuration automatically.
|
||||
|
||||
### Added
|
||||
- Bulk creation of bookmarks
|
||||
- Server administration tool page (and install page requirements)
|
||||
- Support any tag separator, not just whitespaces
|
||||
- Share a private bookmark using a URL with a token
|
||||
- Add a setting to retrieve bookmark metadata asynchronously (enabled by default)
|
||||
- Highlight fulltext search results
|
||||
- Weekly and monthly view/RSS feed for daily page
|
||||
- MarkdownExtra formatter
|
||||
- Default formatter: add a setting to disable auto-linkification
|
||||
- Add mutex on datastore I/O operations to prevent data loss
|
||||
- PHP 8.0 support
|
||||
- REST API: allow override of creation and update dates
|
||||
- Add strict types for bookmarks management
|
||||
|
||||
### Changed
|
||||
- Improve regex and performances to extract HTML metadata (title, description, etc.)
|
||||
- Support using Shaarli without URL rewriting (prefix URL with `/index.php/`)
|
||||
- Improve the "Manage tags" tools page
|
||||
- Use PSR-3 logger for login attempts
|
||||
- Move utils classes to Shaarli\Helper namespace and folder
|
||||
- Include php-simplexml in Docker image
|
||||
- Raise 404 error instead of 500 if permalink access is denied
|
||||
- Display error details even with dev.debug set to false
|
||||
- Reviewed nginx configuration
|
||||
- Reviewed Apache configuration
|
||||
- Replace vimeo link in demo bookmarks due to IP ban on the demo instance
|
||||
- Apply PSR-12 on code base, and add CI check using PHPCS
|
||||
|
||||
### Fixed
|
||||
- Compatiliby issue on login with PHP 7.1
|
||||
- Japanese translations update
|
||||
- Redirect to referrer after bookmark deletion
|
||||
- Inject ROOT_PATH in plugin instead of regenerating it everywhere
|
||||
- Wallabag plugin: minor improvements
|
||||
- REST API postLink: change relative path to absolute path
|
||||
- Webpack: fix vintage theme images include
|
||||
- Docker-compose: fix SSL certificate + add parameter for Docker tag
|
||||
|
||||
### Removed
|
||||
- `config.json.php` new lines in prefix/suffix to prevent issues with Windows PHP
|
||||
|
||||
## [v0.12.0](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0) - 2020-10-13
|
||||
|
||||
**Save you `data/` folder before updating!**
|
||||
|
||||
### Added
|
||||
- Thumbnailer: add soundcloud.com to list of common media domains
|
||||
- Markdown rendering is now integrated into Shaarli core
|
||||
- Add autofocus on tag cloud filter input
|
||||
- Japanese translations
|
||||
- Japanese translation: add language to admin configuration page
|
||||
- Support for PHP 8.0
|
||||
- Support for local anchor URL (starting with `#`)
|
||||
- LDAP authentication
|
||||
- Encapsulated PageCacheManager
|
||||
- Docs:
|
||||
- add screenshots of all pages
|
||||
- section about mkdocs
|
||||
- Ulauncher extension
|
||||
- CI: run against PHP 7.4
|
||||
- Added $links_per_page variable to template and display on default
|
||||
- Inject BookmarkServiceInterface in plugins data
|
||||
- Add manual configuration for root URL
|
||||
- Added PATCH to the allowed Apache request methods.
|
||||
- REST API: compatibility with ionos Apache's headers
|
||||
|
||||
### Changed
|
||||
- Introduce Bookmark object and Service layer
|
||||
- Save bookmark as objects in the datastore
|
||||
- Handle bookmark as objects across the whole codebase (except templates and plugins)
|
||||
- Process all Shaarli page through Slim controller, with proper URL rewriting (see #1516)
|
||||
- Docs: the entire documentation has been reviewed, updated and improved, thanks to @nodiscc!
|
||||
- ATOM feed: use instance name as author name instead of URL
|
||||
- Updated French translation
|
||||
- Default colors plugin: generate CSS file during initialization
|
||||
- Improve default bookmarks after install
|
||||
- Upgrade all front end dependencies and webpack build
|
||||
- Default theme: Make tag cloud/list views buttons more obvious
|
||||
|
||||
### Fixed
|
||||
- Undefined index: thumbnail in daily page
|
||||
- Undefined index: thumbnail on OpenGraph headers
|
||||
- Undefined index: updated on linklist
|
||||
- Make sure that bookmark sort is consistent, even with equal timestamps
|
||||
- Code PHP version check as requirement bumped to PHP 7.1
|
||||
- Thumbnail images lazy loading
|
||||
- Markdown plugin: fix RSS feed direct link reverse
|
||||
- Fix RSS permalink included in Markdown bloc
|
||||
- Demo plugin: multiple typos
|
||||
- Makefile target for releases
|
||||
- Makefile target for html documentation
|
||||
- Session cookie setting being set while session is active
|
||||
- Deprecated use of implode
|
||||
- Division by zero in tag cloud
|
||||
- CI: deprecated linux distribution and sudo directive
|
||||
- Docker build: gcc is no longer included in python alpine image
|
||||
- Default template: display pin button in mobile view
|
||||
- Pinned bookmarks are not longer displayed first in ATOM/RSS feeds
|
||||
- Docs:
|
||||
- Outdated Docker documentation for stable branch
|
||||
- Outdated links
|
||||
- Plugin description in meta files
|
||||
- docker-compose.yml: pin traefik image to 1.7-alpine
|
||||
|
||||
### Removed
|
||||
- Markdown plugin
|
||||
- Docs:
|
||||
- emojione & twemoji removed
|
||||
- Makefile: remove static_analysis_summary from all: target
|
||||
- doc/Makefile: remove references to composer update
|
||||
|
||||
## [v0.11.1](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1) - 2019-08-03
|
||||
|
||||
Release to fix broken Docker build on the latest version.
|
||||
|
||||
### Fixed
|
||||
- Fixed Docker build
|
||||
- Fixed a few documentation broken links
|
||||
- Fixed broken label in configuration page
|
||||
|
||||
### Added
|
||||
- More accessibility improvements
|
||||
|
||||
## [v0.11.0](https://github.com/shaarli/Shaarli/releases/tag/v0.11.0) - 2019-07-27
|
||||
|
||||
**Shaarli no longer officially support PHP 5.6 and PHP 7.0 as they've reached end of life.**
|
||||
|
||||
**Shaarli classes now use namespace, third party plugins need to update.**
|
||||
|
||||
### Added
|
||||
- Add optional PHP extension to composer suggestions.
|
||||
- composer: enforce PHP security advisories
|
||||
- phpDocumentor configuration and make target
|
||||
- Run unit tests against PHP 7.3
|
||||
- Bunch of accessibility improvements to the default template, thanks to @llune
|
||||
- Bulk actions: set visibility
|
||||
- Display sticky label in linklist
|
||||
- Add print CSS rules to the default template
|
||||
- New setting to automatically retrieve description for new bookmarks
|
||||
- Plugin to override default template colors
|
||||
|
||||
### Changed
|
||||
- Shaarli now uses namespaces for its classes.
|
||||
- Rewrite IP ban management
|
||||
- Default template: slightly lighten visited link color
|
||||
- Hide select all button on mobile view
|
||||
- Switch from FontAwesome v4.x to ForkAwesome
|
||||
- Daily - display the current day instead of the previous one
|
||||
|
||||
### Fixed
|
||||
- Do not check the IP address with session protection disabled
|
||||
- API: update test regexes to comply with PCRE2
|
||||
- Optimize and cleanup imports
|
||||
- ensure HTML tags are stripped from OpenGraph description
|
||||
- Documentation invalid links
|
||||
- Thumbnails disabling if PHP GD is not installed
|
||||
- Warning if links sticky status isn't set
|
||||
- Fix button overlapping on mobile in linklist
|
||||
- Do not try to retrieve thumbnails for internal link
|
||||
- Update node-sass to fix a vulnerability in node tar dependency
|
||||
- armhf Dockerfile
|
||||
- Default template: Responsive issue with delete button fix
|
||||
- Persist sticky status on bookmark update
|
||||
|
||||
### Removed
|
||||
- Doxygen configuration
|
||||
- redirector setting
|
||||
- QRCode link to an external service
|
||||
|
||||
## [v0.10.4](https://github.com/shaarli/Shaarli/releases/tag/v0.10.4) - 2019-04-16
|
||||
### Fixed
|
||||
- Fix thumbnails disabling if PHP GD is not installed
|
||||
- Fix a warning if links sticky status isn't set
|
||||
|
||||
## [v0.10.3](https://github.com/shaarli/Shaarli/releases/tag/v0.10.3) - 2019-02-23
|
||||
### Added
|
||||
- Add OpenGraph metadata tags on permalink page
|
||||
- Add CORS headers to REST API reponses
|
||||
- Add a button to toggle checkboxes of displayed links
|
||||
- Add an icon to the link list when the Isso plugin is enabled
|
||||
- Add noindex, nofollow to documentation pages
|
||||
- Document usage of robots.txt
|
||||
- Add a button to set links as sticky
|
||||
|
||||
### Changed
|
||||
- Update French translation
|
||||
- Refactor the documentation homepage
|
||||
- Bump netscape-bookmark-parser
|
||||
- Update session_start condition
|
||||
- Improve accessibility
|
||||
- Cleanup and refactor lint tooling
|
||||
|
||||
### Fixed
|
||||
- Fix input size for dropdown search form
|
||||
- Fix history for bulk link deletion
|
||||
- Fix thumbnail requests
|
||||
- Fix hashtag rendering when markdown escaping is enabled
|
||||
- Fix AJAX tag deletion
|
||||
- Fix lint errors and improve PSR-1 and PSR-2 compliance
|
||||
|
||||
### Removed
|
||||
- Remove Firefox Share documentation
|
||||
|
||||
## [v0.10.2](https://github.com/shaarli/Shaarli/releases/tag/v0.10.2) - 2018-08-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- Docker build
|
||||
|
||||
## [v0.10.1](https://github.com/shaarli/Shaarli/releases/tag/v0.10.1) - 2018-08-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Accessibility:
|
||||
- Remove alt text on the logo
|
||||
- Remove redundant title in tools page
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an error on the daily page and daily RSS
|
||||
- Fixed an issue causing 'You are not authorized to add a link' error while logged out
|
||||
- Fixed thumbnail path when Shaarli's path uses symbolic links
|
||||
- Add a `mod_version` check in Shaarli's root `.htaccess` file for Apache 2.2 syntax
|
||||
- Include assets in the release Makefile target
|
||||
|
||||
### Removed
|
||||
|
||||
- Firefox Social API shaare has been removed
|
||||
|
||||
## [v0.10.0](https://github.com/shaarli/Shaarli/releases/tag/v0.10.0) - 2018-07-28
|
||||
**PHP 5.5 compatibility has been dropped.** Shaarli now requires at least PHP 5.6.
|
||||
|
||||
### Added
|
||||
- Add a filter to display public links only
|
||||
- Add PHP 7.2 support
|
||||
- Add German translation
|
||||
- Resolve front-end dependencies from NPM
|
||||
- Build front-end bundles with Yarn and Webpack
|
||||
- Lint Javascript code with ESLint
|
||||
- Lint SASS code with SASSLint
|
||||
- Support redirection in cURL download callback
|
||||
- Introduce multi-stage builds for Docker images
|
||||
- Use Travis matrix and stages to run Javascript tests in a dedicated environment
|
||||
- Add tag endpoint in the REST API
|
||||
- Build the documentation in Travis builds
|
||||
- Provide a Docker Compose example
|
||||
|
||||
### Changed
|
||||
- Use web-thumbnailer to retrieve thumbnails (see #687)
|
||||
- Use a specific page title in all pages
|
||||
- Daily: run hooks before creating the columns
|
||||
- Load theme translations files automatically
|
||||
- Make max download size and timeout configurable
|
||||
- Make Nginx logs accessible as stdout/stderr for Docker images
|
||||
- Update buttons used to toggle link visibility filters
|
||||
- Rewrite Javascript code for ES6 compliance
|
||||
- Refactor IP ban management
|
||||
- Refactor user login management
|
||||
- Refactor server-side session management
|
||||
- Update Doxygen configuration
|
||||
- Update Parsedown
|
||||
- Improve documentation
|
||||
- Docker: build the images from the local sources
|
||||
- Docker: bump alpine version to 3.7
|
||||
- Docker: expose a volume for the thumbnail cache
|
||||
|
||||
### Removed
|
||||
- Drop support for PHP 5.5
|
||||
- Remove vendored front-end libraries
|
||||
- Remove environment specific .gitignore entries
|
||||
|
||||
### Fixed
|
||||
- Ignore the case while checking DOCTYPE during the file import
|
||||
- Fix removal of on=... attributes from html generated from Markdown
|
||||
- httpd: always forward the 'Authorization' header
|
||||
- Ensure user-specific CSS file is loaded
|
||||
- Fix feed permalink rendering when Markdown escaping is enabled
|
||||
- Fix order of tags with the same number of occurrences
|
||||
- Fixed the referrer meta tag in default template
|
||||
- Disable MkDocs' strict mode for ReadTheDocs builds to pass
|
||||
- fix and simplify Dockerfile for armhf
|
||||
|
||||
### Security
|
||||
- Update `.htaccess` to prevent accessing Git metadata when using a Git-based installation
|
||||
|
||||
|
||||
## [v0.9.7](https://github.com/shaarli/Shaarli/releases/tag/v0.9.7) - 2018-06-20
|
||||
### Changed
|
||||
- Build the Docker images from the local Git sources
|
||||
|
||||
|
||||
## [v0.9.6](https://github.com/shaarli/Shaarli/releases/tag/v0.9.6) - 2018-03-25
|
||||
### Changed
|
||||
- htaccess: prevent accessing resources not managed by SCM
|
||||
- htaccess: always forward the 'Authorization' HTTP header
|
||||
|
||||
|
||||
## [v0.9.5](https://github.com/shaarli/Shaarli/releases/tag/v0.9.5) - 2018-02-02
|
||||
### Fixed
|
||||
- Fix a warning happening when `php-intl` is not installed on the system
|
||||
- Fix warnings happening when updating from legacy SebSauvage version
|
||||
|
||||
## [v0.9.4](https://github.com/shaarli/Shaarli/releases/tag/v0.9.4) - 2018-01-30
|
||||
### Added
|
||||
- Enable translations: Shaarli is now also available in French. Other language translations are welcome!
|
||||
- Add EditorConfig configuration
|
||||
- Add favicons for mobile devices
|
||||
- Add Alpine Linux arm32v7 Dockerfiles (master, latest)
|
||||
|
||||
### Changed
|
||||
- Do not write bookmark edition history during file imports (performance)
|
||||
- Migrate Docker images (master, latest) to Alpine Linux
|
||||
- Improve unitary tests and code coverage
|
||||
- Improve thumbnail display
|
||||
- Improve theme ergonomics
|
||||
- Improve messages if there is no plugin or parameter available in the admin page
|
||||
- Increase buffer size for cURL download
|
||||
- Force HTTPS if the original port is 443 behind a reverse proxy (workaround)
|
||||
- Improve page title retrieval performances
|
||||
|
||||
### Removed
|
||||
- Remove redirector setting from Configure page
|
||||
|
||||
### Fixed
|
||||
- Fix broken links in the documentation
|
||||
- Enable access to `data/user.css` (Apache 2.2 & 2.4)
|
||||
- Don't URL encode description links if parameter `redirector.encode_url` is set to false
|
||||
- Fix an issue preventing the Save button to appear for plugin parameters
|
||||
|
||||
|
||||
## [v0.9.3](https://github.com/shaarli/Shaarli/releases/tag/v0.9.3) - 2018-01-04
|
||||
**XSS vulnerability fixed. Please update.**
|
||||
|
||||
## Security
|
||||
- Fix an XSS (cross-site-scripting) vulnerability in `index.php` -
|
||||
[CVE-2018-5249](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-5249)
|
||||
|
||||
|
||||
## [v0.9.2](https://github.com/shaarli/Shaarli/releases/tag/v0.9.2) - 2017-10-07
|
||||
|
||||
**Major security issue fixed. Please update.**
|
||||
|
||||
### Added
|
||||
- Tag search now supports wildcards `*`
|
||||
- New setting `privacy.force_login` which can be used with `privacy.hide_public_links` to redirect anonymous users to the login page.
|
||||
- New setting `general.default_note_title` used to override default `Note:` title prefix for notes.
|
||||
- Add a version hash for asset loading to prevent browser's cache issue
|
||||
|
||||
### Changed
|
||||
- The "Remember me" checkbox is unchecked by default
|
||||
- The default value of the "Remember me" checkbox can be configured under `data/config.json.php`
|
||||
|
||||
### Removed
|
||||
- Remove obsolete PHP magic quote support
|
||||
|
||||
### Fixed
|
||||
- Generates a permalink URL if the URL is set to blank
|
||||
- Replace links to the old GitHub wiki with ReadTheDocs URIs
|
||||
- Use single quotes in the note bookmarklet
|
||||
- Daily page if there is no link
|
||||
- Bulk link deletion with a single link
|
||||
- HTTPS detection behind a reverse proxy
|
||||
- Travis tests environment and localization
|
||||
- Improve template paths robustness (trailing slash)
|
||||
- Robustness: safer gzinflate/zlib usage
|
||||
- Description links parsing with parenthesis (without Markdown)
|
||||
- Templates:
|
||||
- Sort the tag cloud alphabetically
|
||||
- Firefox social title
|
||||
- Improved visited link color
|
||||
- Fix jumpy textarea with long content in post edit
|
||||
|
||||
### Security
|
||||
|
||||
- Fixed reflected XSS vulnerability introduced in v0.9.1, discovered by @chb9 ([CVE-2017-15215](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-15215)).
|
||||
|
||||
|
||||
## [v0.9.1](https://github.com/shaarli/Shaarli/releases/tag/v0.9.1) - 2017-08-23
|
||||
|
||||
The documentation has been migrated to ReadTheDocs:
|
||||
- https://shaarli.readthedocs.io/
|
||||
- edits are submitted as pull requests
|
||||
|
||||
### Added
|
||||
- Allow bulk link deletion
|
||||
- Display subtags in the tag cloud
|
||||
- Add an endpoint to refresh the token
|
||||
- Add a token on every page
|
||||
- Add a tag list view for management
|
||||
- Add Note bookmarklet
|
||||
- Add creation date when editing a link
|
||||
|
||||
### Changed
|
||||
- Documentation:
|
||||
- Generate static HTML documentation with [mkdocs](http://www.mkdocs.org/)
|
||||
- Host documentation on [ReadTheDocs](http://www.mkdocs.org/)
|
||||
- Update documentation structure
|
||||
- Update Makefile targets to:
|
||||
- Build the docs locally
|
||||
- Include the generated docs in the release archives
|
||||
- Theme:
|
||||
- Use the new theme as the default
|
||||
- Rename the tag cloud template to `tag.cloud.html`
|
||||
- Display visited links in grey
|
||||
- Use only one search form in `linklist.html`
|
||||
- Hide the "search links with these tags" option when an empty `searchtags` is passed to `tag.list.html`
|
||||
- Improve HTTP header handling when hosting Shaarli with Docker behind a reverse proxy
|
||||
- Searching for tags with an empty value returns untagged links only
|
||||
- Set Travis environment to `precise` until the new `trusty` environment is ready
|
||||
|
||||
### Removed
|
||||
- Remove dead Pubsubhubbub code
|
||||
- Disable the GitHub wiki (see changed/documentation)
|
||||
- Remove Docker `dev` image and resources
|
||||
- Theme:
|
||||
- Remove the bottom "Sort by" menu in `tag.list.html`
|
||||
|
||||
### Fixed
|
||||
- Fix file existence check for `user.css`
|
||||
- Limit selection to 2k characters when using the bookmarklet
|
||||
- Fix JS error `uncaught type error`
|
||||
- Fix Firefox Social button
|
||||
- Use pinned PHP dependencies when generating release archives
|
||||
- Make sure that the tag exists before altering/removing it
|
||||
|
||||
### Security
|
||||
- Add a whitelist for protocols for URLs
|
||||
|
||||
|
||||
## [v0.9.0](https://github.com/shaarli/Shaarli/releases/tag/v0.9.0) - 2017-05-07
|
||||
|
||||
This release introduces the REST API, and requires updating HTTP server
|
||||
configuration to enable URL rewriting, see:
|
||||
- https://shaarli.github.io/api-documentation/
|
||||
- https://shaarli.readthedocs.io/en/master/Server-configuration/
|
||||
|
||||
**WARNING**: Shaarli now requires PHP 5.5+.
|
||||
|
||||
### Added
|
||||
- REST API v1
|
||||
- [Slim](https://www.slimframework.com/) framework
|
||||
- [JSON Web Token](https://jwt.io/introduction/) (JWT) authentication
|
||||
- versioned API endpoints:
|
||||
- `/api/v1/info`: get general information on the Shaarli instance
|
||||
- `/api/v1/links`: get a list of shaared links
|
||||
- `/api/v1/history`: get a list of latest actions
|
||||
- Theming:
|
||||
- Introduce a new theme
|
||||
- Allow selecting themes/templates from the configuration page
|
||||
- New/Edit link form can be submitted using CTRL+Enter in the textarea
|
||||
- Shaarli version is displayed in the footer when logged in
|
||||
- Add plugin placeholders to Atom/RSS feed templates
|
||||
- Add OpenSearch to feed templates
|
||||
- Add `campaign_` to the URL cleanup pattern list
|
||||
- Add an AUTHORS file and Makefile target to list authors from Git commit data
|
||||
- Link imports are now logged in `data/` folder, and can be debug using `dev.debug=true` setting.
|
||||
- `composer.lock` is now included in git file to allow proper `composer install`
|
||||
- History mechanism which logs link addition/modification/deletion
|
||||
|
||||
### Changed
|
||||
- Docker: enable nginx URL rewriting for the REST API
|
||||
- Theming:
|
||||
- Move `user.css` to the `data` folder
|
||||
- Move default template files to a subfolder (`default`)
|
||||
- Rename the legacy theme to `vintage`
|
||||
- Private only filter is now displayed as a search parameter
|
||||
- Autocomplete: pre-select the first element
|
||||
- Display daily date in the page title (browser title)
|
||||
- Timezone lists are now passed as an array instead of raw HTML
|
||||
- Move PubSubHub to a dedicated plugin
|
||||
- Coding style:
|
||||
- explicit method visibility
|
||||
- safe boolean comparisons
|
||||
- remove unused variables
|
||||
- The updater now keeps custom theme preferences
|
||||
- Simplify the COPYING information
|
||||
- Improved client locale detection
|
||||
- Improved date time display depending on the locale
|
||||
- Partial namespace support for Shaarli classes
|
||||
- Shaarli version is now only present in `shaarli_version.php`
|
||||
- Human readable maximum file size upload
|
||||
|
||||
|
||||
### Removed
|
||||
- PHP < 5.5 compatibility
|
||||
- ReadItYourself plugin
|
||||
|
||||
### Fixed
|
||||
- Ignore generated release tarballs
|
||||
- Hide default port when behind a reverse proxy
|
||||
- Fix a typo in the Markdown plugin description
|
||||
- Fix the presence of empty tags for private tags and in search results
|
||||
- Fix a fatal error during the install
|
||||
- Fix permalink image alignment in daily page
|
||||
- Fix the delete button in `editlink`
|
||||
- Fix redirection after link deletion
|
||||
- Do not access LinkDB links by ID before the Updater applies migrations
|
||||
- Remove extra spaces in the bookmarklet's name
|
||||
- Piwik plugin: Piwik URL protocol can now be set (http or https)
|
||||
- All inline JS has been moved to dedicated JS files
|
||||
- Keep tags after login redirection
|
||||
|
||||
### Security
|
||||
- Markdown plugin: escape HTML entities by default
|
||||
|
||||
|
||||
## [v0.8.7](https://github.com/shaarli/Shaarli/releases/tag/v0.8.7) - 2018-06-20
|
||||
### Changed
|
||||
- Build the Docker image from the local Git sources
|
||||
|
||||
### Removed
|
||||
- Disable PHP 5.3 Travis build (unsupported)
|
||||
|
||||
|
||||
## [v0.8.6](https://github.com/shaarli/Shaarli/releases/tag/v0.8.6) - 2018-02-19
|
||||
### Changed
|
||||
- Run version check tests against the 'stable' branch
|
||||
|
||||
|
||||
## [v0.8.5](https://github.com/shaarli/Shaarli/releases/tag/v0.8.5) - 2018-01-04
|
||||
**XSS vulnerability fixed. Please update.**
|
||||
|
||||
## Security
|
||||
- Fix an XSS (cross-site-scripting) vulnerability in `index.php` -
|
||||
[CVE-2018-5249](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-5249)
|
||||
|
||||
## [v0.8.4](https://github.com/shaarli/Shaarli/releases/tag/v0.8.4) - 2017-03-04
|
||||
### Security
|
||||
- Markdown plugin: escape HTML entities by default
|
||||
|
||||
## [v0.8.3](https://github.com/shaarli/Shaarli/releases/tag/v0.8.3) - 2017-01-20
|
||||
### Fixed
|
||||
- PHP 7.1 compatibility: add ConfigManager parameter to anti-bruteforce function call in login template.
|
||||
|
||||
## [v0.8.2](https://github.com/shaarli/Shaarli/releases/tag/v0.8.2) - 2016-12-15
|
||||
### Fixed
|
||||
|
||||
- Editing a link created before the new ID system would change its permalink.
|
||||
|
||||
## [v0.8.1](https://github.com/shaarli/Shaarli/releases/tag/v0.8.1) - 2016-12-12
|
||||
|
||||
> Note: this version will create an automatic backup of your database if anything goes wrong.
|
||||
|
||||
## [v0.8.1](https://github.com/shaarli/Shaarli/releases/tag/v0.8.1) - UNPUBLISHED
|
||||
### Added
|
||||
- Add CHANGELOG.md to track the whole project's history
|
||||
- Enable Composer cache for Travis builds
|
||||
|
@ -715,14 +18,7 @@ configuration to enable URL rewriting, see:
|
|||
- Meta tag to *not* send the referrer to external resources.
|
||||
|
||||
### Changed
|
||||
- Link ID complete refactoring:
|
||||
- Links now have a numeric ID instead of dates
|
||||
- Short URLs are now created once and can't change over time (previous URL are kept)
|
||||
- Templates:
|
||||
- Changed placeholder behaviour for: `buttons_toolbar`, `fields_toolbar` and `action_plugin`
|
||||
- Cleanup `{loop}` declarations in templates
|
||||
- Tools: hide Firefox Social button when not in HTTPS
|
||||
- Firefox Social: show Shaarli's title when shaaring using Firefox Social
|
||||
- Cleanup `{loop}` declarations in templates
|
||||
- Release archives now have the same structure as GitHub-generated archives:
|
||||
- archives contain a `Shaarli` directory, itself containing sources + dependencies
|
||||
- the tarball is now gzipped
|
||||
|
@ -730,19 +26,20 @@ configuration to enable URL rewriting, see:
|
|||
- Markdown: Parsedown library is now imported through Composer
|
||||
- Minor code cleanup: PHPDoc, spelling, unused variables, etc.
|
||||
- Docker: explicitly set the maximum file upload size to 10 MiB
|
||||
- Tools: hide Firefox Social button when not in HTTPS
|
||||
- Firefox Social: show Shaarli's title when shaaring using Firefox Social
|
||||
|
||||
### Fixed
|
||||
- Fix the server `<self>` value in Atom/RSS feeds
|
||||
- Plugins:
|
||||
- Tools: only display parameter description when it exists
|
||||
- archive.org: do not propose archival of private notes
|
||||
- Markdown:
|
||||
- Markdown:
|
||||
- render links properly in code blocks
|
||||
- bug regarding the `nomarkdown` tag
|
||||
- W3C compliance
|
||||
- Use absolute URL for hashtags in RSS and ATOM feeds
|
||||
- Docker: specify the location of the favicon
|
||||
- ATOM feed: remove new line between content tag and data
|
||||
|
||||
### Security
|
||||
- Allow whitelisting trusted IPs, else continue banning clients upon login failure
|
||||
|
@ -784,10 +81,6 @@ Please use our release archives, or follow the
|
|||
- XSRF token now generated each time a page is rendered
|
||||
|
||||
|
||||
## [v0.7.1](https://github.com/shaarli/Shaarli/releases/tag/v0.7.1) - 2017-03-08
|
||||
### Security
|
||||
- Markdown plugin: escape HTML entities by default
|
||||
|
||||
## [v0.7.0](https://github.com/shaarli/Shaarli/releases/tag/v0.7.0) - 2016-05-14
|
||||
### Added
|
||||
- Adds an option to encode redirector URL parameter
|
||||
|
@ -875,7 +168,7 @@ Please use our release archives, or follow the
|
|||
### Fixed
|
||||
- Fix a bug where renaming a tag was causing a 404
|
||||
- Fix a bug allowing to search blank terms
|
||||
- Fix a bug preventing to remove a tag with special chars when searching
|
||||
- Fix a bug preventing to remove a tag with special chars when searching
|
||||
|
||||
|
||||
## [v0.6.2](https://github.com/shaarli/Shaarli/releases/tag/v0.6.2) - 2015-12-23
|
||||
|
@ -1181,7 +474,7 @@ Initial release on GitHub.
|
|||
- When you click the key to see only private links, it turns yellow
|
||||
|
||||
### Changed
|
||||
- The "Daily" page now automatically skips empty days.
|
||||
- The "Daily" page now automatically skips empty days.
|
||||
|
||||
### Fixed
|
||||
- Corrected the tag encoding (there was a bug when selecting a second tag which contains accented characters)
|
||||
|
@ -1479,7 +772,7 @@ Initial release on GitHub.
|
|||
- Nicer timezone selection patch by killruana
|
||||
|
||||
### Fixed
|
||||
- New lines now appear correctly in the RSS feed descriptions.
|
||||
- New lines now appear correctly in the RSS feed descriptions.
|
||||
|
||||
|
||||
## [v0.0.17beta](http://sebsauvage.net/wiki/doku.php?id=php:shaarli:history)
|
||||
|
@ -1533,7 +826,7 @@ Initial release on GitHub.
|
|||
## [v0.0.14beta](http://sebsauvage.net/wiki/doku.php?id=php:shaarli:history)
|
||||
### Added
|
||||
- You no longer need to disable `magic_quotes` on your host.
|
||||
Shaarli will cope with this option beeing activated.
|
||||
Shaarli will cope with this option beeing activated.
|
||||
|
||||
|
||||
## [v0.0.13beta](http://sebsauvage.net/wiki/doku.php?id=php:shaarli:history)
|
||||
|
|
|
@ -17,10 +17,14 @@ Check the [milestones](https://github.com/shaarli/Shaarli/milestones) to see wha
|
|||
* You can also join instant discussion at https://gitter.im/shaarli/Shaarli, or via IRC as described [here](https://github.com/shaarli/Shaarli/issues/44#issuecomment-77745105)
|
||||
|
||||
### Documentation
|
||||
**the [wiki](https://github.com/shaarli/Shaarli/wiki) is world-writable** - anyone can edit or add chapters and pages.
|
||||
|
||||
The [official documentation](http://shaarli.readthedocs.io/en/rtfd/) is generated from [Markdown](https://daringfireball.net/projects/markdown/syntax) documents in the `doc/md/` directory. HTML documentation is generated using [Mkdocs](http://www.mkdocs.org/). [Read the Docs](https://readthedocs.org/) provides hosting for the online documentation.
|
||||
* Large changes should preferably be discussed in [General discussion](https://github.com/shaarli/Shaarli/issues/44) beforehand (you can post a draft there and edit it).
|
||||
* If you create a new page, please link it from the new page (eg from the [Other links](https://github.com/shaarli/Shaarli/wiki#other-links) section.
|
||||
* The wiki is a general documentation about Shaarli: usage, development, hacks, usage tricks, related links, projects. Try to keep it organized.
|
||||
* The wiki will be synced to Shaarli's `doc/` directory on each release. Keep that in mind when reviewing the quality of your edits.
|
||||
|
||||
To edit the documentation, please edit the appropriate `doc/md/*.md` files (and optionally `make htmlpages` to preview changes to HTML files). Then submit your changes as a Pull Request. Have a look at the MkDocs documentation and configuration file `mkdocs.yml` if you need to add/remove/rename/reorder pages.
|
||||
You can make the project known by publishing blog posts/articles/videos about it and adding them to the links section in the wiki.
|
||||
|
||||
### Translations
|
||||
Currently Shaarli has no translation/internationalization/localization system available and is single-language. You can help by proposing an i18n system (issue https://github.com/shaarli/Shaarli/issues/121)
|
||||
|
@ -54,7 +58,7 @@ Please report any problem you might find.
|
|||
* starting from branch ` master`, switch to a new branch (eg. `git checkout -b my-awesome-feature`)
|
||||
* edit the required files (from the Github web interface or your text editor)
|
||||
* add and commit your changes with a meaningful commit message (eg `Cool new feature, fixes issue #1001`)
|
||||
* run unit tests against your patched version, see [Running unit tests](https://shaarli.readthedocs.io/en/master/Unit-tests/#run-unit-tests)
|
||||
* run unit tests against your patched version, see [Running unit tests](https://github.com/shaarli/Shaarli/wiki/Running-unit-tests)
|
||||
* Open your fork in the Github web interface and click the "Compare and Pull Request" button, enter required info and submit your Pull Request.
|
||||
|
||||
All changes you will do on the `my-awesome-feature` in the future will be added to your Pull Request. Don't work directly on the master branch, don't do unrelated work on your `my-awesome-feature` branch.
|
||||
|
|
60
COPYING
60
COPYING
|
@ -1,52 +1,72 @@
|
|||
Files: *
|
||||
License: zlib/libpng
|
||||
Copyright: (c) 2011-2015 Sébastien SAUVAGE <sebsauvage@sebsauvage.net>
|
||||
(c) 2011-2018 The Shaarli Community, see AUTHORS
|
||||
(c) 2011-2015 Alexandre Alapetite <alexandre@alapetite.fr>
|
||||
(c) 2011-2015 David Sferruzza <david.sferruzza@gmail.com>
|
||||
(c) 2011-2015 Christophe HENRY <christophe.henry@sbgodin.fr>
|
||||
(c) 2011-2015 Mathieu Chabanon <git@matchab.fr>
|
||||
(c) 2011-2015 BoboTiG <bobotig@gmail.com>
|
||||
(c) 2011-2015 Bronco <bronco@warriordudimanche.net>
|
||||
(c) 2011-2015 Emilien Klein <emilien@klein.st>
|
||||
(c) 2011-2015 Knah Tsaeb <knah-tsaeb@knah-tsaeb.org>
|
||||
(c) 2011-2015 Lionel Martin <renarddesmers@gmail.com>
|
||||
(c) 2011-2015 lehollandaisvolant <levoltigeurhollandais@gmail.com>
|
||||
(c) 2011-2015 timo van neerden <fire@lehollandaisvolant.net>
|
||||
(c) 2011-2015 nodiscc <nodiscc@gmail.com>
|
||||
(c) 2011-2015 Florian Eula <mr.pikzen@gmail.com>
|
||||
(c) 2011-2015 Arthur Hoaro <arthur@hoa.ro>
|
||||
(c) 2011-2015 Aurélien "VirtualTam" Tamisier <virtualtam@flibidi.net>
|
||||
(c) 2011-2015 qwertygc <champlywood@free.fr>
|
||||
(c) 2011-2015 idleman <idleman@idleman.fr>
|
||||
(c) 2015 Alexis Ju <alexis@effingo.be>
|
||||
(c) 2015 dimtion <zizou.xena@gmail.com>
|
||||
(c) 2015 Fanch <fanch-github@qth.fr>
|
||||
(c) 2015 Guillaume Virlet <github@virlet.org>
|
||||
(c) 2015 Felix Bartels <felix@host-consultants.de>
|
||||
(c) 2015 Marsup <marsup@gmail.com>
|
||||
(c) 2015 Miloš Jovanović <mjovanovic@gmail.com>
|
||||
(c) 2015 Nicolás Danelón <hola@nicolasdanelon.com.ar>
|
||||
(c) 2015 TsT <tst2005@gmail.com>
|
||||
|
||||
Files: assets/vintage/css/reset.css
|
||||
|
||||
Files: inc/reset.css
|
||||
License: BSD (http://opensource.org/licenses/BSD-3-Clause)
|
||||
Copyright: (c) 2010, Yahoo! Inc.
|
||||
|
||||
Files: assets/vintage/img/calendar.png
|
||||
assets/vintage/img/edit_icon.png
|
||||
assets/vintage/img/feed-icon-14x14.png
|
||||
assets/vintage/img/private.png
|
||||
assets/vintage/img/private_16x16.png
|
||||
assets/vintage/img/private_16x16_active.png
|
||||
assets/vintage/img/tag_blue.png
|
||||
Files: images/calendar.png, images/edit_icon.png, images/feed-icon-14x14.png, images/private.png, images/private_16x16.png, images/private_16x16_active.png, images/tag_blue.png
|
||||
License: CC-BY (http://creativecommons.org/licenses/by/3.0/)
|
||||
Copyright: (c) 2014 Yusuke Kamiyamane
|
||||
Source: http://p.yusukekamiyamane.com/
|
||||
|
||||
Files: assets/vintage/img/delete_icon.png
|
||||
Files: images/delete_icon.png
|
||||
License: CC-BY (http://creativecommons.org/licenses/by/3.0/)
|
||||
Copyright: (c) 2014 Designmodo
|
||||
Source: http://designmodo.com/linecons-free/
|
||||
|
||||
Files: assets/vintage/img/floral_left.png
|
||||
assets/vintage/img/floral_right.png
|
||||
assets/vintage/img/squiggle.png
|
||||
assets/vintage/img/squiggle_closing.png
|
||||
Files: images/floral_left.png, images/floral_right.png, images/squiggle.png, images/squiggle2.png, images/squiggle_closing.png
|
||||
Licence: Public Domain
|
||||
Source: https://openclipart.org/people/j4p4n/j4p4n_ornimental_bookend_-_left.svg
|
||||
|
||||
Files: assets/vintage/img/Paper_texture_v5_by_bashcorpo_w1000.jpg
|
||||
Files: images/Paper_texture_v5_by_bashcorpo_w1000.jpg
|
||||
Licence: Public Domain
|
||||
Source: http://bashcorpo.deviantart.com/art/Grungy-paper-texture-v-5-22966998
|
||||
|
||||
Files: assets/vintage/img/logo.png
|
||||
assets/vintage/img/logo.png
|
||||
Files: images/logo.png
|
||||
License: zlib/libpng
|
||||
Copyright: (c) 2011-2014 idleman idleman@idleman.fr
|
||||
|
||||
Files: assets/default/img/sad_star.png
|
||||
Files: inc/blazy*.js
|
||||
License: MIT License (http://opensource.org/licenses/MIT)
|
||||
Copyright: (C) 2015 kalvn - https://github.com/kalvn/Shaarli-Material
|
||||
Copyright: (C) Bjoern Klinggaard - @bklinggaard - http://dinbror.dk/blazy
|
||||
|
||||
Files: inc/rain.tpl.class.php
|
||||
License: LGPL-3+ (https://www.gnu.org/licenses/lgpl-3.0.txt)
|
||||
Copyright: 2011-2012, Federico Ulfo <rainelemental@gmail.com>
|
||||
2011-2012, The Rain Team <hello@raintm.com>
|
||||
License: LGPL-3+ (https://www.gnu.org/licenses/lgpl-3.0.txt)
|
||||
|
||||
Files: inc/awesomplete*
|
||||
License: MIT License (http://opensource.org/licenses/MIT)
|
||||
Copyright: (C) 2015 Lea Verou - https://github.com/LeaVerou/awesomplete
|
||||
|
||||
Files: plugins/wallabag/wallabag.png
|
||||
License: MIT License (http://opensource.org/licenses/MIT)
|
||||
|
|
74
Dockerfile
74
Dockerfile
|
@ -1,74 +0,0 @@
|
|||
# Stage 1:
|
||||
# - Copy Shaarli sources
|
||||
# - Build documentation
|
||||
FROM python:3-alpine as docs
|
||||
ADD . /usr/src/app/shaarli
|
||||
RUN cd /usr/src/app/shaarli \
|
||||
&& apk add --no-cache gcc musl-dev make bash \
|
||||
&& make htmldoc
|
||||
|
||||
# Stage 2:
|
||||
# - Resolve PHP dependencies with Composer
|
||||
FROM composer:latest as composer
|
||||
COPY --from=docs /usr/src/app/shaarli /app/shaarli
|
||||
RUN cd shaarli \
|
||||
&& composer --prefer-dist --no-dev install
|
||||
|
||||
# Stage 3:
|
||||
# - Frontend dependencies
|
||||
FROM node:12-alpine as node
|
||||
COPY --from=composer /app/shaarli shaarli
|
||||
RUN cd shaarli \
|
||||
&& yarnpkg install \
|
||||
&& yarnpkg run build \
|
||||
&& rm -rf node_modules
|
||||
|
||||
# Stage 4:
|
||||
# - Shaarli image
|
||||
FROM alpine:3.16.7
|
||||
LABEL maintainer="Shaarli Community"
|
||||
|
||||
RUN apk --update --no-cache add \
|
||||
ca-certificates \
|
||||
nginx \
|
||||
php8 \
|
||||
php8-ctype \
|
||||
php8-curl \
|
||||
php8-fpm \
|
||||
php8-gd \
|
||||
php8-gettext \
|
||||
php8-iconv \
|
||||
php8-intl \
|
||||
php8-json \
|
||||
php8-ldap \
|
||||
php8-mbstring \
|
||||
php8-openssl \
|
||||
php8-session \
|
||||
php8-xml \
|
||||
php8-simplexml \
|
||||
php8-zlib \
|
||||
s6
|
||||
|
||||
COPY .docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY .docker/php-fpm.conf /etc/php8/php-fpm.conf
|
||||
COPY .docker/services.d /etc/services.d
|
||||
|
||||
RUN rm -rf /etc/php8/php-fpm.d/www.conf \
|
||||
&& sed -i 's/post_max_size.*/post_max_size = 10M/' /etc/php8/php.ini \
|
||||
&& sed -i 's/upload_max_filesize.*/upload_max_filesize = 10M/' /etc/php8/php.ini
|
||||
|
||||
|
||||
WORKDIR /var/www
|
||||
COPY --from=node /shaarli shaarli
|
||||
|
||||
RUN chown -R nginx:nginx . \
|
||||
&& ln -sf /dev/stdout /var/log/nginx/shaarli.access.log \
|
||||
&& ln -sf /dev/stderr /var/log/nginx/shaarli.error.log
|
||||
|
||||
VOLUME /var/www/shaarli/cache
|
||||
VOLUME /var/www/shaarli/data
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
ENTRYPOINT ["/bin/s6-svscan", "/etc/services.d"]
|
||||
CMD []
|
250
Makefile
250
Makefile
|
@ -1,19 +1,30 @@
|
|||
# The personal, minimalist, super fast, database-free, bookmarking service.
|
||||
# The personal, minimalist, super-fast, database free, bookmarking service.
|
||||
# Makefile for PHP code analysis & testing, documentation and release generation
|
||||
|
||||
# Prerequisites:
|
||||
# - install Composer, either:
|
||||
# - from your distro's package manager;
|
||||
# - from the official website (https://getcomposer.org/download/);
|
||||
# - install/update test dependencies:
|
||||
# $ composer install # 1st setup
|
||||
# $ composer update
|
||||
# - install Xdebug for PHPUnit code coverage reports:
|
||||
# - see http://xdebug.org/docs/install
|
||||
# - enable in php.ini
|
||||
|
||||
BIN = vendor/bin
|
||||
PHP_SOURCE = index.php application tests plugins
|
||||
PHP_COMMA_SOURCE = index.php,application,tests,plugins
|
||||
|
||||
all: check_permissions test
|
||||
all: static_analysis_summary check_permissions test
|
||||
|
||||
##
|
||||
# Docker test adapter
|
||||
#
|
||||
# Shaarli sources and vendored libraries are copied from a shared volume
|
||||
# to a user-owned directory to enable running tests as a non-root user.
|
||||
# Concise status of the project
|
||||
# These targets are non-blocking: || exit 0
|
||||
##
|
||||
docker_%:
|
||||
rsync -az /shaarli/ ~/shaarli/
|
||||
cd ~/shaarli && make $*
|
||||
|
||||
static_analysis_summary: code_sniffer_source copy_paste mess_detector_summary
|
||||
@echo
|
||||
|
||||
##
|
||||
# PHP_CodeSniffer
|
||||
|
@ -22,29 +33,70 @@ docker_%:
|
|||
# - http://pear.php.net/manual/en/package.php.php-codesniffer.usage.php
|
||||
# - http://pear.php.net/manual/en/package.php.php-codesniffer.reporting.php
|
||||
##
|
||||
PHPCS := $(BIN)/phpcs
|
||||
|
||||
# Use GNU Tar where available
|
||||
ifneq (, $(shell which gtar))
|
||||
TAR := gtar
|
||||
else
|
||||
TAR := tar
|
||||
endif
|
||||
code_sniffer: code_sniffer_full
|
||||
|
||||
code_sniffer:
|
||||
@$(PHPCS)
|
||||
### - errors filtered by coding standard: PEAR, PSR1, PSR2, Zend...
|
||||
PHPCS_%:
|
||||
@$(BIN)/phpcs $(PHP_SOURCE) --report-full --report-width=200 --standard=$*
|
||||
|
||||
### - errors by Git author
|
||||
code_sniffer_blame:
|
||||
@$(PHPCS) --report-gitblame
|
||||
@$(BIN)/phpcs $(PHP_SOURCE) --report-gitblame
|
||||
|
||||
### - all errors/warnings
|
||||
code_sniffer_full:
|
||||
@$(PHPCS) --report-full --report-width=200
|
||||
@$(BIN)/phpcs $(PHP_SOURCE) --report-full --report-width=200
|
||||
|
||||
### - errors grouped by kind
|
||||
code_sniffer_source:
|
||||
@$(PHPCS) --report-source || exit 0
|
||||
@$(BIN)/phpcs $(PHP_SOURCE) --report-source || exit 0
|
||||
|
||||
##
|
||||
# PHP Copy/Paste Detector
|
||||
# Detects code redundancy
|
||||
# Documentation: https://github.com/sebastianbergmann/phpcpd
|
||||
##
|
||||
|
||||
copy_paste:
|
||||
@echo "-----------------------"
|
||||
@echo "PHP COPY/PASTE DETECTOR"
|
||||
@echo "-----------------------"
|
||||
@$(BIN)/phpcpd $(PHP_SOURCE) || exit 0
|
||||
@echo
|
||||
|
||||
##
|
||||
# PHP Mess Detector
|
||||
# Detects PHP syntax errors, sorted by category
|
||||
# Rules documentation: http://phpmd.org/rules/index.html
|
||||
##
|
||||
MESS_DETECTOR_RULES = cleancode,codesize,controversial,design,naming,unusedcode
|
||||
|
||||
mess_title:
|
||||
@echo "-----------------"
|
||||
@echo "PHP MESS DETECTOR"
|
||||
@echo "-----------------"
|
||||
|
||||
### - all warnings
|
||||
mess_detector: mess_title
|
||||
@$(BIN)/phpmd $(PHP_COMMA_SOURCE) text $(MESS_DETECTOR_RULES) | sed 's_.*\/__'
|
||||
|
||||
### - all warnings + HTML output contains links to PHPMD's documentation
|
||||
mess_detector_html:
|
||||
@$(BIN)/phpmd $(PHP_COMMA_SOURCE) html $(MESS_DETECTOR_RULES) \
|
||||
--reportfile phpmd.html || exit 0
|
||||
|
||||
### - warnings grouped by message, sorted by descending frequency order
|
||||
mess_detector_grouped: mess_title
|
||||
@$(BIN)/phpmd $(PHP_SOURCE) text $(MESS_DETECTOR_RULES) \
|
||||
| cut -f 2 | sort | uniq -c | sort -nr
|
||||
|
||||
### - summary: number of warnings by rule set
|
||||
mess_detector_summary: mess_title
|
||||
@for rule in $$(echo $(MESS_DETECTOR_RULES) | tr ',' ' '); do \
|
||||
warnings=$$($(BIN)/phpmd $(PHP_COMMA_SOURCE) text $$rule | wc -l); \
|
||||
printf "$$warnings\t$$rule\n"; \
|
||||
done;
|
||||
|
||||
##
|
||||
# Checks source file & script permissions
|
||||
|
@ -53,7 +105,7 @@ check_permissions:
|
|||
@echo "----------------------"
|
||||
@echo "Check file permissions"
|
||||
@echo "----------------------"
|
||||
@for file in `git ls-files | grep -v docker`; do \
|
||||
@for file in `git ls-files`; do \
|
||||
if [ -x $$file ]; then \
|
||||
errors=true; \
|
||||
echo "$${file} is executable"; \
|
||||
|
@ -68,29 +120,12 @@ check_permissions:
|
|||
# See phpunit.xml for configuration
|
||||
# https://phpunit.de/manual/current/en/appendixes.configuration.html
|
||||
##
|
||||
test: translate
|
||||
test:
|
||||
@echo "-------"
|
||||
@echo "PHPUNIT"
|
||||
@echo "-------"
|
||||
@mkdir -p sandbox coverage
|
||||
@$(BIN)/phpunit --coverage-php coverage/main.cov --bootstrap tests/bootstrap.php --testsuite unit-tests
|
||||
|
||||
locale_test_%:
|
||||
@UT_LOCALE=$*.utf8 \
|
||||
$(BIN)/phpunit \
|
||||
--coverage-php coverage/$(firstword $(subst _, ,$*)).cov \
|
||||
--bootstrap tests/languages/bootstrap.php \
|
||||
--testsuite language-$(firstword $(subst _, ,$*))
|
||||
|
||||
all_tests: test locale_test_de_DE locale_test_en_US locale_test_fr_FR
|
||||
@# --The current version is not compatible with PHP 7.2
|
||||
@#$(BIN)/phpcov merge --html coverage coverage
|
||||
@# --text doesn't work with phpunit 4.* (v5 requires PHP 5.6)
|
||||
@#$(BIN)/phpcov merge --text coverage/txt coverage
|
||||
|
||||
### download 3rd-party PHP libraries, including dev dependencies
|
||||
composer_dependencies_dev: clean
|
||||
composer install --prefer-dist
|
||||
@mkdir -p sandbox
|
||||
@$(BIN)/phpunit tests
|
||||
|
||||
##
|
||||
# Custom release archive generation
|
||||
|
@ -108,36 +143,21 @@ release_archive: release_tar release_zip
|
|||
|
||||
### download 3rd-party PHP libraries
|
||||
composer_dependencies: clean
|
||||
composer install --no-dev --prefer-dist
|
||||
composer update --no-dev
|
||||
find vendor/ -name ".git" -type d -exec rm -rf {} +
|
||||
|
||||
### download 3rd-party frontend libraries
|
||||
frontend_dependencies:
|
||||
yarnpkg install
|
||||
|
||||
### Build frontend dependencies
|
||||
build_frontend: frontend_dependencies
|
||||
yarnpkg run build
|
||||
|
||||
### generate a release tarball and include 3rd-party dependencies and translations
|
||||
release_tar: composer_dependencies htmldoc translate build_frontend
|
||||
### generate a release tarball and include 3rd-party dependencies
|
||||
release_tar: composer_dependencies
|
||||
git archive --prefix=$(ARCHIVE_PREFIX) -o $(ARCHIVE_VERSION).tar HEAD
|
||||
$(TAR) rvf $(ARCHIVE_VERSION).tar --transform "s|^vendor|$(ARCHIVE_PREFIX)vendor|" vendor/
|
||||
$(TAR) rvf $(ARCHIVE_VERSION).tar --transform "s|^doc/html|$(ARCHIVE_PREFIX)doc/html|" doc/html/
|
||||
$(TAR) rvf $(ARCHIVE_VERSION).tar --transform "s|^tpl|$(ARCHIVE_PREFIX)tpl|" tpl/
|
||||
tar rvf $(ARCHIVE_VERSION).tar --transform "s|^vendor|$(ARCHIVE_PREFIX)vendor|" vendor/
|
||||
gzip $(ARCHIVE_VERSION).tar
|
||||
|
||||
### generate a release zip and include 3rd-party dependencies and translations
|
||||
release_zip: composer_dependencies htmldoc translate build_frontend
|
||||
### generate a release zip and include 3rd-party dependencies
|
||||
release_zip: composer_dependencies
|
||||
git archive --prefix=$(ARCHIVE_PREFIX) -o $(ARCHIVE_VERSION).zip -9 HEAD
|
||||
mkdir -p $(ARCHIVE_PREFIX)/doc
|
||||
mkdir -p $(ARCHIVE_PREFIX)/vendor
|
||||
rsync -a doc/html/ $(ARCHIVE_PREFIX)doc/html/
|
||||
zip -r $(ARCHIVE_VERSION).zip $(ARCHIVE_PREFIX)doc/
|
||||
mkdir $(ARCHIVE_PREFIX)
|
||||
rsync -a vendor/ $(ARCHIVE_PREFIX)vendor/
|
||||
zip -r $(ARCHIVE_VERSION).zip $(ARCHIVE_PREFIX)vendor/
|
||||
rsync -a tpl/ $(ARCHIVE_PREFIX)tpl/
|
||||
zip -r $(ARCHIVE_VERSION).zip $(ARCHIVE_PREFIX)tpl/
|
||||
rm -rf $(ARCHIVE_PREFIX)
|
||||
|
||||
##
|
||||
|
@ -147,69 +167,51 @@ release_zip: composer_dependencies htmldoc translate build_frontend
|
|||
### remove all unversioned files
|
||||
clean:
|
||||
@git clean -df
|
||||
@rm -rf sandbox trivy*
|
||||
@rm -rf sandbox
|
||||
|
||||
### generate the AUTHORS file from Git commit information
|
||||
generate_authors:
|
||||
@cp .github/mailmap .mailmap
|
||||
@git shortlog -sne > AUTHORS
|
||||
@rm .mailmap
|
||||
### generate Doxygen documentation
|
||||
doxygen: clean
|
||||
@rm -rf doxygen
|
||||
@( cat Doxyfile ; echo "PROJECT_NUMBER=`git describe`" ) | doxygen -
|
||||
|
||||
### generate phpDocumentor documentation
|
||||
phpdoc: clean
|
||||
@docker run --rm -v $(PWD):/data -u `id -u`:`id -g` phpdoc/phpdoc
|
||||
### update the local copy of the documentation
|
||||
doc: clean
|
||||
@rm -rf doc
|
||||
@git clone https://github.com/shaarli/Shaarli.wiki.git doc
|
||||
@rm -rf doc/.git
|
||||
|
||||
### generate HTML documentation from Markdown pages with Sphinx
|
||||
htmldoc:
|
||||
python3 -m venv venv/
|
||||
bash -c 'source venv/bin/activate; \
|
||||
pip install wheel; \
|
||||
pip install sphinx==7.1.0 furo==2023.7.26 myst-parser sphinx-design; \
|
||||
sphinx-build -b html -c doc/ doc/md/ doc/html/'
|
||||
find doc/html/ -type f -exec chmod a-x '{}' \;
|
||||
rm -r venv
|
||||
### Generate a custom sidebar
|
||||
#
|
||||
# Sidebar content:
|
||||
# - convert GitHub-flavoured relative links to standard Markdown
|
||||
# - trim HTML, only keep the list (<ul>[...]</ul>) part
|
||||
htmlsidebar:
|
||||
@echo '<div id="local-sidebar">' > doc/sidebar.html
|
||||
@awk 'BEGIN { FS = "[\\[\\]]{2}" }'\
|
||||
'm = /\[/ { t=$$2; gsub(/ /, "-", $$2); print $$1"["t"]("$$2".html)"$$3 }'\
|
||||
'!m { print $$0 }' doc/_Sidebar.md > doc/tmp.md
|
||||
@pandoc -f markdown -t html5 -s doc/tmp.md | awk '/(ul>|li>)/' >> doc/sidebar.html
|
||||
@echo '</div>' >> doc/sidebar.html
|
||||
@rm doc/tmp.md
|
||||
|
||||
### Generate Shaarli's translation compiled file (.mo)
|
||||
translate:
|
||||
@echo "----------------------"
|
||||
@echo "Compile translation files"
|
||||
@echo "----------------------"
|
||||
@for pofile in `find inc/languages/ -name shaarli.po`; do \
|
||||
echo "Compiling $$pofile"; \
|
||||
msgfmt -v "$$pofile" -o "`dirname "$$pofile"`/`basename "$$pofile" .po`.mo"; \
|
||||
### Convert local markdown documentation to HTML
|
||||
#
|
||||
# For all pages:
|
||||
# - infer title from the file name
|
||||
# - convert GitHub-flavoured relative links to standard Markdown
|
||||
# - insert the sidebar menu
|
||||
htmlpages:
|
||||
@for file in `find doc/ -maxdepth 1 -name "*.md"`; do \
|
||||
base=`basename $$file .md`; \
|
||||
sed -i "1i #$${base//-/ }" $$file; \
|
||||
awk 'BEGIN { FS = "[\\[\\]]{2}" }'\
|
||||
'm = /\[/ { t=$$2; gsub(/ /, "-", $$2); print $$1"["t"]("$$2".html)"$$3 }'\
|
||||
'!m { print $$0 }' $$file > doc/tmp.md; \
|
||||
mv doc/tmp.md $$file; \
|
||||
pandoc -f markdown_github -t html5 -s \
|
||||
-c "github-markdown.css" \
|
||||
-T Shaarli -M pagetitle:"$${base//-/ }" -B doc/sidebar.html \
|
||||
-o doc/$$base.html $$file; \
|
||||
done;
|
||||
|
||||
### Run ESLint check against Shaarli's JS files
|
||||
eslint:
|
||||
@yarnpkg run eslint -c .dev/.eslintrc.js assets/vintage/js/
|
||||
@yarnpkg run eslint -c .dev/.eslintrc.js assets/default/js/
|
||||
@yarnpkg run eslint -c .dev/.eslintrc.js assets/common/js/
|
||||
|
||||
### Run CSSLint check against Shaarli's SCSS files
|
||||
sasslint:
|
||||
@yarnpkg run stylelint --config .dev/.stylelintrc.js 'assets/default/scss/*.scss'
|
||||
|
||||
##
|
||||
# Security scans
|
||||
##
|
||||
|
||||
# trivy version (https://github.com/aquasecurity/trivy/releases)
|
||||
TRIVY_VERSION=0.44.0
|
||||
# default trivy exit code when vulnerabilities are found
|
||||
TRIVY_EXIT_CODE=1
|
||||
# default docker image to scan with trivy
|
||||
TRIVY_TARGET_DOCKER_IMAGE=ghcr.io/shaarli/shaarli:latest
|
||||
|
||||
### download trivy vulneravbility scanner
|
||||
download_trivy:
|
||||
wget --quiet --continue -O trivy_$(TRIVY_VERSION)_Linux-64bit.tar.gz https://github.com/aquasecurity/trivy/releases/download/v$(TRIVY_VERSION)/trivy_$(TRIVY_VERSION)_Linux-64bit.tar.gz
|
||||
tar -z -x trivy -f trivy_$(TRIVY_VERSION)_Linux-64bit.tar.gz
|
||||
|
||||
### run trivy vulnerability scanner on docker image
|
||||
test_trivy_docker: download_trivy
|
||||
./trivy --exit-code $(TRIVY_EXIT_CODE) image $(TRIVY_TARGET_DOCKER_IMAGE)
|
||||
|
||||
### run trivy vulnerability scanner on composer/yarn dependency trees
|
||||
test_trivy_repo: download_trivy
|
||||
./trivy --exit-code $(TRIVY_EXIT_CODE) fs composer.lock
|
||||
./trivy --exit-code $(TRIVY_EXIT_CODE) fs yarn.lock
|
||||
htmldoc: doc htmlsidebar htmlpages
|
||||
|
|
110
README.md
110
README.md
|
@ -1,31 +1,115 @@
|
|||
![Shaarli logo](doc/md/images/doc-logo.png)
|
||||
![Shaarli logo](doc/images/doc-logo.png)
|
||||
|
||||
The personal, minimalist, super fast, database-free, bookmarking service.
|
||||
The personal, minimalist, super-fast, database free, bookmarking service.
|
||||
|
||||
_Do you want to share the links you discover?_
|
||||
_Shaarli is a minimalist link sharing service that you can install on your own server._
|
||||
_Shaarli is a minimalist delicious clone that you can install on your own server._
|
||||
_It is designed to be personal (single-user), fast and handy._
|
||||
|
||||
[![](https://img.shields.io/badge/stable-v0.12.2-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1)
|
||||
[![](https://img.shields.io/badge/latest-v0.13.0-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.12.2)
|
||||
[![](https://img.shields.io/badge/master-v0.13.x-blue.svg)](https://github.com/shaarli/Shaarli)
|
||||
[![](https://github.com/shaarli/Shaarli/actions/workflows/ci.yml/badge.svg)](https://github.com/shaarli/Shaarli/actions)
|
||||
[![](https://img.shields.io/travis/shaarli/Shaarli.svg?label=master)](https://travis-ci.org/shaarli/Shaarli)
|
||||
[![](https://img.shields.io/travis/shaarli/Shaarli/stable.svg?label=stable)](https://travis-ci.org/shaarli/Shaarli)
|
||||
[![](https://img.shields.io/github/release/shaarli/shaarli.svg)](https://github.com/shaarli/Shaarli/releases/latest/)
|
||||
[![Docker repository](https://img.shields.io/docker/pulls/shaarli/shaarli.svg)](https://hub.docker.com/r/shaarli/shaarli/)
|
||||
|
||||
[![Join the chat at https://gitter.im/shaarli/Shaarli](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/shaarli/Shaarli)
|
||||
[![Docker repository](https://img.shields.io/docker/pulls/shaarli/shaarli.svg)](https://github.com/shaarli/Shaarli/pkgs/container/shaarli)
|
||||
[![Bountysource](https://www.bountysource.com/badge/team?team_id=19583&style=bounties_received)](https://www.bountysource.com/teams/shaarli/issues)
|
||||
|
||||
## Quickstart
|
||||
|
||||
- [Documentation](https://shaarli.readthedocs.io)
|
||||
- [Wiki/documentation](https://github.com/shaarli/Shaarli/wiki)
|
||||
- [Change log](CHANGELOG.md)
|
||||
- [Bugs/Feature requests/Discussion](https://github.com/shaarli/Shaarli/issues/)
|
||||
|
||||
### Demo
|
||||
|
||||
You can use this [public demo instance of Shaarli](https://demo.shaarli.org).
|
||||
You can use this [public demo instance of Shaarli](http://shaarlidemo.tuxfamily.org/Shaarli).
|
||||
It runs the latest development version of Shaarli and is updated/reset daily.
|
||||
|
||||
Login: `demo`; Password: `demo`
|
||||
|
||||
### License
|
||||
### Installation & upgrade
|
||||
- [Download and installation](https://github.com/shaarli/Shaarli/wiki/Download-and-Installation)
|
||||
- [Upgrade and migration](https://github.com/shaarli/Shaarli/wiki/Upgrade-and-migration)
|
||||
- [Server requirements](https://github.com/shaarli/Shaarli/wiki/Server-requirements)
|
||||
- [Server configuration](https://github.com/shaarli/Shaarli/wiki/Server-configuration)
|
||||
- [Shaarli configuration](https://github.com/shaarli/Shaarli/wiki/Shaarli-configuration)
|
||||
|
||||
## Features
|
||||
### Interface
|
||||
- minimalist design (simple is beautiful)
|
||||
- FAST
|
||||
- ATOM and RSS feeds
|
||||
- views:
|
||||
- paginated link list
|
||||
- tag cloud
|
||||
- picture wall: image and video thumbnails
|
||||
- daily: newspaper-like daily digest
|
||||
- daily RSS feed
|
||||
- permalinks for easy reference
|
||||
- links can be public or private
|
||||
- extensible through [plugins](https://github.com/shaarli/Shaarli/wiki/Plugins#plugin-usage)
|
||||
|
||||
### Tag, view and search your links!
|
||||
- add a custom title and description to archived links
|
||||
- add tags to classify and search links
|
||||
- features tag autocompletion, renaming, merging and deletion
|
||||
- full-text and tag search
|
||||
|
||||
### Easy setup
|
||||
- dead-simple installation: drop the files, open the page
|
||||
- links are stored in a file
|
||||
- compact storage
|
||||
- no database required
|
||||
- easy backup: simply copy the datastore file
|
||||
- import and export links as Netscape bookmarks
|
||||
|
||||
### Accessibility
|
||||
- Firefox bookmarlet to share links in one click
|
||||
- support for mobile browsers
|
||||
- works with Javascript disabled
|
||||
- easy page customization through HTML/CSS/RainTPL
|
||||
|
||||
### Security
|
||||
- bruteforce-proof login form
|
||||
- protected against [XSRF](http://en.wikipedia.org/wiki/Cross-site_request_forgery)
|
||||
and session cookie hijacking
|
||||
|
||||
### Goodies
|
||||
- thumbnail generation for images and video services:
|
||||
dailymotion, flickr, imageshack, imgur, vimeo, xkcd, youtube...
|
||||
- lazy-loading with [bLazy](http://dinbror.dk/blazy/)
|
||||
- [PubSubHubbub](https://code.google.com/p/pubsubhubbub/) protocol support
|
||||
- URL cleanup: automatic removal of `?utm_source=...`, `fb=...`
|
||||
- discreet pop-up notification when a new release is available
|
||||
|
||||
### Other usages
|
||||
Though Shaarli is primarily a bookmarking application, it can serve other purposes
|
||||
(see [usage examples](https://github.com/shaarli/Shaarli/wiki#usage-examples)):
|
||||
- micro-blogging
|
||||
- pastebin
|
||||
- online notepad
|
||||
- snippet archive
|
||||
|
||||
## About
|
||||
### Shaarli community fork
|
||||
This friendly fork is maintained by the Shaarli community at https://github.com/shaarli/Shaarli
|
||||
|
||||
This is a community fork of the original [Shaarli](https://github.com/sebsauvage/Shaarli/) project by [Sébastien Sauvage](http://sebsauvage.net/).
|
||||
|
||||
The original project is currently unmaintained, and the developer [has informed us](https://github.com/sebsauvage/Shaarli/issues/191)
|
||||
that he would have no time to work on Shaarli in the near future.
|
||||
The Shaarli community has carried on the work to provide
|
||||
[many patches](https://github.com/shaarli/Shaarli/compare/sebsauvage:master...master)
|
||||
for [bug fixes and enhancements](https://github.com/shaarli/Shaarli/issues?q=is%3Aclosed+)
|
||||
in this repository, and will keep maintaining the project for the foreseeable future, while keeping Shaarli simple and efficient.
|
||||
|
||||
### Contributing
|
||||
If you'd like to help, please:
|
||||
- have a look at the open [issues](https://github.com/shaarli/Shaarli/issues)
|
||||
and [pull requests](https://github.com/shaarli/Shaarli/pulls)
|
||||
- feel free to report bugs (feedback is much appreciated)
|
||||
- suggest new features and improvements to both code and [documentation](https://github.com/shaarli/Shaarli/wiki)
|
||||
- propose solutions to existing problems
|
||||
- submit pull requests :-)
|
||||
|
||||
|
||||
### License
|
||||
Shaarli is [Free Software](http://en.wikipedia.org/wiki/Free_software). See [COPYING](COPYING) for a detail of the contributors and licenses for each individual component.
|
||||
|
|
198
application/ApplicationUtils.php
Normal file
198
application/ApplicationUtils.php
Normal file
|
@ -0,0 +1,198 @@
|
|||
<?php
|
||||
/**
|
||||
* Shaarli (application) utilities
|
||||
*/
|
||||
class ApplicationUtils
|
||||
{
|
||||
private static $GIT_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli';
|
||||
private static $GIT_BRANCHES = array('master', 'stable');
|
||||
private static $VERSION_FILE = 'shaarli_version.php';
|
||||
private static $VERSION_START_TAG = '<?php /* ';
|
||||
private static $VERSION_END_TAG = ' */ ?>';
|
||||
|
||||
/**
|
||||
* Gets the latest version code from the Git repository
|
||||
*
|
||||
* The code is read from the raw content of the version file on the Git server.
|
||||
*
|
||||
* @param string $url URL to reach to get the latest version.
|
||||
* @param int $timeout Timeout to check the URL (in seconds).
|
||||
*
|
||||
* @return mixed the version code from the repository if available, else 'false'
|
||||
*/
|
||||
public static function getLatestGitVersionCode($url, $timeout=2)
|
||||
{
|
||||
list($headers, $data) = get_http_response($url, $timeout);
|
||||
|
||||
if (strpos($headers[0], '200 OK') === false) {
|
||||
error_log('Failed to retrieve ' . $url);
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_replace(
|
||||
array(self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL),
|
||||
array('', '', ''),
|
||||
$data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a new Shaarli version has been published on the Git repository
|
||||
*
|
||||
* Updates checks are run periodically, according to the following criteria:
|
||||
* - the update checks are enabled (install, global config);
|
||||
* - the user is logged in (or this is an open instance);
|
||||
* - the last check is older than a given interval;
|
||||
* - the check is non-blocking if the HTTPS connection to Git fails;
|
||||
* - in case of failure, the update file's modification date is updated,
|
||||
* to avoid intempestive connection attempts.
|
||||
*
|
||||
* @param string $currentVersion the current version code
|
||||
* @param string $updateFile the file where to store the latest version code
|
||||
* @param int $checkInterval the minimum interval between update checks (in seconds
|
||||
* @param bool $enableCheck whether to check for new versions
|
||||
* @param bool $isLoggedIn whether the user is logged in
|
||||
* @param string $branch check update for the given branch
|
||||
*
|
||||
* @throws Exception an invalid branch has been set for update checks
|
||||
*
|
||||
* @return mixed the new version code if available and greater, else 'false'
|
||||
*/
|
||||
public static function checkUpdate($currentVersion,
|
||||
$updateFile,
|
||||
$checkInterval,
|
||||
$enableCheck,
|
||||
$isLoggedIn,
|
||||
$branch='stable')
|
||||
{
|
||||
if (! $isLoggedIn) {
|
||||
// Do not check versions for visitors
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($enableCheck)) {
|
||||
// Do not check if the user doesn't want to
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_file($updateFile) && (filemtime($updateFile) > time() - $checkInterval)) {
|
||||
// Shaarli has checked for updates recently - skip HTTP query
|
||||
$latestKnownVersion = file_get_contents($updateFile);
|
||||
|
||||
if (version_compare($latestKnownVersion, $currentVersion) == 1) {
|
||||
return $latestKnownVersion;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! in_array($branch, self::$GIT_BRANCHES)) {
|
||||
throw new Exception(
|
||||
'Invalid branch selected for updates: "' . $branch . '"'
|
||||
);
|
||||
}
|
||||
|
||||
// Late Static Binding allows overriding within tests
|
||||
// See http://php.net/manual/en/language.oop5.late-static-bindings.php
|
||||
$latestVersion = static::getLatestGitVersionCode(
|
||||
self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE
|
||||
);
|
||||
|
||||
if (! $latestVersion) {
|
||||
// Only update the file's modification date
|
||||
file_put_contents($updateFile, $currentVersion);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update the file's content and modification date
|
||||
file_put_contents($updateFile, $latestVersion);
|
||||
|
||||
if (version_compare($latestVersion, $currentVersion) == 1) {
|
||||
return $latestVersion;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the PHP version to ensure Shaarli can run
|
||||
*
|
||||
* @param string $minVersion minimum PHP required version
|
||||
* @param string $curVersion current PHP version (use PHP_VERSION)
|
||||
*
|
||||
* @throws Exception the PHP version is not supported
|
||||
*/
|
||||
public static function checkPHPVersion($minVersion, $curVersion)
|
||||
{
|
||||
if (version_compare($curVersion, $minVersion) < 0) {
|
||||
throw new Exception(
|
||||
'Your PHP version is obsolete!'
|
||||
.' Shaarli requires at least PHP '.$minVersion.', and thus cannot run.'
|
||||
.' Your PHP version has known security vulnerabilities and should be'
|
||||
.' updated as soon as possible.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Shaarli has the proper access permissions to its resources
|
||||
*
|
||||
* @param ConfigManager $conf Configuration Manager instance.
|
||||
*
|
||||
* @return array A list of the detected configuration issues
|
||||
*/
|
||||
public static function checkResourcePermissions($conf)
|
||||
{
|
||||
$errors = array();
|
||||
|
||||
// Check script and template directories are readable
|
||||
foreach (array(
|
||||
'application',
|
||||
'inc',
|
||||
'plugins',
|
||||
$conf->get('resource.raintpl_tpl'),
|
||||
$conf->get('resource.raintpl_tpl').'/'.$conf->get('resource.theme'),
|
||||
) as $path) {
|
||||
if (! is_readable(realpath($path))) {
|
||||
$errors[] = '"'.$path.'" directory is not readable';
|
||||
}
|
||||
}
|
||||
|
||||
// Check cache and data directories are readable and writable
|
||||
foreach (array(
|
||||
$conf->get('resource.thumbnails_cache'),
|
||||
$conf->get('resource.data_dir'),
|
||||
$conf->get('resource.page_cache'),
|
||||
$conf->get('resource.raintpl_tmp'),
|
||||
) as $path) {
|
||||
if (! is_readable(realpath($path))) {
|
||||
$errors[] = '"'.$path.'" directory is not readable';
|
||||
}
|
||||
if (! is_writable(realpath($path))) {
|
||||
$errors[] = '"'.$path.'" directory is not writable';
|
||||
}
|
||||
}
|
||||
|
||||
// Check configuration files are readable and writable
|
||||
foreach (array(
|
||||
$conf->getConfigFileExt(),
|
||||
$conf->get('resource.datastore'),
|
||||
$conf->get('resource.ban_file'),
|
||||
$conf->get('resource.log'),
|
||||
$conf->get('resource.update_check'),
|
||||
) as $path) {
|
||||
if (! is_file(realpath($path))) {
|
||||
# the file may not exist yet
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_readable(realpath($path))) {
|
||||
$errors[] = '"'.$path.'" file is not readable';
|
||||
}
|
||||
if (! is_writable(realpath($path))) {
|
||||
$errors[] = '"'.$path.'" file is not writable';
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
}
|
38
application/Cache.php
Normal file
38
application/Cache.php
Normal file
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
/**
|
||||
* Cache utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Purges all cached pages
|
||||
*
|
||||
* @param string $pageCacheDir page cache directory
|
||||
*
|
||||
* @return mixed an error string if the directory is missing
|
||||
*/
|
||||
function purgeCachedPages($pageCacheDir)
|
||||
{
|
||||
if (! is_dir($pageCacheDir)) {
|
||||
$error = 'Cannot purge '.$pageCacheDir.': no directory';
|
||||
error_log($error);
|
||||
return $error;
|
||||
}
|
||||
|
||||
array_map('unlink', glob($pageCacheDir.'/*.cache'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates caches when the database is changed or the user logs out.
|
||||
*
|
||||
* @param string $pageCacheDir page cache directory
|
||||
*/
|
||||
function invalidateCaches($pageCacheDir)
|
||||
{
|
||||
// Purge cache attached to session.
|
||||
if (isset($_SESSION['tags'])) {
|
||||
unset($_SESSION['tags']);
|
||||
}
|
||||
|
||||
// Purge page cache shared by sessions.
|
||||
purgeCachedPages($pageCacheDir);
|
||||
}
|
63
application/CachedPage.php
Normal file
63
application/CachedPage.php
Normal file
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
/**
|
||||
* Simple cache system, mainly for the RSS/ATOM feeds
|
||||
*/
|
||||
class CachedPage
|
||||
{
|
||||
// Directory containing page caches
|
||||
private $cacheDir;
|
||||
|
||||
// Full URL of the page to cache -typically the value returned by pageUrl()
|
||||
private $url;
|
||||
|
||||
// Should this URL be cached (boolean)?
|
||||
private $shouldBeCached;
|
||||
|
||||
// Name of the cache file for this URL
|
||||
private $filename;
|
||||
|
||||
/**
|
||||
* Creates a new CachedPage
|
||||
*
|
||||
* @param string $cacheDir page cache directory
|
||||
* @param string $url page URL
|
||||
* @param bool $shouldBeCached whether this page needs to be cached
|
||||
*/
|
||||
public function __construct($cacheDir, $url, $shouldBeCached)
|
||||
{
|
||||
// TODO: check write access to the cache directory
|
||||
$this->cacheDir = $cacheDir;
|
||||
$this->url = $url;
|
||||
$this->filename = $this->cacheDir.'/'.sha1($url).'.cache';
|
||||
$this->shouldBeCached = $shouldBeCached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached version of a page, if it exists and should be cached
|
||||
*
|
||||
* @return string a cached version of the page if it exists, null otherwise
|
||||
*/
|
||||
public function cachedVersion()
|
||||
{
|
||||
if (!$this->shouldBeCached) {
|
||||
return null;
|
||||
}
|
||||
if (is_file($this->filename)) {
|
||||
return file_get_contents($this->filename);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a page in the cache
|
||||
*
|
||||
* @param string $pageContent XML content to cache
|
||||
*/
|
||||
public function cache($pageContent)
|
||||
{
|
||||
if (!$this->shouldBeCached) {
|
||||
return;
|
||||
}
|
||||
file_put_contents($this->filename, $pageContent);
|
||||
}
|
||||
}
|
307
application/FeedBuilder.php
Normal file
307
application/FeedBuilder.php
Normal file
|
@ -0,0 +1,307 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* FeedBuilder class.
|
||||
*
|
||||
* Used to build ATOM and RSS feeds data.
|
||||
*/
|
||||
class FeedBuilder
|
||||
{
|
||||
/**
|
||||
* @var string Constant: RSS feed type.
|
||||
*/
|
||||
public static $FEED_RSS = 'rss';
|
||||
|
||||
/**
|
||||
* @var string Constant: ATOM feed type.
|
||||
*/
|
||||
public static $FEED_ATOM = 'atom';
|
||||
|
||||
/**
|
||||
* @var string Default language if the locale isn't set.
|
||||
*/
|
||||
public static $DEFAULT_LANGUAGE = 'en-en';
|
||||
|
||||
/**
|
||||
* @var int Number of links to display in a feed by default.
|
||||
*/
|
||||
public static $DEFAULT_NB_LINKS = 50;
|
||||
|
||||
/**
|
||||
* @var LinkDB instance.
|
||||
*/
|
||||
protected $linkDB;
|
||||
|
||||
/**
|
||||
* @var string RSS or ATOM feed.
|
||||
*/
|
||||
protected $feedType;
|
||||
|
||||
/**
|
||||
* @var array $_SERVER.
|
||||
*/
|
||||
protected $serverInfo;
|
||||
|
||||
/**
|
||||
* @var array $_GET.
|
||||
*/
|
||||
protected $userInput;
|
||||
|
||||
/**
|
||||
* @var boolean True if the user is currently logged in, false otherwise.
|
||||
*/
|
||||
protected $isLoggedIn;
|
||||
|
||||
/**
|
||||
* @var boolean Use permalinks instead of direct links if true.
|
||||
*/
|
||||
protected $usePermalinks;
|
||||
|
||||
/**
|
||||
* @var boolean true to hide dates in feeds.
|
||||
*/
|
||||
protected $hideDates;
|
||||
|
||||
/**
|
||||
* @var string PubSub hub URL.
|
||||
*/
|
||||
protected $pubsubhubUrl;
|
||||
|
||||
/**
|
||||
* @var string server locale.
|
||||
*/
|
||||
protected $locale;
|
||||
|
||||
/**
|
||||
* @var DateTime Latest item date.
|
||||
*/
|
||||
protected $latestDate;
|
||||
|
||||
/**
|
||||
* Feed constructor.
|
||||
*
|
||||
* @param LinkDB $linkDB LinkDB instance.
|
||||
* @param string $feedType Type of feed.
|
||||
* @param array $serverInfo $_SERVER.
|
||||
* @param array $userInput $_GET.
|
||||
* @param boolean $isLoggedIn True if the user is currently logged in, false otherwise.
|
||||
*/
|
||||
public function __construct($linkDB, $feedType, $serverInfo, $userInput, $isLoggedIn)
|
||||
{
|
||||
$this->linkDB = $linkDB;
|
||||
$this->feedType = $feedType;
|
||||
$this->serverInfo = $serverInfo;
|
||||
$this->userInput = $userInput;
|
||||
$this->isLoggedIn = $isLoggedIn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build data for feed templates.
|
||||
*
|
||||
* @return array Formatted data for feeds templates.
|
||||
*/
|
||||
public function buildData()
|
||||
{
|
||||
// Optionally filter the results:
|
||||
$linksToDisplay = $this->linkDB->filterSearch($this->userInput);
|
||||
|
||||
$nblinksToDisplay = $this->getNbLinks(count($linksToDisplay));
|
||||
|
||||
// Can't use array_keys() because $link is a LinkDB instance and not a real array.
|
||||
$keys = array();
|
||||
foreach ($linksToDisplay as $key => $value) {
|
||||
$keys[] = $key;
|
||||
}
|
||||
|
||||
$pageaddr = escape(index_url($this->serverInfo));
|
||||
$linkDisplayed = array();
|
||||
for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) {
|
||||
$linkDisplayed[$keys[$i]] = $this->buildItem($linksToDisplay[$keys[$i]], $pageaddr);
|
||||
}
|
||||
|
||||
$data['language'] = $this->getTypeLanguage();
|
||||
$data['pubsubhub_url'] = $this->pubsubhubUrl;
|
||||
$data['last_update'] = $this->getLatestDateFormatted();
|
||||
$data['show_dates'] = !$this->hideDates || $this->isLoggedIn;
|
||||
// Remove leading slash from REQUEST_URI.
|
||||
$data['self_link'] = escape(server_url($this->serverInfo))
|
||||
. escape($this->serverInfo['REQUEST_URI']);
|
||||
$data['index_url'] = $pageaddr;
|
||||
$data['usepermalinks'] = $this->usePermalinks === true;
|
||||
$data['links'] = $linkDisplayed;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a feed item (one per shaare).
|
||||
*
|
||||
* @param array $link Single link array extracted from LinkDB.
|
||||
* @param string $pageaddr Index URL.
|
||||
*
|
||||
* @return array Link array with feed attributes.
|
||||
*/
|
||||
protected function buildItem($link, $pageaddr)
|
||||
{
|
||||
$link['guid'] = $pageaddr .'?'. smallHash($link['linkdate']);
|
||||
// Check for both signs of a note: starting with ? and 7 chars long.
|
||||
if ($link['url'][0] === '?' && strlen($link['url']) === 7) {
|
||||
$link['url'] = $pageaddr . $link['url'];
|
||||
}
|
||||
if ($this->usePermalinks === true) {
|
||||
$permalink = '<a href="'. $link['url'] .'" title="Direct link">Direct link</a>';
|
||||
} else {
|
||||
$permalink = '<a href="'. $link['guid'] .'" title="Permalink">Permalink</a>';
|
||||
}
|
||||
$link['description'] = format_description($link['description'], '', $pageaddr);
|
||||
$link['description'] .= PHP_EOL .'<br>— '. $permalink;
|
||||
|
||||
$pubDate = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $link['linkdate']);
|
||||
$link['pub_iso_date'] = $this->getIsoDate($pubDate);
|
||||
|
||||
// atom:entry elements MUST contain exactly one atom:updated element.
|
||||
if (!empty($link['updated'])) {
|
||||
$upDate = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $link['updated']);
|
||||
$link['up_iso_date'] = $this->getIsoDate($upDate, DateTime::ATOM);
|
||||
} else {
|
||||
$link['up_iso_date'] = $this->getIsoDate($pubDate, DateTime::ATOM);;
|
||||
}
|
||||
|
||||
// Save the more recent item.
|
||||
if (empty($this->latestDate) || $this->latestDate < $pubDate) {
|
||||
$this->latestDate = $pubDate;
|
||||
}
|
||||
if (!empty($upDate) && $this->latestDate < $upDate) {
|
||||
$this->latestDate = $upDate;
|
||||
}
|
||||
|
||||
$taglist = array_filter(explode(' ', $link['tags']), 'strlen');
|
||||
uasort($taglist, 'strcasecmp');
|
||||
$link['taglist'] = $taglist;
|
||||
|
||||
return $link;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign PubSub hub URL.
|
||||
*
|
||||
* @param string $pubsubhubUrl PubSub hub url.
|
||||
*/
|
||||
public function setPubsubhubUrl($pubsubhubUrl)
|
||||
{
|
||||
$this->pubsubhubUrl = $pubsubhubUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this to true to use permalinks instead of direct links.
|
||||
*
|
||||
* @param boolean $usePermalinks true to force permalinks.
|
||||
*/
|
||||
public function setUsePermalinks($usePermalinks)
|
||||
{
|
||||
$this->usePermalinks = $usePermalinks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this to true to hide timestamps in feeds.
|
||||
*
|
||||
* @param boolean $hideDates true to enable.
|
||||
*/
|
||||
public function setHideDates($hideDates)
|
||||
{
|
||||
$this->hideDates = $hideDates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the locale. Used to show feed language.
|
||||
*
|
||||
* @param string $locale The locale (eg. 'fr_FR.UTF8').
|
||||
*/
|
||||
public function setLocale($locale)
|
||||
{
|
||||
$this->locale = strtolower($locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the language according to the feed type, based on the locale:
|
||||
*
|
||||
* - RSS format: en-us (default: 'en-en').
|
||||
* - ATOM format: fr (default: 'en').
|
||||
*
|
||||
* @return string The language.
|
||||
*/
|
||||
public function getTypeLanguage()
|
||||
{
|
||||
// Use the locale do define the language, if available.
|
||||
if (! empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) {
|
||||
$length = ($this->feedType == self::$FEED_RSS) ? 5 : 2;
|
||||
return str_replace('_', '-', substr($this->locale, 0, $length));
|
||||
}
|
||||
return ($this->feedType == self::$FEED_RSS) ? 'en-en' : 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the latest item date found according to the feed type.
|
||||
*
|
||||
* Return an empty string if invalid DateTime is passed.
|
||||
*
|
||||
* @return string Formatted date.
|
||||
*/
|
||||
protected function getLatestDateFormatted()
|
||||
{
|
||||
if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$type = ($this->feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM;
|
||||
return $this->latestDate->format($type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ISO date from DateTime according to feed type.
|
||||
*
|
||||
* @param DateTime $date Date to format.
|
||||
* @param string|bool $format Force format.
|
||||
*
|
||||
* @return string Formatted date.
|
||||
*/
|
||||
protected function getIsoDate(DateTime $date, $format = false)
|
||||
{
|
||||
if ($format !== false) {
|
||||
return $date->format($format);
|
||||
}
|
||||
if ($this->feedType == self::$FEED_RSS) {
|
||||
return $date->format(DateTime::RSS);
|
||||
|
||||
}
|
||||
return $date->format(DateTime::ATOM);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of link to display according to 'nb' user input parameter.
|
||||
*
|
||||
* If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS.
|
||||
* If 'nb' is set to 'all', display all filtered links (max parameter).
|
||||
*
|
||||
* @param int $max maximum number of links to display.
|
||||
*
|
||||
* @return int number of links to display.
|
||||
*/
|
||||
public function getNbLinks($max)
|
||||
{
|
||||
if (empty($this->userInput['nb'])) {
|
||||
return self::$DEFAULT_NB_LINKS;
|
||||
}
|
||||
|
||||
if ($this->userInput['nb'] == 'all') {
|
||||
return $max;
|
||||
}
|
||||
|
||||
$intNb = intval($this->userInput['nb']);
|
||||
if (! is_int($intNb) || $intNb == 0) {
|
||||
return self::$DEFAULT_NB_LINKS;
|
||||
}
|
||||
|
||||
return $intNb;
|
||||
}
|
||||
}
|
|
@ -1,9 +1,4 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* Exception class thrown when a filesystem access failure happens
|
||||
*/
|
||||
|
@ -20,7 +15,7 @@ class IOException extends Exception
|
|||
public function __construct($path, $message = '')
|
||||
{
|
||||
$this->path = $path;
|
||||
$this->message = empty($message) ? t('Error accessing') : $message;
|
||||
$this->message .= ' "' . $this->path . '"';
|
||||
$this->message = empty($message) ? 'Error accessing' : $message;
|
||||
$this->message .= PHP_EOL . $this->path;
|
||||
}
|
||||
}
|
|
@ -1,223 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli;
|
||||
|
||||
use DateTime;
|
||||
use Exception;
|
||||
use Shaarli\Bookmark\Bookmark;
|
||||
use Shaarli\Helper\FileUtils;
|
||||
|
||||
/**
|
||||
* Class History
|
||||
*
|
||||
* Handle the history file tracing events in Shaarli.
|
||||
* The history is stored as JSON in a file set by 'resource.history' setting.
|
||||
*
|
||||
* Available data:
|
||||
* - event: event key
|
||||
* - datetime: event date, in ISO8601 format.
|
||||
* - id: event item identifier (currently only link IDs).
|
||||
*
|
||||
* Available event keys:
|
||||
* - CREATED: new link
|
||||
* - UPDATED: link updated
|
||||
* - DELETED: link deleted
|
||||
* - SETTINGS: the settings have been updated through the UI.
|
||||
* - IMPORT: bulk bookmarks import
|
||||
*
|
||||
* Note: new events are put at the beginning of the file and history array.
|
||||
*/
|
||||
class History
|
||||
{
|
||||
/**
|
||||
* @var string Action key: a new link has been created.
|
||||
*/
|
||||
public const CREATED = 'CREATED';
|
||||
|
||||
/**
|
||||
* @var string Action key: a link has been updated.
|
||||
*/
|
||||
public const UPDATED = 'UPDATED';
|
||||
|
||||
/**
|
||||
* @var string Action key: a link has been deleted.
|
||||
*/
|
||||
public const DELETED = 'DELETED';
|
||||
|
||||
/**
|
||||
* @var string Action key: settings have been updated.
|
||||
*/
|
||||
public const SETTINGS = 'SETTINGS';
|
||||
|
||||
/**
|
||||
* @var string Action key: a bulk import has been processed.
|
||||
*/
|
||||
public const IMPORT = 'IMPORT';
|
||||
|
||||
/**
|
||||
* @var string History file path.
|
||||
*/
|
||||
protected $historyFilePath;
|
||||
|
||||
/**
|
||||
* @var array History data.
|
||||
*/
|
||||
protected $history;
|
||||
|
||||
/**
|
||||
* @var int History retention time in seconds (1 month).
|
||||
*/
|
||||
protected $retentionTime = 2678400;
|
||||
|
||||
/**
|
||||
* History constructor.
|
||||
*
|
||||
* @param string $historyFilePath History file path.
|
||||
* @param int $retentionTime History content retention time in seconds.
|
||||
*
|
||||
* @throws Exception if something goes wrong.
|
||||
*/
|
||||
public function __construct($historyFilePath, $retentionTime = null)
|
||||
{
|
||||
$this->historyFilePath = $historyFilePath;
|
||||
if ($retentionTime !== null) {
|
||||
$this->retentionTime = $retentionTime;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize: read history file.
|
||||
*
|
||||
* Allow lazy loading (don't read the file if it isn't necessary).
|
||||
*/
|
||||
protected function initialize()
|
||||
{
|
||||
$this->check();
|
||||
$this->read();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Event: new link.
|
||||
*
|
||||
* @param Bookmark $link Link data.
|
||||
*/
|
||||
public function addLink($link)
|
||||
{
|
||||
$this->addEvent(self::CREATED, $link->getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Event: update existing link.
|
||||
*
|
||||
* @param Bookmark $link Link data.
|
||||
*/
|
||||
public function updateLink($link)
|
||||
{
|
||||
$this->addEvent(self::UPDATED, $link->getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Event: delete existing link.
|
||||
*
|
||||
* @param Bookmark $link Link data.
|
||||
*/
|
||||
public function deleteLink($link)
|
||||
{
|
||||
$this->addEvent(self::DELETED, $link->getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Event: settings updated.
|
||||
*/
|
||||
public function updateSettings()
|
||||
{
|
||||
$this->addEvent(self::SETTINGS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add Event: bulk import.
|
||||
*
|
||||
* Note: we don't store bookmarks add/update one by one since it can have a huge impact on performances.
|
||||
*/
|
||||
public function importLinks()
|
||||
{
|
||||
$this->addEvent(self::IMPORT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new event and write it in the history file.
|
||||
*
|
||||
* @param string $status Event key, should be defined as constant.
|
||||
* @param mixed $id Event item identifier (e.g. link ID).
|
||||
*/
|
||||
protected function addEvent($status, $id = null)
|
||||
{
|
||||
if ($this->history === null) {
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
$item = [
|
||||
'event' => $status,
|
||||
'datetime' => new DateTime(),
|
||||
'id' => $id !== null ? $id : '',
|
||||
];
|
||||
$this->history = array_merge([$item], $this->history);
|
||||
$this->write();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the history file is writable.
|
||||
* Create the file if it doesn't exist.
|
||||
*
|
||||
* @throws Exception if it isn't writable.
|
||||
*/
|
||||
protected function check()
|
||||
{
|
||||
if (!is_file($this->historyFilePath)) {
|
||||
FileUtils::writeFlatDB($this->historyFilePath, []);
|
||||
}
|
||||
|
||||
if (!is_writable($this->historyFilePath)) {
|
||||
throw new Exception(t('History file isn\'t readable or writable'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read JSON history file.
|
||||
*/
|
||||
protected function read()
|
||||
{
|
||||
$this->history = FileUtils::readFlatDB($this->historyFilePath, []);
|
||||
if ($this->history === false) {
|
||||
throw new Exception(t('Could not parse history file'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write JSON history file and delete old entries.
|
||||
*/
|
||||
protected function write()
|
||||
{
|
||||
$comparaison = new DateTime('-' . $this->retentionTime . ' seconds');
|
||||
foreach ($this->history as $key => $value) {
|
||||
if ($value['datetime'] < $comparaison) {
|
||||
unset($this->history[$key]);
|
||||
}
|
||||
}
|
||||
FileUtils::writeFlatDB($this->historyFilePath, array_values($this->history));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the History.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getHistory()
|
||||
{
|
||||
if ($this->history === null) {
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
return $this->history;
|
||||
}
|
||||
}
|
383
application/HttpUtils.php
Normal file
383
application/HttpUtils.php
Normal file
|
@ -0,0 +1,383 @@
|
|||
<?php
|
||||
/**
|
||||
* GET an HTTP URL to retrieve its content
|
||||
* Uses the cURL library or a fallback method
|
||||
*
|
||||
* @param string $url URL to get (http://...)
|
||||
* @param int $timeout network timeout (in seconds)
|
||||
* @param int $maxBytes maximum downloaded bytes (default: 4 MiB)
|
||||
*
|
||||
* @return array HTTP response headers, downloaded content
|
||||
*
|
||||
* Output format:
|
||||
* [0] = associative array containing HTTP response headers
|
||||
* [1] = URL content (downloaded data)
|
||||
*
|
||||
* Example:
|
||||
* list($headers, $data) = get_http_response('http://sebauvage.net/');
|
||||
* if (strpos($headers[0], '200 OK') !== false) {
|
||||
* echo 'Data type: '.htmlspecialchars($headers['Content-Type']);
|
||||
* } else {
|
||||
* echo 'There was an error: '.htmlspecialchars($headers[0]);
|
||||
* }
|
||||
*
|
||||
* @see https://secure.php.net/manual/en/ref.curl.php
|
||||
* @see https://secure.php.net/manual/en/functions.anonymous.php
|
||||
* @see https://secure.php.net/manual/en/function.preg-split.php
|
||||
* @see https://secure.php.net/manual/en/function.explode.php
|
||||
* @see http://stackoverflow.com/q/17641073
|
||||
* @see http://stackoverflow.com/q/9183178
|
||||
* @see http://stackoverflow.com/q/1462720
|
||||
*/
|
||||
function get_http_response($url, $timeout = 30, $maxBytes = 4194304)
|
||||
{
|
||||
$urlObj = new Url($url);
|
||||
$cleanUrl = $urlObj->idnToAscii();
|
||||
|
||||
if (!filter_var($cleanUrl, FILTER_VALIDATE_URL) || !$urlObj->isHttp()) {
|
||||
return array(array(0 => 'Invalid HTTP Url'), false);
|
||||
}
|
||||
|
||||
$userAgent =
|
||||
'Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:45.0)'
|
||||
. ' Gecko/20100101 Firefox/45.0';
|
||||
$acceptLanguage =
|
||||
substr(setlocale(LC_COLLATE, 0), 0, 2) . ',en-US;q=0.7,en;q=0.3';
|
||||
$maxRedirs = 3;
|
||||
|
||||
if (!function_exists('curl_init')) {
|
||||
return get_http_response_fallback(
|
||||
$cleanUrl,
|
||||
$timeout,
|
||||
$maxBytes,
|
||||
$userAgent,
|
||||
$acceptLanguage,
|
||||
$maxRedirs
|
||||
);
|
||||
}
|
||||
|
||||
$ch = curl_init($cleanUrl);
|
||||
if ($ch === false) {
|
||||
return array(array(0 => 'curl_init() error'), false);
|
||||
}
|
||||
|
||||
// General cURL settings
|
||||
curl_setopt($ch, CURLOPT_AUTOREFERER, true);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($ch, CURLOPT_HEADER, true);
|
||||
curl_setopt(
|
||||
$ch,
|
||||
CURLOPT_HTTPHEADER,
|
||||
array('Accept-Language: ' . $acceptLanguage)
|
||||
);
|
||||
curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirs);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
|
||||
curl_setopt($ch, CURLOPT_USERAGENT, $userAgent);
|
||||
|
||||
// Max download size management
|
||||
curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024);
|
||||
curl_setopt($ch, CURLOPT_NOPROGRESS, false);
|
||||
curl_setopt($ch, CURLOPT_PROGRESSFUNCTION,
|
||||
function($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes)
|
||||
{
|
||||
if (version_compare(phpversion(), '5.5', '<')) {
|
||||
// PHP version lower than 5.5
|
||||
// Callback has 4 arguments
|
||||
$downloaded = $arg1;
|
||||
} else {
|
||||
// Callback has 5 arguments
|
||||
$downloaded = $arg2;
|
||||
}
|
||||
// Non-zero return stops downloading
|
||||
return ($downloaded > $maxBytes) ? 1 : 0;
|
||||
}
|
||||
);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$errorNo = curl_errno($ch);
|
||||
$errorStr = curl_error($ch);
|
||||
$headSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
if ($errorNo == CURLE_COULDNT_RESOLVE_HOST) {
|
||||
/*
|
||||
* Workaround to match fallback method behaviour
|
||||
* Removing this would require updating
|
||||
* GetHttpUrlTest::testGetInvalidRemoteUrl()
|
||||
*/
|
||||
return array(false, false);
|
||||
}
|
||||
return array(array(0 => 'curl_exec() error: ' . $errorStr), false);
|
||||
}
|
||||
|
||||
// Formatting output like the fallback method
|
||||
$rawHeaders = substr($response, 0, $headSize);
|
||||
|
||||
// Keep only headers from latest redirection
|
||||
$rawHeadersArrayRedirs = explode("\r\n\r\n", trim($rawHeaders));
|
||||
$rawHeadersLastRedir = end($rawHeadersArrayRedirs);
|
||||
|
||||
$content = substr($response, $headSize);
|
||||
$headers = array();
|
||||
foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) {
|
||||
if (empty($line) or ctype_space($line)) {
|
||||
continue;
|
||||
}
|
||||
$splitLine = explode(': ', $line, 2);
|
||||
if (count($splitLine) > 1) {
|
||||
$key = $splitLine[0];
|
||||
$value = $splitLine[1];
|
||||
if (array_key_exists($key, $headers)) {
|
||||
if (!is_array($headers[$key])) {
|
||||
$headers[$key] = array(0 => $headers[$key]);
|
||||
}
|
||||
$headers[$key][] = $value;
|
||||
} else {
|
||||
$headers[$key] = $value;
|
||||
}
|
||||
} else {
|
||||
$headers[] = $splitLine[0];
|
||||
}
|
||||
}
|
||||
|
||||
return array($headers, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET an HTTP URL to retrieve its content (fallback method)
|
||||
*
|
||||
* @param string $cleanUrl URL to get (http://... valid and in ASCII form)
|
||||
* @param int $timeout network timeout (in seconds)
|
||||
* @param int $maxBytes maximum downloaded bytes
|
||||
* @param string $userAgent "User-Agent" header
|
||||
* @param string $acceptLanguage "Accept-Language" header
|
||||
* @param int $maxRedr maximum amount of redirections followed
|
||||
*
|
||||
* @return array HTTP response headers, downloaded content
|
||||
*
|
||||
* Output format:
|
||||
* [0] = associative array containing HTTP response headers
|
||||
* [1] = URL content (downloaded data)
|
||||
*
|
||||
* @see http://php.net/manual/en/function.file-get-contents.php
|
||||
* @see http://php.net/manual/en/function.stream-context-create.php
|
||||
* @see http://php.net/manual/en/function.get-headers.php
|
||||
*/
|
||||
function get_http_response_fallback(
|
||||
$cleanUrl,
|
||||
$timeout,
|
||||
$maxBytes,
|
||||
$userAgent,
|
||||
$acceptLanguage,
|
||||
$maxRedr
|
||||
) {
|
||||
$options = array(
|
||||
'http' => array(
|
||||
'method' => 'GET',
|
||||
'timeout' => $timeout,
|
||||
'user_agent' => $userAgent,
|
||||
'header' => "Accept: */*\r\n"
|
||||
. 'Accept-Language: ' . $acceptLanguage
|
||||
)
|
||||
);
|
||||
|
||||
stream_context_set_default($options);
|
||||
list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr);
|
||||
if (! $headers || strpos($headers[0], '200 OK') === false) {
|
||||
$options['http']['request_fulluri'] = true;
|
||||
stream_context_set_default($options);
|
||||
list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr);
|
||||
}
|
||||
|
||||
if (! $headers) {
|
||||
return array($headers, false);
|
||||
}
|
||||
|
||||
try {
|
||||
// TODO: catch Exception in calling code (thumbnailer)
|
||||
$context = stream_context_create($options);
|
||||
$content = file_get_contents($finalUrl, false, $context, -1, $maxBytes);
|
||||
} catch (Exception $exc) {
|
||||
return array(array(0 => 'HTTP Error'), $exc->getMessage());
|
||||
}
|
||||
|
||||
return array($headers, $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve HTTP headers, following n redirections (temporary and permanent ones).
|
||||
*
|
||||
* @param string $url initial URL to reach.
|
||||
* @param int $redirectionLimit max redirection follow.
|
||||
*
|
||||
* @return array HTTP headers, or false if it failed.
|
||||
*/
|
||||
function get_redirected_headers($url, $redirectionLimit = 3)
|
||||
{
|
||||
$headers = get_headers($url, 1);
|
||||
if (!empty($headers['location']) && empty($headers['Location'])) {
|
||||
$headers['Location'] = $headers['location'];
|
||||
}
|
||||
|
||||
// Headers found, redirection found, and limit not reached.
|
||||
if ($redirectionLimit-- > 0
|
||||
&& !empty($headers)
|
||||
&& (strpos($headers[0], '301') !== false || strpos($headers[0], '302') !== false)
|
||||
&& !empty($headers['Location'])) {
|
||||
|
||||
$redirection = is_array($headers['Location']) ? end($headers['Location']) : $headers['Location'];
|
||||
if ($redirection != $url) {
|
||||
$redirection = getAbsoluteUrl($url, $redirection);
|
||||
return get_redirected_headers($redirection, $redirectionLimit);
|
||||
}
|
||||
}
|
||||
|
||||
return array($headers, $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an absolute URL from a complete one, and another absolute/relative URL.
|
||||
*
|
||||
* @param string $originalUrl The original complete URL.
|
||||
* @param string $newUrl The new one, absolute or relative.
|
||||
*
|
||||
* @return string Final URL:
|
||||
* - $newUrl if it was already an absolute URL.
|
||||
* - if it was relative, absolute URL from $originalUrl path.
|
||||
*/
|
||||
function getAbsoluteUrl($originalUrl, $newUrl)
|
||||
{
|
||||
$newScheme = parse_url($newUrl, PHP_URL_SCHEME);
|
||||
// Already an absolute URL.
|
||||
if (!empty($newScheme)) {
|
||||
return $newUrl;
|
||||
}
|
||||
|
||||
$parts = parse_url($originalUrl);
|
||||
$final = $parts['scheme'] .'://'. $parts['host'];
|
||||
$final .= (!empty($parts['port'])) ? $parts['port'] : '';
|
||||
$final .= '/';
|
||||
if ($newUrl[0] != '/') {
|
||||
$final .= substr(ltrim($parts['path'], '/'), 0, strrpos($parts['path'], '/'));
|
||||
}
|
||||
$final .= ltrim($newUrl, '/');
|
||||
return $final;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the server's base URL: scheme://domain.tld[:port]
|
||||
*
|
||||
* @param array $server the $_SERVER array
|
||||
*
|
||||
* @return string the server's base URL
|
||||
*
|
||||
* @see http://www.ietf.org/rfc/rfc7239.txt
|
||||
* @see http://www.ietf.org/rfc/rfc6648.txt
|
||||
* @see http://stackoverflow.com/a/3561399
|
||||
* @see http://stackoverflow.com/q/452375
|
||||
*/
|
||||
function server_url($server)
|
||||
{
|
||||
$scheme = 'http';
|
||||
$port = '';
|
||||
|
||||
// Shaarli is served behind a proxy
|
||||
if (isset($server['HTTP_X_FORWARDED_PROTO'])) {
|
||||
// Keep forwarded scheme
|
||||
if (strpos($server['HTTP_X_FORWARDED_PROTO'], ',') !== false) {
|
||||
$schemes = explode(',', $server['HTTP_X_FORWARDED_PROTO']);
|
||||
$scheme = trim($schemes[0]);
|
||||
} else {
|
||||
$scheme = $server['HTTP_X_FORWARDED_PROTO'];
|
||||
}
|
||||
|
||||
if (isset($server['HTTP_X_FORWARDED_PORT'])) {
|
||||
// Keep forwarded port
|
||||
if (strpos($server['HTTP_X_FORWARDED_PORT'], ',') !== false) {
|
||||
$ports = explode(',', $server['HTTP_X_FORWARDED_PORT']);
|
||||
$port = ':' . trim($ports[0]);
|
||||
} else {
|
||||
$port = ':' . $server['HTTP_X_FORWARDED_PORT'];
|
||||
}
|
||||
}
|
||||
|
||||
return $scheme.'://'.$server['SERVER_NAME'].$port;
|
||||
}
|
||||
|
||||
// SSL detection
|
||||
if ((! empty($server['HTTPS']) && strtolower($server['HTTPS']) == 'on')
|
||||
|| (isset($server['SERVER_PORT']) && $server['SERVER_PORT'] == '443')) {
|
||||
$scheme = 'https';
|
||||
}
|
||||
|
||||
// Do not append standard port values
|
||||
if (($scheme == 'http' && $server['SERVER_PORT'] != '80')
|
||||
|| ($scheme == 'https' && $server['SERVER_PORT'] != '443')) {
|
||||
$port = ':'.$server['SERVER_PORT'];
|
||||
}
|
||||
|
||||
return $scheme.'://'.$server['SERVER_NAME'].$port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute URL of the current script, without the query
|
||||
*
|
||||
* If the resource is "index.php", then it is removed (for better-looking URLs)
|
||||
*
|
||||
* @param array $server the $_SERVER array
|
||||
*
|
||||
* @return string the absolute URL of the current script, without the query
|
||||
*/
|
||||
function index_url($server)
|
||||
{
|
||||
$scriptname = $server['SCRIPT_NAME'];
|
||||
if (endsWith($scriptname, 'index.php')) {
|
||||
$scriptname = substr($scriptname, 0, -9);
|
||||
}
|
||||
return server_url($server) . $scriptname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute URL of the current script, with the query
|
||||
*
|
||||
* If the resource is "index.php", then it is removed (for better-looking URLs)
|
||||
*
|
||||
* @param array $server the $_SERVER array
|
||||
*
|
||||
* @return string the absolute URL of the current script, with the query
|
||||
*/
|
||||
function page_url($server)
|
||||
{
|
||||
if (! empty($server['QUERY_STRING'])) {
|
||||
return index_url($server).'?'.$server['QUERY_STRING'];
|
||||
}
|
||||
return index_url($server);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the initial IP forwarded by the reverse proxy.
|
||||
*
|
||||
* Inspired from: https://github.com/zendframework/zend-http/blob/master/src/PhpEnvironment/RemoteAddress.php
|
||||
*
|
||||
* @param array $server $_SERVER array which contains HTTP headers.
|
||||
* @param array $trustedIps List of trusted IP from the configuration.
|
||||
*
|
||||
* @return string|bool The forwarded IP, or false if none could be extracted.
|
||||
*/
|
||||
function getIpAddressFromProxy($server, $trustedIps)
|
||||
{
|
||||
$forwardedIpHeader = 'HTTP_X_FORWARDED_FOR';
|
||||
if (empty($server[$forwardedIpHeader])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ips = preg_split('/\s*,\s*/', $server[$forwardedIpHeader]);
|
||||
$ips = array_diff($ips, $trustedIps);
|
||||
if (empty($ips)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return array_pop($ips);
|
||||
}
|
|
@ -1,193 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli;
|
||||
|
||||
use Gettext\GettextTranslator;
|
||||
use Gettext\Translations;
|
||||
use Gettext\Translator;
|
||||
use Gettext\TranslatorInterface;
|
||||
use Shaarli\Config\ConfigManager;
|
||||
|
||||
/**
|
||||
* Class Languages
|
||||
* Wrapper function for translation which match the API
|
||||
* of gettext()/_() and ngettext().
|
||||
*
|
||||
* Load Shaarli translations using 'gettext/gettext'.
|
||||
* This class allows to either use PHP gettext extension, or a PHP implementation of gettext,
|
||||
* with a fixed language, or dynamically using autoLocale().
|
||||
* Not doing translation for now.
|
||||
*
|
||||
* Translation files PO/MO files follow gettext standard and must be placed under:
|
||||
* <translation path>/<language>/LC_MESSAGES/<domain>.[po|mo]
|
||||
* @param string $text Text to translate.
|
||||
* @param string $nText The plural message ID.
|
||||
* @param int $nb The number of items for plural forms.
|
||||
*
|
||||
* Pros/cons:
|
||||
* - gettext extension is faster
|
||||
* - gettext is very system dependent (PHP extension, the locale must be installed, and web server reloaded)
|
||||
*
|
||||
* Settings:
|
||||
* - translation.mode:
|
||||
* - auto: use default setting (PHP implementation)
|
||||
* - php: use PHP implementation
|
||||
* - gettext: use gettext wrapper
|
||||
* - translation.language:
|
||||
* - auto: use autoLocale() and the language change according to user HTTP headers
|
||||
* - fixed language: e.g. 'fr'
|
||||
* - translation.extensions:
|
||||
* - domain => translation_path: allow plugins and themes to extend the defaut extension
|
||||
* The domain must be unique, and translation path must be relative, and contains the tree mentioned above.
|
||||
*
|
||||
* @package Shaarli
|
||||
* @return String Text translated.
|
||||
*/
|
||||
class Languages
|
||||
{
|
||||
/**
|
||||
* Core translations domain
|
||||
*/
|
||||
public const DEFAULT_DOMAIN = 'shaarli';
|
||||
|
||||
/**
|
||||
* @var TranslatorInterface
|
||||
*/
|
||||
protected $translator;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $language;
|
||||
|
||||
/**
|
||||
* @var ConfigManager
|
||||
*/
|
||||
protected $conf;
|
||||
|
||||
/**
|
||||
* Languages constructor.
|
||||
*
|
||||
* @param string $language lang determined by autoLocale(), can be overridden.
|
||||
* @param ConfigManager $conf instance.
|
||||
*/
|
||||
public function __construct($language, $conf)
|
||||
{
|
||||
$this->conf = $conf;
|
||||
$confLanguage = $this->conf->get('translation.language', 'auto');
|
||||
// Auto mode or invalid parameter, use the detected language.
|
||||
// If the detected language is invalid, it doesn't matter, it will use English.
|
||||
if ($confLanguage === 'auto' || ! $this->isValidLanguage($confLanguage)) {
|
||||
$this->language = substr($language, 0, 5);
|
||||
} else {
|
||||
$this->language = $confLanguage;
|
||||
}
|
||||
|
||||
if (
|
||||
! extension_loaded('gettext')
|
||||
|| in_array($this->conf->get('translation.mode', 'auto'), ['auto', 'php'])
|
||||
) {
|
||||
$this->initPhpTranslator();
|
||||
} else {
|
||||
$this->initGettextTranslator();
|
||||
}
|
||||
|
||||
// Register default functions (e.g. '__()') to use our Translator
|
||||
$this->translator->register();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the translator using php gettext extension (gettext dependency act as a wrapper).
|
||||
*/
|
||||
protected function initGettextTranslator()
|
||||
{
|
||||
$this->translator = new GettextTranslator();
|
||||
$this->translator->setLanguage($this->language);
|
||||
$this->translator->loadDomain(self::DEFAULT_DOMAIN, 'inc/languages');
|
||||
|
||||
// Default extension translation from the current theme
|
||||
$themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') . '/' . $this->conf->get('theme') . '/language';
|
||||
if (is_dir($themeTransFolder)) {
|
||||
$this->translator->loadDomain($this->conf->get('theme'), $themeTransFolder, false);
|
||||
}
|
||||
|
||||
foreach ($this->conf->get('translation.extensions', []) as $domain => $translationPath) {
|
||||
if ($domain !== self::DEFAULT_DOMAIN) {
|
||||
$this->translator->loadDomain($domain, $translationPath, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the translator using a PHP implementation of gettext.
|
||||
*
|
||||
* Note that if language po file doesn't exist, errors are ignored (e.g. not installed language).
|
||||
*/
|
||||
protected function initPhpTranslator()
|
||||
{
|
||||
$this->translator = new Translator();
|
||||
$translations = new Translations();
|
||||
// Core translations
|
||||
try {
|
||||
$translations = $translations->addFromPoFile(
|
||||
'inc/languages/' . $this->language . '/LC_MESSAGES/shaarli.po'
|
||||
);
|
||||
$translations->setDomain('shaarli');
|
||||
$this->translator->loadTranslations($translations);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
}
|
||||
|
||||
// Default extension translation from the current theme
|
||||
$theme = $this->conf->get('theme');
|
||||
$themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') . '/' . $theme . '/language';
|
||||
if (is_dir($themeTransFolder)) {
|
||||
try {
|
||||
$translations = Translations::fromPoFile(
|
||||
$themeTransFolder . '/' . $this->language . '/LC_MESSAGES/' . $theme . '.po'
|
||||
);
|
||||
$translations->setDomain($theme);
|
||||
$this->translator->loadTranslations($translations);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
}
|
||||
}
|
||||
|
||||
// Extension translations (plugins, themes, etc.).
|
||||
foreach ($this->conf->get('translation.extensions', []) as $domain => $translationPath) {
|
||||
if ($domain === self::DEFAULT_DOMAIN) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$extension = Translations::fromPoFile(
|
||||
$translationPath . $this->language . '/LC_MESSAGES/' . $domain . '.po'
|
||||
);
|
||||
$extension->setDomain($domain);
|
||||
$this->translator->loadTranslations($extension);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a language string is valid.
|
||||
*
|
||||
* @param string $language e.g. 'fr' or 'en_US'
|
||||
*
|
||||
* @return bool true if valid, false otherwise
|
||||
*/
|
||||
protected function isValidLanguage($language)
|
||||
{
|
||||
return preg_match('/^[a-z]{2}(_[A-Z]{2})?/', $language) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of available languages for Shaarli.
|
||||
*
|
||||
* @return array List of available languages, with their label.
|
||||
*/
|
||||
public static function getAvailableLanguages()
|
||||
{
|
||||
return [
|
||||
'auto' => t('Automatic'),
|
||||
'de' => t('German'),
|
||||
'en' => t('English'),
|
||||
'fr' => t('French'),
|
||||
'jp' => t('Japanese'),
|
||||
'ru' => t('Russian'),
|
||||
'zh_CN' => t('Chinese (Simplified)'),
|
||||
];
|
||||
function t($text, $nText = '', $nb = 0) {
|
||||
if (empty($nText)) {
|
||||
return $text;
|
||||
}
|
||||
$actualForm = $nb > 1 ? $nText : $text;
|
||||
return sprintf($actualForm, $nb);
|
||||
}
|
||||
|
|
478
application/LinkDB.php
Normal file
478
application/LinkDB.php
Normal file
|
@ -0,0 +1,478 @@
|
|||
<?php
|
||||
/**
|
||||
* Data storage for links.
|
||||
*
|
||||
* This object behaves like an associative array.
|
||||
*
|
||||
* Example:
|
||||
* $myLinks = new LinkDB();
|
||||
* echo $myLinks['20110826_161819']['title'];
|
||||
* foreach ($myLinks as $link)
|
||||
* echo $link['title'].' at url '.$link['url'].'; description:'.$link['description'];
|
||||
*
|
||||
* Available keys:
|
||||
* - description: description of the entry
|
||||
* - linkdate: creation date of this entry, format: YYYYMMDD_HHMMSS
|
||||
* (e.g.'20110914_192317')
|
||||
* - updated: last modification date of this entry, format: YYYYMMDD_HHMMSS
|
||||
* - private: Is this link private? 0=no, other value=yes
|
||||
* - tags: tags attached to this entry (separated by spaces)
|
||||
* - title Title of the link
|
||||
* - url URL of the link. Used for displayable links (no redirector, relative, etc.).
|
||||
* Can be absolute or relative.
|
||||
* Relative URLs are permalinks (e.g.'?m-ukcw')
|
||||
* - real_url Absolute processed URL.
|
||||
*
|
||||
* Implements 3 interfaces:
|
||||
* - ArrayAccess: behaves like an associative array;
|
||||
* - Countable: there is a count() method;
|
||||
* - Iterator: usable in foreach () loops.
|
||||
*/
|
||||
class LinkDB implements Iterator, Countable, ArrayAccess
|
||||
{
|
||||
// Links are stored as a PHP serialized string
|
||||
private $datastore;
|
||||
|
||||
// Link date storage format
|
||||
const LINK_DATE_FORMAT = 'Ymd_His';
|
||||
|
||||
// Datastore PHP prefix
|
||||
protected static $phpPrefix = '<?php /* ';
|
||||
|
||||
// Datastore PHP suffix
|
||||
protected static $phpSuffix = ' */ ?>';
|
||||
|
||||
// List of links (associative array)
|
||||
// - key: link date (e.g. "20110823_124546"),
|
||||
// - value: associative array (keys: title, description...)
|
||||
private $links;
|
||||
|
||||
// List of all recorded URLs (key=url, value=linkdate)
|
||||
// for fast reserve search (url-->linkdate)
|
||||
private $urls;
|
||||
|
||||
// List of linkdate keys (for the Iterator interface implementation)
|
||||
private $keys;
|
||||
|
||||
// Position in the $this->keys array (for the Iterator interface)
|
||||
private $position;
|
||||
|
||||
// Is the user logged in? (used to filter private links)
|
||||
private $loggedIn;
|
||||
|
||||
// Hide public links
|
||||
private $hidePublicLinks;
|
||||
|
||||
// link redirector set in user settings.
|
||||
private $redirector;
|
||||
|
||||
/**
|
||||
* Set this to `true` to urlencode link behind redirector link, `false` to leave it untouched.
|
||||
*
|
||||
* Example:
|
||||
* anonym.to needs clean URL while dereferer.org needs urlencoded URL.
|
||||
*
|
||||
* @var boolean $redirectorEncode parameter: true or false
|
||||
*/
|
||||
private $redirectorEncode;
|
||||
|
||||
/**
|
||||
* Creates a new LinkDB
|
||||
*
|
||||
* Checks if the datastore exists; else, attempts to create a dummy one.
|
||||
*
|
||||
* @param string $datastore datastore file path.
|
||||
* @param boolean $isLoggedIn is the user logged in?
|
||||
* @param boolean $hidePublicLinks if true all links are private.
|
||||
* @param string $redirector link redirector set in user settings.
|
||||
* @param boolean $redirectorEncode Enable urlencode on redirected urls (default: true).
|
||||
*/
|
||||
public function __construct(
|
||||
$datastore,
|
||||
$isLoggedIn,
|
||||
$hidePublicLinks,
|
||||
$redirector = '',
|
||||
$redirectorEncode = true
|
||||
)
|
||||
{
|
||||
$this->datastore = $datastore;
|
||||
$this->loggedIn = $isLoggedIn;
|
||||
$this->hidePublicLinks = $hidePublicLinks;
|
||||
$this->redirector = $redirector;
|
||||
$this->redirectorEncode = $redirectorEncode === true;
|
||||
$this->check();
|
||||
$this->read();
|
||||
}
|
||||
|
||||
/**
|
||||
* Countable - Counts elements of an object
|
||||
*/
|
||||
public function count()
|
||||
{
|
||||
return count($this->links);
|
||||
}
|
||||
|
||||
/**
|
||||
* ArrayAccess - Assigns a value to the specified offset
|
||||
*/
|
||||
public function offsetSet($offset, $value)
|
||||
{
|
||||
// TODO: use exceptions instead of "die"
|
||||
if (!$this->loggedIn) {
|
||||
die('You are not authorized to add a link.');
|
||||
}
|
||||
if (empty($value['linkdate']) || empty($value['url'])) {
|
||||
die('Internal Error: A link should always have a linkdate and URL.');
|
||||
}
|
||||
if (empty($offset)) {
|
||||
die('You must specify a key.');
|
||||
}
|
||||
$this->links[$offset] = $value;
|
||||
$this->urls[$value['url']]=$offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* ArrayAccess - Whether or not an offset exists
|
||||
*/
|
||||
public function offsetExists($offset)
|
||||
{
|
||||
return array_key_exists($offset, $this->links);
|
||||
}
|
||||
|
||||
/**
|
||||
* ArrayAccess - Unsets an offset
|
||||
*/
|
||||
public function offsetUnset($offset)
|
||||
{
|
||||
if (!$this->loggedIn) {
|
||||
// TODO: raise an exception
|
||||
die('You are not authorized to delete a link.');
|
||||
}
|
||||
$url = $this->links[$offset]['url'];
|
||||
unset($this->urls[$url]);
|
||||
unset($this->links[$offset]);
|
||||
}
|
||||
|
||||
/**
|
||||
* ArrayAccess - Returns the value at specified offset
|
||||
*/
|
||||
public function offsetGet($offset)
|
||||
{
|
||||
return isset($this->links[$offset]) ? $this->links[$offset] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterator - Returns the current element
|
||||
*/
|
||||
public function current()
|
||||
{
|
||||
return $this->links[$this->keys[$this->position]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterator - Returns the key of the current element
|
||||
*/
|
||||
public function key()
|
||||
{
|
||||
return $this->keys[$this->position];
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterator - Moves forward to next element
|
||||
*/
|
||||
public function next()
|
||||
{
|
||||
++$this->position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterator - Rewinds the Iterator to the first element
|
||||
*
|
||||
* Entries are sorted by date (latest first)
|
||||
*/
|
||||
public function rewind()
|
||||
{
|
||||
$this->keys = array_keys($this->links);
|
||||
rsort($this->keys);
|
||||
$this->position = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterator - Checks if current position is valid
|
||||
*/
|
||||
public function valid()
|
||||
{
|
||||
return isset($this->keys[$this->position]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the DB directory and file exist
|
||||
*
|
||||
* If no DB file is found, creates a dummy DB.
|
||||
*/
|
||||
private function check()
|
||||
{
|
||||
if (file_exists($this->datastore)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a dummy database for example
|
||||
$this->links = array();
|
||||
$link = array(
|
||||
'title'=>' Shaarli: the personal, minimalist, super-fast, no-database delicious clone',
|
||||
'url'=>'https://github.com/shaarli/Shaarli/wiki',
|
||||
'description'=>'Welcome to Shaarli! This is your first public bookmark. To edit or delete me, you must first login.
|
||||
|
||||
To learn how to use Shaarli, consult the link "Help/documentation" at the bottom of this page.
|
||||
|
||||
You use the community supported version of the original Shaarli project, by Sebastien Sauvage.',
|
||||
'private'=>0,
|
||||
'linkdate'=> date('Ymd_His'),
|
||||
'tags'=>'opensource software'
|
||||
);
|
||||
$this->links[$link['linkdate']] = $link;
|
||||
|
||||
$link = array(
|
||||
'title'=>'My secret stuff... - Pastebin.com',
|
||||
'url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
|
||||
'description'=>'Shhhh! I\'m a private link only YOU can see. You can delete me too.',
|
||||
'private'=>1,
|
||||
'linkdate'=> date('Ymd_His', strtotime('-1 minute')),
|
||||
'tags'=>'secretstuff'
|
||||
);
|
||||
$this->links[$link['linkdate']] = $link;
|
||||
|
||||
// Write database to disk
|
||||
$this->write();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads database from disk to memory
|
||||
*/
|
||||
private function read()
|
||||
{
|
||||
|
||||
// Public links are hidden and user not logged in => nothing to show
|
||||
if ($this->hidePublicLinks && !$this->loggedIn) {
|
||||
$this->links = array();
|
||||
return;
|
||||
}
|
||||
|
||||
// Read data
|
||||
// Note that gzinflate is faster than gzuncompress.
|
||||
// See: http://www.php.net/manual/en/function.gzdeflate.php#96439
|
||||
$this->links = array();
|
||||
|
||||
if (file_exists($this->datastore)) {
|
||||
$this->links = unserialize(gzinflate(base64_decode(
|
||||
substr(file_get_contents($this->datastore),
|
||||
strlen(self::$phpPrefix), -strlen(self::$phpSuffix)))));
|
||||
}
|
||||
|
||||
// If user is not logged in, filter private links.
|
||||
if (!$this->loggedIn) {
|
||||
$toremove = array();
|
||||
foreach ($this->links as $link) {
|
||||
if ($link['private'] != 0) {
|
||||
$toremove[] = $link['linkdate'];
|
||||
}
|
||||
}
|
||||
foreach ($toremove as $linkdate) {
|
||||
unset($this->links[$linkdate]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->urls = array();
|
||||
foreach ($this->links as &$link) {
|
||||
// Keep the list of the mapping URLs-->linkdate up-to-date.
|
||||
$this->urls[$link['url']] = $link['linkdate'];
|
||||
|
||||
// Sanitize data fields.
|
||||
sanitizeLink($link);
|
||||
|
||||
// Remove private tags if the user is not logged in.
|
||||
if (! $this->loggedIn) {
|
||||
$link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']);
|
||||
}
|
||||
|
||||
// Do not use the redirector for internal links (Shaarli note URL starting with a '?').
|
||||
if (!empty($this->redirector) && !startsWith($link['url'], '?')) {
|
||||
$link['real_url'] = $this->redirector;
|
||||
if ($this->redirectorEncode) {
|
||||
$link['real_url'] .= urlencode(unescape($link['url']));
|
||||
} else {
|
||||
$link['real_url'] .= $link['url'];
|
||||
}
|
||||
}
|
||||
else {
|
||||
$link['real_url'] = $link['url'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the database from memory to disk
|
||||
*
|
||||
* @throws IOException the datastore is not writable
|
||||
*/
|
||||
private function write()
|
||||
{
|
||||
if (is_file($this->datastore) && !is_writeable($this->datastore)) {
|
||||
// The datastore exists but is not writeable
|
||||
throw new IOException($this->datastore);
|
||||
} else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
|
||||
// The datastore does not exist and its parent directory is not writeable
|
||||
throw new IOException(dirname($this->datastore));
|
||||
}
|
||||
|
||||
file_put_contents(
|
||||
$this->datastore,
|
||||
self::$phpPrefix.base64_encode(gzdeflate(serialize($this->links))).self::$phpSuffix
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the database from memory to disk
|
||||
*
|
||||
* @param string $pageCacheDir page cache directory
|
||||
*/
|
||||
public function save($pageCacheDir)
|
||||
{
|
||||
if (!$this->loggedIn) {
|
||||
// TODO: raise an Exception instead
|
||||
die('You are not authorized to change the database.');
|
||||
}
|
||||
|
||||
$this->write();
|
||||
|
||||
invalidateCaches($pageCacheDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the link for a given URL, or False if it does not exist.
|
||||
*
|
||||
* @param string $url URL to search for
|
||||
*
|
||||
* @return mixed the existing link if it exists, else 'false'
|
||||
*/
|
||||
public function getLinkFromUrl($url)
|
||||
{
|
||||
if (isset($this->urls[$url])) {
|
||||
return $this->links[$this->urls[$url]];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the shaare corresponding to a smallHash.
|
||||
*
|
||||
* @param string $request QUERY_STRING server parameter.
|
||||
*
|
||||
* @return array $filtered array containing permalink data.
|
||||
*
|
||||
* @throws LinkNotFoundException if the smallhash is malformed or doesn't match any link.
|
||||
*/
|
||||
public function filterHash($request)
|
||||
{
|
||||
$request = substr($request, 0, 6);
|
||||
$linkFilter = new LinkFilter($this->links);
|
||||
return $linkFilter->filter(LinkFilter::$FILTER_HASH, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of articles for a given day.
|
||||
*
|
||||
* @param string $request day to filter. Format: YYYYMMDD.
|
||||
*
|
||||
* @return array list of shaare found.
|
||||
*/
|
||||
public function filterDay($request) {
|
||||
$linkFilter = new LinkFilter($this->links);
|
||||
return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter links according to search parameters.
|
||||
*
|
||||
* @param array $filterRequest Search request content. Supported keys:
|
||||
* - searchtags: list of tags
|
||||
* - searchterm: term search
|
||||
* @param bool $casesensitive Optional: Perform case sensitive filter
|
||||
* @param bool $privateonly Optional: Returns private links only if true.
|
||||
*
|
||||
* @return array filtered links, all links if no suitable filter was provided.
|
||||
*/
|
||||
public function filterSearch($filterRequest = array(), $casesensitive = false, $privateonly = false)
|
||||
{
|
||||
// Filter link database according to parameters.
|
||||
$searchtags = !empty($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
|
||||
$searchterm = !empty($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
|
||||
|
||||
// Search tags + fullsearch.
|
||||
if (! empty($searchtags) && ! empty($searchterm)) {
|
||||
$type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT;
|
||||
$request = array($searchtags, $searchterm);
|
||||
}
|
||||
// Search by tags.
|
||||
elseif (! empty($searchtags)) {
|
||||
$type = LinkFilter::$FILTER_TAG;
|
||||
$request = $searchtags;
|
||||
}
|
||||
// Fulltext search.
|
||||
elseif (! empty($searchterm)) {
|
||||
$type = LinkFilter::$FILTER_TEXT;
|
||||
$request = $searchterm;
|
||||
}
|
||||
// Otherwise, display without filtering.
|
||||
else {
|
||||
$type = '';
|
||||
$request = '';
|
||||
}
|
||||
|
||||
$linkFilter = new LinkFilter($this->links);
|
||||
return $linkFilter->filter($type, $request, $casesensitive, $privateonly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of all tags
|
||||
* Output: associative array key=tags, value=0
|
||||
*/
|
||||
public function allTags()
|
||||
{
|
||||
$tags = array();
|
||||
$caseMapping = array();
|
||||
foreach ($this->links as $link) {
|
||||
foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) {
|
||||
if (empty($tag)) {
|
||||
continue;
|
||||
}
|
||||
// The first case found will be displayed.
|
||||
if (!isset($caseMapping[strtolower($tag)])) {
|
||||
$caseMapping[strtolower($tag)] = $tag;
|
||||
$tags[$caseMapping[strtolower($tag)]] = 0;
|
||||
}
|
||||
$tags[$caseMapping[strtolower($tag)]]++;
|
||||
}
|
||||
}
|
||||
// Sort tags by usage (most used tag first)
|
||||
arsort($tags);
|
||||
return $tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of days containing articles (oldest first)
|
||||
* Output: An array containing days (in format YYYYMMDD).
|
||||
*/
|
||||
public function days()
|
||||
{
|
||||
$linkDays = array();
|
||||
foreach (array_keys($this->links) as $day) {
|
||||
$linkDays[substr($day, 0, 8)] = 0;
|
||||
}
|
||||
$linkDays = array_keys($linkDays);
|
||||
sort($linkDays);
|
||||
|
||||
return $linkDays;
|
||||
}
|
||||
}
|
361
application/LinkFilter.php
Normal file
361
application/LinkFilter.php
Normal file
|
@ -0,0 +1,361 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Class LinkFilter.
|
||||
*
|
||||
* Perform search and filter operation on link data list.
|
||||
*/
|
||||
class LinkFilter
|
||||
{
|
||||
/**
|
||||
* @var string permalinks.
|
||||
*/
|
||||
public static $FILTER_HASH = 'permalink';
|
||||
|
||||
/**
|
||||
* @var string text search.
|
||||
*/
|
||||
public static $FILTER_TEXT = 'fulltext';
|
||||
|
||||
/**
|
||||
* @var string tag filter.
|
||||
*/
|
||||
public static $FILTER_TAG = 'tags';
|
||||
|
||||
/**
|
||||
* @var string filter by day.
|
||||
*/
|
||||
public static $FILTER_DAY = 'FILTER_DAY';
|
||||
|
||||
/**
|
||||
* @var string Allowed characters for hashtags (regex syntax).
|
||||
*/
|
||||
public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}';
|
||||
|
||||
/**
|
||||
* @var array all available links.
|
||||
*/
|
||||
private $links;
|
||||
|
||||
/**
|
||||
* @param array $links initialization.
|
||||
*/
|
||||
public function __construct($links)
|
||||
{
|
||||
$this->links = $links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter links according to parameters.
|
||||
*
|
||||
* @param string $type Type of filter (eg. tags, permalink, etc.).
|
||||
* @param mixed $request Filter content.
|
||||
* @param bool $casesensitive Optional: Perform case sensitive filter if true.
|
||||
* @param bool $privateonly Optional: Only returns private links if true.
|
||||
*
|
||||
* @return array filtered link list.
|
||||
*/
|
||||
public function filter($type, $request, $casesensitive = false, $privateonly = false)
|
||||
{
|
||||
switch($type) {
|
||||
case self::$FILTER_HASH:
|
||||
return $this->filterSmallHash($request);
|
||||
case self::$FILTER_TAG | self::$FILTER_TEXT:
|
||||
if (!empty($request)) {
|
||||
$filtered = $this->links;
|
||||
if (isset($request[0])) {
|
||||
$filtered = $this->filterTags($request[0], $casesensitive, $privateonly);
|
||||
}
|
||||
if (isset($request[1])) {
|
||||
$lf = new LinkFilter($filtered);
|
||||
$filtered = $lf->filterFulltext($request[1], $privateonly);
|
||||
}
|
||||
return $filtered;
|
||||
}
|
||||
return $this->noFilter($privateonly);
|
||||
case self::$FILTER_TEXT:
|
||||
return $this->filterFulltext($request, $privateonly);
|
||||
case self::$FILTER_TAG:
|
||||
return $this->filterTags($request, $casesensitive, $privateonly);
|
||||
case self::$FILTER_DAY:
|
||||
return $this->filterDay($request);
|
||||
default:
|
||||
return $this->noFilter($privateonly);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unknown filter, but handle private only.
|
||||
*
|
||||
* @param bool $privateonly returns private link only if true.
|
||||
*
|
||||
* @return array filtered links.
|
||||
*/
|
||||
private function noFilter($privateonly = false)
|
||||
{
|
||||
if (! $privateonly) {
|
||||
krsort($this->links);
|
||||
return $this->links;
|
||||
}
|
||||
|
||||
$out = array();
|
||||
foreach ($this->links as $value) {
|
||||
if ($value['private']) {
|
||||
$out[$value['linkdate']] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
krsort($out);
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the shaare corresponding to a smallHash.
|
||||
*
|
||||
* @param string $smallHash permalink hash.
|
||||
*
|
||||
* @return array $filtered array containing permalink data.
|
||||
*
|
||||
* @throws LinkNotFoundException if the smallhash doesn't match any link.
|
||||
*/
|
||||
private function filterSmallHash($smallHash)
|
||||
{
|
||||
$filtered = array();
|
||||
foreach ($this->links as $l) {
|
||||
if ($smallHash == smallHash($l['linkdate'])) {
|
||||
// Yes, this is ugly and slow
|
||||
$filtered[$l['linkdate']] = $l;
|
||||
return $filtered;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($filtered)) {
|
||||
throw new LinkNotFoundException();
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of links corresponding to a full-text search
|
||||
*
|
||||
* Searches:
|
||||
* - in the URLs, title and description;
|
||||
* - are case-insensitive;
|
||||
* - terms surrounded by quotes " are exact terms search.
|
||||
* - terms starting with a dash - are excluded (except exact terms).
|
||||
*
|
||||
* Example:
|
||||
* print_r($mydb->filterFulltext('hollandais'));
|
||||
*
|
||||
* mb_convert_case($val, MB_CASE_LOWER, 'UTF-8')
|
||||
* - allows to perform searches on Unicode text
|
||||
* - see https://github.com/shaarli/Shaarli/issues/75 for examples
|
||||
*
|
||||
* @param string $searchterms search query.
|
||||
* @param bool $privateonly return only private links if true.
|
||||
*
|
||||
* @return array search results.
|
||||
*/
|
||||
private function filterFulltext($searchterms, $privateonly = false)
|
||||
{
|
||||
if (empty($searchterms)) {
|
||||
return $this->links;
|
||||
}
|
||||
|
||||
$filtered = array();
|
||||
$search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
|
||||
$exactRegex = '/"([^"]+)"/';
|
||||
// Retrieve exact search terms.
|
||||
preg_match_all($exactRegex, $search, $exactSearch);
|
||||
$exactSearch = array_values(array_filter($exactSearch[1]));
|
||||
|
||||
// Remove exact search terms to get AND terms search.
|
||||
$explodedSearchAnd = explode(' ', trim(preg_replace($exactRegex, '', $search)));
|
||||
$explodedSearchAnd = array_values(array_filter($explodedSearchAnd));
|
||||
|
||||
// Filter excluding terms and update andSearch.
|
||||
$excludeSearch = array();
|
||||
$andSearch = array();
|
||||
foreach ($explodedSearchAnd as $needle) {
|
||||
if ($needle[0] == '-' && strlen($needle) > 1) {
|
||||
$excludeSearch[] = substr($needle, 1);
|
||||
} else {
|
||||
$andSearch[] = $needle;
|
||||
}
|
||||
}
|
||||
|
||||
$keys = array('title', 'description', 'url', 'tags');
|
||||
|
||||
// Iterate over every stored link.
|
||||
foreach ($this->links as $link) {
|
||||
|
||||
// ignore non private links when 'privatonly' is on.
|
||||
if (! $link['private'] && $privateonly === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Concatenate link fields to search across fields.
|
||||
// Adds a '\' separator for exact search terms.
|
||||
$content = '';
|
||||
foreach ($keys as $key) {
|
||||
$content .= mb_convert_case($link[$key], MB_CASE_LOWER, 'UTF-8') . '\\';
|
||||
}
|
||||
|
||||
// Be optimistic
|
||||
$found = true;
|
||||
|
||||
// First, we look for exact term search
|
||||
for ($i = 0; $i < count($exactSearch) && $found; $i++) {
|
||||
$found = strpos($content, $exactSearch[$i]) !== false;
|
||||
}
|
||||
|
||||
// Iterate over keywords, if keyword is not found,
|
||||
// no need to check for the others. We want all or nothing.
|
||||
for ($i = 0; $i < count($andSearch) && $found; $i++) {
|
||||
$found = strpos($content, $andSearch[$i]) !== false;
|
||||
}
|
||||
|
||||
// Exclude terms.
|
||||
for ($i = 0; $i < count($excludeSearch) && $found; $i++) {
|
||||
$found = strpos($content, $excludeSearch[$i]) === false;
|
||||
}
|
||||
|
||||
if ($found) {
|
||||
$filtered[$link['linkdate']] = $link;
|
||||
}
|
||||
}
|
||||
|
||||
krsort($filtered);
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of links associated with a given list of tags
|
||||
*
|
||||
* You can specify one or more tags, separated by space or a comma, e.g.
|
||||
* print_r($mydb->filterTags('linux programming'));
|
||||
*
|
||||
* @param string $tags list of tags separated by commas or blank spaces.
|
||||
* @param bool $casesensitive ignore case if false.
|
||||
* @param bool $privateonly returns private links only.
|
||||
*
|
||||
* @return array filtered links.
|
||||
*/
|
||||
public function filterTags($tags, $casesensitive = false, $privateonly = false)
|
||||
{
|
||||
// Implode if array for clean up.
|
||||
$tags = is_array($tags) ? trim(implode(' ', $tags)) : $tags;
|
||||
if (empty($tags)) {
|
||||
return $this->links;
|
||||
}
|
||||
|
||||
$searchtags = self::tagsStrToArray($tags, $casesensitive);
|
||||
$filtered = array();
|
||||
if (empty($searchtags)) {
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
foreach ($this->links as $link) {
|
||||
// ignore non private links when 'privatonly' is on.
|
||||
if (! $link['private'] && $privateonly === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$linktags = self::tagsStrToArray($link['tags'], $casesensitive);
|
||||
|
||||
$found = true;
|
||||
for ($i = 0 ; $i < count($searchtags) && $found; $i++) {
|
||||
// Exclusive search, quit if tag found.
|
||||
// Or, tag not found in the link, quit.
|
||||
if (($searchtags[$i][0] == '-'
|
||||
&& $this->searchTagAndHashTag(substr($searchtags[$i], 1), $linktags, $link['description']))
|
||||
|| ($searchtags[$i][0] != '-')
|
||||
&& ! $this->searchTagAndHashTag($searchtags[$i], $linktags, $link['description'])
|
||||
) {
|
||||
$found = false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($found) {
|
||||
$filtered[$link['linkdate']] = $link;
|
||||
}
|
||||
}
|
||||
krsort($filtered);
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of articles for a given day, chronologically sorted
|
||||
*
|
||||
* Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g.
|
||||
* print_r($mydb->filterDay('20120125'));
|
||||
*
|
||||
* @param string $day day to filter.
|
||||
*
|
||||
* @return array all link matching given day.
|
||||
*
|
||||
* @throws Exception if date format is invalid.
|
||||
*/
|
||||
public function filterDay($day)
|
||||
{
|
||||
if (! checkDateFormat('Ymd', $day)) {
|
||||
throw new Exception('Invalid date format');
|
||||
}
|
||||
|
||||
$filtered = array();
|
||||
foreach ($this->links as $l) {
|
||||
if (startsWith($l['linkdate'], $day)) {
|
||||
$filtered[$l['linkdate']] = $l;
|
||||
}
|
||||
}
|
||||
ksort($filtered);
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tag is found in the taglist, or as an hashtag in the link description.
|
||||
*
|
||||
* @param string $tag Tag to search.
|
||||
* @param array $taglist List of tags for the current link.
|
||||
* @param string $description Link description.
|
||||
*
|
||||
* @return bool True if found, false otherwise.
|
||||
*/
|
||||
protected function searchTagAndHashTag($tag, $taglist, $description)
|
||||
{
|
||||
if (in_array($tag, $taglist)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preg_match('/(^| )#'. $tag .'([^'. self::$HASHTAG_CHARS .']|$)/mui', $description) > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a list of tags (str) to an array. Also
|
||||
* - handle case sensitivity.
|
||||
* - accepts spaces commas as separator.
|
||||
*
|
||||
* @param string $tags string containing a list of tags.
|
||||
* @param bool $casesensitive will convert everything to lowercase if false.
|
||||
*
|
||||
* @return array filtered tags string.
|
||||
*/
|
||||
public static function tagsStrToArray($tags, $casesensitive)
|
||||
{
|
||||
// We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
|
||||
$tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8');
|
||||
$tagsOut = str_replace(',', ' ', $tagsOut);
|
||||
|
||||
return array_values(array_filter(explode(' ', trim($tagsOut)), 'strlen'));
|
||||
}
|
||||
}
|
||||
|
||||
class LinkNotFoundException extends Exception
|
||||
{
|
||||
protected $message = 'The link you are trying to reach does not exist or has been deleted.';
|
||||
}
|
171
application/LinkUtils.php
Normal file
171
application/LinkUtils.php
Normal file
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Extract title from an HTML document.
|
||||
*
|
||||
* @param string $html HTML content where to look for a title.
|
||||
*
|
||||
* @return bool|string Extracted title if found, false otherwise.
|
||||
*/
|
||||
function html_extract_title($html)
|
||||
{
|
||||
if (preg_match('!<title.*?>(.*?)</title>!is', $html, $matches)) {
|
||||
return trim(str_replace("\n", '', $matches[1]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine charset from downloaded page.
|
||||
* Priority:
|
||||
* 1. HTTP headers (Content type).
|
||||
* 2. HTML content page (tag <meta charset>).
|
||||
* 3. Use a default charset (default: UTF-8).
|
||||
*
|
||||
* @param array $headers HTTP headers array.
|
||||
* @param string $htmlContent HTML content where to look for charset.
|
||||
* @param string $defaultCharset Default charset to apply if other methods failed.
|
||||
*
|
||||
* @return string Determined charset.
|
||||
*/
|
||||
function get_charset($headers, $htmlContent, $defaultCharset = 'utf-8')
|
||||
{
|
||||
if ($charset = headers_extract_charset($headers)) {
|
||||
return $charset;
|
||||
}
|
||||
|
||||
if ($charset = html_extract_charset($htmlContent)) {
|
||||
return $charset;
|
||||
}
|
||||
|
||||
return $defaultCharset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract charset from HTTP headers if it's defined.
|
||||
*
|
||||
* @param array $headers HTTP headers array.
|
||||
*
|
||||
* @return bool|string Charset string if found (lowercase), false otherwise.
|
||||
*/
|
||||
function headers_extract_charset($headers)
|
||||
{
|
||||
if (! empty($headers['Content-Type']) && strpos($headers['Content-Type'], 'charset=') !== false) {
|
||||
preg_match('/charset="?([^; ]+)/i', $headers['Content-Type'], $match);
|
||||
if (! empty($match[1])) {
|
||||
return strtolower(trim($match[1]));
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract charset HTML content (tag <meta charset>).
|
||||
*
|
||||
* @param string $html HTML content where to look for charset.
|
||||
*
|
||||
* @return bool|string Charset string if found, false otherwise.
|
||||
*/
|
||||
function html_extract_charset($html)
|
||||
{
|
||||
// Get encoding specified in HTML header.
|
||||
preg_match('#<meta .*charset=["\']?([^";\'>/]+)["\']? */?>#Usi', $html, $enc);
|
||||
if (!empty($enc[1])) {
|
||||
return strtolower($enc[1]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count private links in given linklist.
|
||||
*
|
||||
* @param array|Countable $links Linklist.
|
||||
*
|
||||
* @return int Number of private links.
|
||||
*/
|
||||
function count_private($links)
|
||||
{
|
||||
$cpt = 0;
|
||||
foreach ($links as $link) {
|
||||
$cpt = $link['private'] == true ? $cpt + 1 : $cpt;
|
||||
}
|
||||
|
||||
return $cpt;
|
||||
}
|
||||
|
||||
/**
|
||||
* In a string, converts URLs to clickable links.
|
||||
*
|
||||
* @param string $text input string.
|
||||
* @param string $redirector if a redirector is set, use it to gerenate links.
|
||||
*
|
||||
* @return string returns $text with all links converted to HTML links.
|
||||
*
|
||||
* @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
|
||||
*/
|
||||
function text2clickable($text, $redirector = '')
|
||||
{
|
||||
$regex = '!(((?:https?|ftp|file)://|apt:|magnet:)\S+[[:alnum:]]/?)!si';
|
||||
|
||||
if (empty($redirector)) {
|
||||
return preg_replace($regex, '<a href="$1">$1</a>', $text);
|
||||
}
|
||||
// Redirector is set, urlencode the final URL.
|
||||
return preg_replace_callback(
|
||||
$regex,
|
||||
function ($matches) use ($redirector) {
|
||||
return '<a href="' . $redirector . urlencode($matches[1]) .'">'. $matches[1] .'</a>';
|
||||
},
|
||||
$text
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-link hashtags.
|
||||
*
|
||||
* @param string $description Given description.
|
||||
* @param string $indexUrl Root URL.
|
||||
*
|
||||
* @return string Description with auto-linked hashtags.
|
||||
*/
|
||||
function hashtag_autolink($description, $indexUrl = '')
|
||||
{
|
||||
/*
|
||||
* To support unicode: http://stackoverflow.com/a/35498078/1484919
|
||||
* \p{Pc} - to match underscore
|
||||
* \p{N} - numeric character in any script
|
||||
* \p{L} - letter from any language
|
||||
* \p{Mn} - any non marking space (accents, umlauts, etc)
|
||||
*/
|
||||
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
|
||||
$replacement = '$1<a href="'. $indexUrl .'?addtag=$2" title="Hashtag $2">#$2</a>';
|
||||
return preg_replace($regex, $replacement, $description);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function inserts where relevant so that multiple spaces are properly displayed in HTML
|
||||
* even in the absence of <pre> (This is used in description to keep text formatting).
|
||||
*
|
||||
* @param string $text input text.
|
||||
*
|
||||
* @return string formatted text.
|
||||
*/
|
||||
function space2nbsp($text)
|
||||
{
|
||||
return preg_replace('/(^| ) /m', '$1 ', $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Shaarli's description
|
||||
*
|
||||
* @param string $description shaare's description.
|
||||
* @param string $redirector if a redirector is set, use it to gerenate links.
|
||||
* @param string $indexUrl URL to Shaarli's index.
|
||||
*
|
||||
* @return string formatted description.
|
||||
*/
|
||||
function format_description($description, $redirector = '', $indexUrl = '') {
|
||||
return nl2br(space2nbsp(hashtag_autolink(text2clickable($description, $redirector), $indexUrl)));
|
||||
}
|
195
application/NetscapeBookmarkUtils.php
Normal file
195
application/NetscapeBookmarkUtils.php
Normal file
|
@ -0,0 +1,195 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Utilities to import and export bookmarks using the Netscape format
|
||||
*/
|
||||
class NetscapeBookmarkUtils
|
||||
{
|
||||
|
||||
/**
|
||||
* Filters links and adds Netscape-formatted fields
|
||||
*
|
||||
* Added fields:
|
||||
* - timestamp link addition date, using the Unix epoch format
|
||||
* - taglist comma-separated tag list
|
||||
*
|
||||
* @param LinkDB $linkDb Link datastore
|
||||
* @param string $selection Which links to export: (all|private|public)
|
||||
* @param bool $prependNoteUrl Prepend note permalinks with the server's URL
|
||||
* @param string $indexUrl Absolute URL of the Shaarli index page
|
||||
*
|
||||
* @throws Exception Invalid export selection
|
||||
*
|
||||
* @return array The links to be exported, with additional fields
|
||||
*/
|
||||
public static function filterAndFormat($linkDb, $selection, $prependNoteUrl, $indexUrl)
|
||||
{
|
||||
// see tpl/export.html for possible values
|
||||
if (! in_array($selection, array('all', 'public', 'private'))) {
|
||||
throw new Exception('Invalid export selection: "'.$selection.'"');
|
||||
}
|
||||
|
||||
$bookmarkLinks = array();
|
||||
|
||||
foreach ($linkDb as $link) {
|
||||
if ($link['private'] != 0 && $selection == 'public') {
|
||||
continue;
|
||||
}
|
||||
if ($link['private'] == 0 && $selection == 'private') {
|
||||
continue;
|
||||
}
|
||||
$date = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $link['linkdate']);
|
||||
$link['timestamp'] = $date->getTimestamp();
|
||||
$link['taglist'] = str_replace(' ', ',', $link['tags']);
|
||||
|
||||
if (startsWith($link['url'], '?') && $prependNoteUrl) {
|
||||
$link['url'] = $indexUrl . $link['url'];
|
||||
}
|
||||
|
||||
$bookmarkLinks[] = $link;
|
||||
}
|
||||
|
||||
return $bookmarkLinks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an import status summary
|
||||
*
|
||||
* @param string $filename name of the file to import
|
||||
* @param int $filesize size of the file to import
|
||||
* @param int $importCount how many links were imported
|
||||
* @param int $overwriteCount how many links were overwritten
|
||||
* @param int $skipCount how many links were skipped
|
||||
*
|
||||
* @return string Summary of the bookmark import status
|
||||
*/
|
||||
private static function importStatus(
|
||||
$filename,
|
||||
$filesize,
|
||||
$importCount=0,
|
||||
$overwriteCount=0,
|
||||
$skipCount=0
|
||||
)
|
||||
{
|
||||
$status = 'File '.$filename.' ('.$filesize.' bytes) ';
|
||||
if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) {
|
||||
$status .= 'has an unknown file format. Nothing was imported.';
|
||||
} else {
|
||||
$status .= 'was successfully processed: '.$importCount.' links imported, ';
|
||||
$status .= $overwriteCount.' links overwritten, ';
|
||||
$status .= $skipCount.' links skipped.';
|
||||
}
|
||||
return $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports Web bookmarks from an uploaded Netscape bookmark dump
|
||||
*
|
||||
* @param array $post Server $_POST parameters
|
||||
* @param array $files Server $_FILES parameters
|
||||
* @param LinkDB $linkDb Loaded LinkDB instance
|
||||
* @param string $pagecache Page cache
|
||||
*
|
||||
* @return string Summary of the bookmark import status
|
||||
*/
|
||||
public static function import($post, $files, $linkDb, $pagecache)
|
||||
{
|
||||
$filename = $files['filetoupload']['name'];
|
||||
$filesize = $files['filetoupload']['size'];
|
||||
$data = file_get_contents($files['filetoupload']['tmp_name']);
|
||||
|
||||
if (strpos($data, '<!DOCTYPE NETSCAPE-Bookmark-file-1>') === false) {
|
||||
return self::importStatus($filename, $filesize);
|
||||
}
|
||||
|
||||
// Overwrite existing links?
|
||||
$overwrite = ! empty($post['overwrite']);
|
||||
|
||||
// Add tags to all imported links?
|
||||
if (empty($post['default_tags'])) {
|
||||
$defaultTags = array();
|
||||
} else {
|
||||
$defaultTags = preg_split(
|
||||
'/[\s,]+/',
|
||||
escape($post['default_tags'])
|
||||
);
|
||||
}
|
||||
|
||||
// links are imported as public by default
|
||||
$defaultPrivacy = 0;
|
||||
|
||||
$parser = new NetscapeBookmarkParser(
|
||||
true, // nested tag support
|
||||
$defaultTags, // additional user-specified tags
|
||||
strval(1 - $defaultPrivacy) // defaultPub = 1 - defaultPrivacy
|
||||
);
|
||||
$bookmarks = $parser->parseString($data);
|
||||
|
||||
$importCount = 0;
|
||||
$overwriteCount = 0;
|
||||
$skipCount = 0;
|
||||
|
||||
foreach ($bookmarks as $bkm) {
|
||||
$private = $defaultPrivacy;
|
||||
if (empty($post['privacy']) || $post['privacy'] == 'default') {
|
||||
// use value from the imported file
|
||||
$private = $bkm['pub'] == '1' ? 0 : 1;
|
||||
} else if ($post['privacy'] == 'private') {
|
||||
// all imported links are private
|
||||
$private = 1;
|
||||
} else if ($post['privacy'] == 'public') {
|
||||
// all imported links are public
|
||||
$private = 0;
|
||||
}
|
||||
|
||||
$newLink = array(
|
||||
'title' => $bkm['title'],
|
||||
'url' => $bkm['uri'],
|
||||
'description' => $bkm['note'],
|
||||
'private' => $private,
|
||||
'linkdate'=> '',
|
||||
'tags' => $bkm['tags']
|
||||
);
|
||||
|
||||
$existingLink = $linkDb->getLinkFromUrl($bkm['uri']);
|
||||
|
||||
if ($existingLink !== false) {
|
||||
if ($overwrite === false) {
|
||||
// Do not overwrite an existing link
|
||||
$skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Overwrite an existing link, keep its date
|
||||
$newLink['linkdate'] = $existingLink['linkdate'];
|
||||
$linkDb[$existingLink['linkdate']] = $newLink;
|
||||
$importCount++;
|
||||
$overwriteCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add a new link
|
||||
$newLinkDate = new DateTime('@'.strval($bkm['time']));
|
||||
while (!empty($linkDb[$newLinkDate->format(LinkDB::LINK_DATE_FORMAT)])) {
|
||||
// Ensure the date/time is not already used
|
||||
// - this hack is necessary as the date/time acts as a primary key
|
||||
// - apply 1 second increments until an unused index is found
|
||||
// See https://github.com/shaarli/Shaarli/issues/351
|
||||
$newLinkDate->add(new DateInterval('PT1S'));
|
||||
}
|
||||
$linkDbDate = $newLinkDate->format(LinkDB::LINK_DATE_FORMAT);
|
||||
$newLink['linkdate'] = $linkDbDate;
|
||||
$linkDb[$linkDbDate] = $newLink;
|
||||
$importCount++;
|
||||
}
|
||||
|
||||
$linkDb->save($pagecache);
|
||||
return self::importStatus(
|
||||
$filename,
|
||||
$filesize,
|
||||
$importCount,
|
||||
$overwriteCount,
|
||||
$skipCount
|
||||
);
|
||||
}
|
||||
}
|
149
application/PageBuilder.php
Normal file
149
application/PageBuilder.php
Normal file
|
@ -0,0 +1,149 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* This class is in charge of building the final page.
|
||||
* (This is basically a wrapper around RainTPL which pre-fills some fields.)
|
||||
* $p = new PageBuilder();
|
||||
* $p->assign('myfield','myvalue');
|
||||
* $p->renderPage('mytemplate');
|
||||
*/
|
||||
class PageBuilder
|
||||
{
|
||||
/**
|
||||
* @var RainTPL RainTPL instance.
|
||||
*/
|
||||
private $tpl;
|
||||
|
||||
/**
|
||||
* @var ConfigManager $conf Configuration Manager instance.
|
||||
*/
|
||||
protected $conf;
|
||||
|
||||
/**
|
||||
* PageBuilder constructor.
|
||||
* $tpl is initialized at false for lazy loading.
|
||||
*
|
||||
* @param ConfigManager $conf Configuration Manager instance (reference).
|
||||
*/
|
||||
function __construct(&$conf)
|
||||
{
|
||||
$this->tpl = false;
|
||||
$this->conf = $conf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all default tpl tags.
|
||||
*/
|
||||
private function initialize()
|
||||
{
|
||||
$this->tpl = new RainTPL();
|
||||
|
||||
try {
|
||||
$version = ApplicationUtils::checkUpdate(
|
||||
shaarli_version,
|
||||
$this->conf->get('resource.update_check'),
|
||||
$this->conf->get('updates.check_updates_interval'),
|
||||
$this->conf->get('updates.check_updates'),
|
||||
isLoggedIn(),
|
||||
$this->conf->get('updates.check_updates_branch')
|
||||
);
|
||||
$this->tpl->assign('newVersion', escape($version));
|
||||
$this->tpl->assign('versionError', '');
|
||||
|
||||
} catch (Exception $exc) {
|
||||
logm($this->conf->get('resource.log'), $_SERVER['REMOTE_ADDR'], $exc->getMessage());
|
||||
$this->tpl->assign('newVersion', '');
|
||||
$this->tpl->assign('versionError', escape($exc->getMessage()));
|
||||
}
|
||||
|
||||
$this->tpl->assign('feedurl', escape(index_url($_SERVER)));
|
||||
$searchcrits = ''; // Search criteria
|
||||
if (!empty($_GET['searchtags'])) {
|
||||
$searchcrits .= '&searchtags=' . urlencode($_GET['searchtags']);
|
||||
}
|
||||
if (!empty($_GET['searchterm'])) {
|
||||
$searchcrits .= '&searchterm=' . urlencode($_GET['searchterm']);
|
||||
}
|
||||
$this->tpl->assign('searchcrits', $searchcrits);
|
||||
$this->tpl->assign('source', index_url($_SERVER));
|
||||
$this->tpl->assign('version', shaarli_version);
|
||||
$this->tpl->assign('scripturl', index_url($_SERVER));
|
||||
$this->tpl->assign('privateonly', !empty($_SESSION['privateonly'])); // Show only private links?
|
||||
$this->tpl->assign('pagetitle', $this->conf->get('general.title', 'Shaarli'));
|
||||
if ($this->conf->exists('general.header_link')) {
|
||||
$this->tpl->assign('titleLink', $this->conf->get('general.header_link'));
|
||||
}
|
||||
$this->tpl->assign('shaarlititle', $this->conf->get('general.title', 'Shaarli'));
|
||||
$this->tpl->assign('openshaarli', $this->conf->get('security.open_shaarli', false));
|
||||
$this->tpl->assign('showatom', $this->conf->get('feed.show_atom', false));
|
||||
$this->tpl->assign('hide_timestamps', $this->conf->get('privacy.hide_timestamps', false));
|
||||
$this->tpl->assign('token', getToken($this->conf));
|
||||
// To be removed with a proper theme configuration.
|
||||
$this->tpl->assign('conf', $this->conf->get('resource.theme', 'default'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The following assign() method is basically the same as RainTPL (except lazy loading)
|
||||
*
|
||||
* @param string $placeholder Template placeholder.
|
||||
* @param mixed $value Value to assign.
|
||||
*/
|
||||
public function assign($placeholder, $value)
|
||||
{
|
||||
if ($this->tpl === false) {
|
||||
$this->initialize();
|
||||
}
|
||||
$this->tpl->assign($placeholder, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign an array of data to the template builder.
|
||||
*
|
||||
* @param array $data Data to assign.
|
||||
*
|
||||
* @return false if invalid data.
|
||||
*/
|
||||
public function assignAll($data)
|
||||
{
|
||||
if ($this->tpl === false) {
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
if (empty($data) || !is_array($data)){
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($data as $key => $value) {
|
||||
$this->assign($key, $value);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a specific page (using a template file).
|
||||
* e.g. $pb->renderPage('picwall');
|
||||
*
|
||||
* @param string $page Template filename (without extension).
|
||||
*/
|
||||
public function renderPage($page)
|
||||
{
|
||||
if ($this->tpl === false) {
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
$this->tpl->draw($page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a 404 page (uses the template : tpl/404.tpl)
|
||||
* usage : $PAGE->render404('The link was deleted')
|
||||
*
|
||||
* @param string $message A messate to display what is not found
|
||||
*/
|
||||
public function render404($message = 'The page you are trying to reach does not exist or has been deleted.')
|
||||
{
|
||||
header($_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found');
|
||||
$this->tpl->assign('error_message', $message);
|
||||
$this->renderPage('404');
|
||||
}
|
||||
}
|
242
application/PluginManager.php
Normal file
242
application/PluginManager.php
Normal file
|
@ -0,0 +1,242 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Class PluginManager
|
||||
*
|
||||
* Use to manage, load and execute plugins.
|
||||
*/
|
||||
class PluginManager
|
||||
{
|
||||
/**
|
||||
* List of authorized plugins from configuration file.
|
||||
* @var array $authorizedPlugins
|
||||
*/
|
||||
private $authorizedPlugins;
|
||||
|
||||
/**
|
||||
* List of loaded plugins.
|
||||
* @var array $loadedPlugins
|
||||
*/
|
||||
private $loadedPlugins = array();
|
||||
|
||||
/**
|
||||
* @var ConfigManager Configuration Manager instance.
|
||||
*/
|
||||
protected $conf;
|
||||
|
||||
/**
|
||||
* @var array List of plugin errors.
|
||||
*/
|
||||
protected $errors;
|
||||
|
||||
/**
|
||||
* Plugins subdirectory.
|
||||
* @var string $PLUGINS_PATH
|
||||
*/
|
||||
public static $PLUGINS_PATH = 'plugins';
|
||||
|
||||
/**
|
||||
* Plugins meta files extension.
|
||||
* @var string $META_EXT
|
||||
*/
|
||||
public static $META_EXT = 'meta';
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param ConfigManager $conf Configuration Manager instance.
|
||||
*/
|
||||
public function __construct(&$conf)
|
||||
{
|
||||
$this->conf = $conf;
|
||||
$this->errors = array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load plugins listed in $authorizedPlugins.
|
||||
*
|
||||
* @param array $authorizedPlugins Names of plugin authorized to be loaded.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function load($authorizedPlugins)
|
||||
{
|
||||
$this->authorizedPlugins = $authorizedPlugins;
|
||||
|
||||
$dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR);
|
||||
$dirnames = array_map('basename', $dirs);
|
||||
foreach ($this->authorizedPlugins as $plugin) {
|
||||
$index = array_search($plugin, $dirnames);
|
||||
|
||||
// plugin authorized, but its folder isn't listed
|
||||
if ($index === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->loadPlugin($dirs[$index], $plugin);
|
||||
}
|
||||
catch (PluginFileNotFoundException $e) {
|
||||
error_log($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all plugins registered hook.
|
||||
*
|
||||
* @param string $hook name of the hook to trigger.
|
||||
* @param array $data list of data to manipulate passed by reference.
|
||||
* @param array $params additional parameters such as page target.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function executeHooks($hook, &$data, $params = array())
|
||||
{
|
||||
if (!empty($params['target'])) {
|
||||
$data['_PAGE_'] = $params['target'];
|
||||
}
|
||||
|
||||
if (isset($params['loggedin'])) {
|
||||
$data['_LOGGEDIN_'] = $params['loggedin'];
|
||||
}
|
||||
|
||||
foreach ($this->loadedPlugins as $plugin) {
|
||||
$hookFunction = $this->buildHookName($hook, $plugin);
|
||||
|
||||
if (function_exists($hookFunction)) {
|
||||
$data = call_user_func($hookFunction, $data, $this->conf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single plugin from its files.
|
||||
* Call the init function if it exists, and collect errors.
|
||||
* Add them in $loadedPlugins if successful.
|
||||
*
|
||||
* @param string $dir plugin's directory.
|
||||
* @param string $pluginName plugin's name.
|
||||
*
|
||||
* @return void
|
||||
* @throws PluginFileNotFoundException - plugin files not found.
|
||||
*/
|
||||
private function loadPlugin($dir, $pluginName)
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
throw new PluginFileNotFoundException($pluginName);
|
||||
}
|
||||
|
||||
$pluginFilePath = $dir . '/' . $pluginName . '.php';
|
||||
if (!is_file($pluginFilePath)) {
|
||||
throw new PluginFileNotFoundException($pluginName);
|
||||
}
|
||||
|
||||
$conf = $this->conf;
|
||||
include_once $pluginFilePath;
|
||||
|
||||
$initFunction = $pluginName . '_init';
|
||||
if (function_exists($initFunction)) {
|
||||
$errors = call_user_func($initFunction, $this->conf);
|
||||
if (!empty($errors)) {
|
||||
$this->errors = array_merge($this->errors, $errors);
|
||||
}
|
||||
}
|
||||
|
||||
$this->loadedPlugins[] = $pluginName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct normalize hook name for a specific plugin.
|
||||
*
|
||||
* Format:
|
||||
* hook_<plugin_name>_<hook_name>
|
||||
*
|
||||
* @param string $hook hook name.
|
||||
* @param string $pluginName plugin name.
|
||||
*
|
||||
* @return string - plugin's hook name.
|
||||
*/
|
||||
public function buildHookName($hook, $pluginName)
|
||||
{
|
||||
return 'hook_' . $pluginName . '_' . $hook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve plugins metadata from *.meta (INI) files into an array.
|
||||
* Metadata contains:
|
||||
* - plugin description [description]
|
||||
* - parameters split with ';' [parameters]
|
||||
*
|
||||
* Respects plugins order from settings.
|
||||
*
|
||||
* @return array plugins metadata.
|
||||
*/
|
||||
public function getPluginsMeta()
|
||||
{
|
||||
$metaData = array();
|
||||
$dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK);
|
||||
|
||||
// Browse all plugin directories.
|
||||
foreach ($dirs as $pluginDir) {
|
||||
$plugin = basename($pluginDir);
|
||||
$metaFile = $pluginDir . $plugin . '.' . self::$META_EXT;
|
||||
if (!is_file($metaFile) || !is_readable($metaFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$metaData[$plugin] = parse_ini_file($metaFile);
|
||||
$metaData[$plugin]['order'] = array_search($plugin, $this->authorizedPlugins);
|
||||
|
||||
// Read parameters and format them into an array.
|
||||
if (isset($metaData[$plugin]['parameters'])) {
|
||||
$params = explode(';', $metaData[$plugin]['parameters']);
|
||||
} else {
|
||||
$params = array();
|
||||
}
|
||||
$metaData[$plugin]['parameters'] = array();
|
||||
foreach ($params as $param) {
|
||||
if (empty($param)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$metaData[$plugin]['parameters'][$param]['value'] = '';
|
||||
// Optional parameter description in parameter.PARAM_NAME=
|
||||
if (isset($metaData[$plugin]['parameter.'. $param])) {
|
||||
$metaData[$plugin]['parameters'][$param]['desc'] = $metaData[$plugin]['parameter.'. $param];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $metaData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of encountered errors.
|
||||
*
|
||||
* @return array List of errors (empty array if none exists).
|
||||
*/
|
||||
public function getErrors()
|
||||
{
|
||||
return $this->errors;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class PluginFileNotFoundException
|
||||
*
|
||||
* Raise when plugin files can't be found.
|
||||
*/
|
||||
class PluginFileNotFoundException extends Exception
|
||||
{
|
||||
/**
|
||||
* Construct exception with plugin name.
|
||||
* Generate message.
|
||||
*
|
||||
* @param string $pluginName name of the plugin not found
|
||||
*/
|
||||
public function __construct($pluginName)
|
||||
{
|
||||
$this->message = 'Plugin "'. $pluginName .'" files not found.';
|
||||
}
|
||||
}
|
141
application/Router.php
Normal file
141
application/Router.php
Normal file
|
@ -0,0 +1,141 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Class Router
|
||||
*
|
||||
* (only displayable pages here)
|
||||
*/
|
||||
class Router
|
||||
{
|
||||
public static $PAGE_LOGIN = 'login';
|
||||
|
||||
public static $PAGE_PICWALL = 'picwall';
|
||||
|
||||
public static $PAGE_TAGCLOUD = 'tagcloud';
|
||||
|
||||
public static $PAGE_DAILY = 'daily';
|
||||
|
||||
public static $PAGE_FEED_ATOM = 'atom';
|
||||
|
||||
public static $PAGE_FEED_RSS = 'rss';
|
||||
|
||||
public static $PAGE_TOOLS = 'tools';
|
||||
|
||||
public static $PAGE_CHANGEPASSWORD = 'changepasswd';
|
||||
|
||||
public static $PAGE_CONFIGURE = 'configure';
|
||||
|
||||
public static $PAGE_CHANGETAG = 'changetag';
|
||||
|
||||
public static $PAGE_ADDLINK = 'addlink';
|
||||
|
||||
public static $PAGE_EDITLINK = 'edit_link';
|
||||
|
||||
public static $PAGE_EXPORT = 'export';
|
||||
|
||||
public static $PAGE_IMPORT = 'import';
|
||||
|
||||
public static $PAGE_OPENSEARCH = 'opensearch';
|
||||
|
||||
public static $PAGE_LINKLIST = 'linklist';
|
||||
|
||||
public static $PAGE_PLUGINSADMIN = 'pluginadmin';
|
||||
|
||||
public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin';
|
||||
|
||||
/**
|
||||
* Reproducing renderPage() if hell, to avoid regression.
|
||||
*
|
||||
* This highlights how bad this needs to be rewrite,
|
||||
* but let's focus on plugins for now.
|
||||
*
|
||||
* @param string $query $_SERVER['QUERY_STRING'].
|
||||
* @param array $get $_SERVER['GET'].
|
||||
* @param bool $loggedIn true if authenticated user.
|
||||
*
|
||||
* @return string page found.
|
||||
*/
|
||||
public static function findPage($query, $get, $loggedIn)
|
||||
{
|
||||
$loggedIn = ($loggedIn === true) ? true : false;
|
||||
|
||||
if (empty($query) && !isset($get['edit_link']) && !isset($get['post'])) {
|
||||
return self::$PAGE_LINKLIST;
|
||||
}
|
||||
|
||||
if (startsWith($query, 'do='. self::$PAGE_LOGIN) && $loggedIn === false) {
|
||||
return self::$PAGE_LOGIN;
|
||||
}
|
||||
|
||||
if (startsWith($query, 'do='. self::$PAGE_PICWALL)) {
|
||||
return self::$PAGE_PICWALL;
|
||||
}
|
||||
|
||||
if (startsWith($query, 'do='. self::$PAGE_TAGCLOUD)) {
|
||||
return self::$PAGE_TAGCLOUD;
|
||||
}
|
||||
|
||||
if (startsWith($query, 'do='. self::$PAGE_OPENSEARCH)) {
|
||||
return self::$PAGE_OPENSEARCH;
|
||||
}
|
||||
|
||||
if (startsWith($query, 'do='. self::$PAGE_DAILY)) {
|
||||
return self::$PAGE_DAILY;
|
||||
}
|
||||
|
||||
if (startsWith($query, 'do='. self::$PAGE_FEED_ATOM)) {
|
||||
return self::$PAGE_FEED_ATOM;
|
||||
}
|
||||
|
||||
if (startsWith($query, 'do='. self::$PAGE_FEED_RSS)) {
|
||||
return self::$PAGE_FEED_RSS;
|
||||
}
|
||||
|
||||
// At this point, only loggedin pages.
|
||||
if (!$loggedIn) {
|
||||
return self::$PAGE_LINKLIST;
|
||||
}
|
||||
|
||||
if (startsWith($query, 'do='. self::$PAGE_TOOLS)) {
|
||||
return self::$PAGE_TOOLS;
|
||||
}
|
||||
|
||||
if (startsWith($query, 'do='. self::$PAGE_CHANGEPASSWORD)) {
|
||||
return self::$PAGE_CHANGEPASSWORD;
|
||||
}
|
||||
|
||||
if (startsWith($query, 'do='. self::$PAGE_CONFIGURE)) {
|
||||
return self::$PAGE_CONFIGURE;
|
||||
}
|
||||
|
||||
if (startsWith($query, 'do='. self::$PAGE_CHANGETAG)) {
|
||||
return self::$PAGE_CHANGETAG;
|
||||
}
|
||||
|
||||
if (startsWith($query, 'do='. self::$PAGE_ADDLINK)) {
|
||||
return self::$PAGE_ADDLINK;
|
||||
}
|
||||
|
||||
if (isset($get['edit_link']) || isset($get['post'])) {
|
||||
return self::$PAGE_EDITLINK;
|
||||
}
|
||||
|
||||
if (startsWith($query, 'do='. self::$PAGE_EXPORT)) {
|
||||
return self::$PAGE_EXPORT;
|
||||
}
|
||||
|
||||
if (startsWith($query, 'do='. self::$PAGE_IMPORT)) {
|
||||
return self::$PAGE_IMPORT;
|
||||
}
|
||||
|
||||
if (startsWith($query, 'do='. self::$PAGE_PLUGINSADMIN)) {
|
||||
return self::$PAGE_PLUGINSADMIN;
|
||||
}
|
||||
|
||||
if (startsWith($query, 'do='. self::$PAGE_SAVE_PLUGINSADMIN)) {
|
||||
return self::$PAGE_SAVE_PLUGINSADMIN;
|
||||
}
|
||||
|
||||
return self::$PAGE_LINKLIST;
|
||||
}
|
||||
}
|
|
@ -1,131 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli;
|
||||
|
||||
use Shaarli\Config\ConfigManager;
|
||||
use WebThumbnailer\Application\ConfigManager as WTConfigManager;
|
||||
use WebThumbnailer\WebThumbnailer;
|
||||
|
||||
/**
|
||||
* Class Thumbnailer
|
||||
*
|
||||
* Utility class used to retrieve thumbnails using web-thumbnailer dependency.
|
||||
*/
|
||||
class Thumbnailer
|
||||
{
|
||||
protected const COMMON_MEDIA_DOMAINS = [
|
||||
'imgur.com',
|
||||
'flickr.com',
|
||||
'youtube.com',
|
||||
'wikimedia.org',
|
||||
'redd.it',
|
||||
'gfycat.com',
|
||||
'media.giphy.com',
|
||||
'twitter.com',
|
||||
'twimg.com',
|
||||
'instagram.com',
|
||||
'pinterest.com',
|
||||
'pinterest.fr',
|
||||
'soundcloud.com',
|
||||
'tumblr.com',
|
||||
'deviantart.com',
|
||||
];
|
||||
|
||||
public const MODE_ALL = 'all';
|
||||
public const MODE_COMMON = 'common';
|
||||
public const MODE_NONE = 'none';
|
||||
|
||||
/**
|
||||
* @var WebThumbnailer instance.
|
||||
*/
|
||||
protected $wt;
|
||||
|
||||
/**
|
||||
* @var ConfigManager instance.
|
||||
*/
|
||||
protected $conf;
|
||||
|
||||
/**
|
||||
* Thumbnailer constructor.
|
||||
*
|
||||
* @param ConfigManager $conf instance.
|
||||
*/
|
||||
public function __construct($conf)
|
||||
{
|
||||
$this->conf = $conf;
|
||||
|
||||
if (! $this->checkRequirements()) {
|
||||
$this->conf->set('thumbnails.mode', Thumbnailer::MODE_NONE);
|
||||
$this->conf->write(true);
|
||||
// TODO: create a proper error handling system able to catch exceptions...
|
||||
die(t(
|
||||
'php-gd extension must be loaded to use thumbnails. '
|
||||
. 'Thumbnails are now disabled. Please reload the page.'
|
||||
));
|
||||
}
|
||||
|
||||
$this->wt = new WebThumbnailer();
|
||||
WTConfigManager::addFile('inc/web-thumbnailer.json');
|
||||
$this->wt->maxWidth($this->conf->get('thumbnails.width'))
|
||||
->maxHeight($this->conf->get('thumbnails.height'))
|
||||
->crop(true)
|
||||
->debug($this->conf->get('dev.debug', false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a thumbnail for given URL
|
||||
*
|
||||
* @param string $url where to look for a thumbnail.
|
||||
*
|
||||
* @return bool|string The thumbnail relative cache file path, or false if none has been found.
|
||||
*/
|
||||
public function get($url)
|
||||
{
|
||||
if (
|
||||
$this->conf->get('thumbnails.mode') === self::MODE_COMMON
|
||||
&& ! $this->isCommonMediaOrImage($url)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->wt->thumbnail($url);
|
||||
} catch (\Throwable $e) {
|
||||
// Exceptions are only thrown in debug mode.
|
||||
error_log(get_class($e) . ': ' . $e->getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* We check weather the given URL is from a common media domain,
|
||||
* or if the file extension is an image.
|
||||
*
|
||||
* @param string $url to check
|
||||
*
|
||||
* @return bool true if it's an image or from a common media domain, false otherwise.
|
||||
*/
|
||||
public function isCommonMediaOrImage($url)
|
||||
{
|
||||
foreach (self::COMMON_MEDIA_DOMAINS as $domain) {
|
||||
if (strpos($url, $domain) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (endsWith($url, '.jpg') || endsWith($url, '.png') || endsWith($url, '.jpeg')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that requirements are match to use thumbnails:
|
||||
* - php-gd is loaded
|
||||
*/
|
||||
protected function checkRequirements()
|
||||
{
|
||||
return extension_loaded('gd');
|
||||
}
|
||||
}
|
|
@ -1,76 +1,88 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Generates a list of available timezone continents and cities.
|
||||
* Generates the timezone selection form and JavaScript.
|
||||
*
|
||||
* Two distinct array based on available timezones
|
||||
* and the one selected in the settings:
|
||||
* - (0) continents:
|
||||
* + list of available continents
|
||||
* + special key 'selected' containing the value of the selected timezone's continent
|
||||
* - (1) cities:
|
||||
* + list of available cities associated with their continent
|
||||
* + special key 'selected' containing the value of the selected timezone's city (without the continent)
|
||||
* Note: 'UTC/UTC' is mapped to 'UTC' to form a valid option
|
||||
*
|
||||
* Example:
|
||||
* [
|
||||
* [
|
||||
* 'America',
|
||||
* 'Europe',
|
||||
* 'selected' => 'Europe',
|
||||
* ],
|
||||
* [
|
||||
* ['continent' => 'America', 'city' => 'Toronto'],
|
||||
* ['continent' => 'Europe', 'city' => 'Paris'],
|
||||
* 'selected' => 'Paris',
|
||||
* ],
|
||||
* ];
|
||||
* Example: preselect Europe/Paris
|
||||
* list($htmlform, $js) = generateTimeZoneForm('Europe/Paris');
|
||||
*
|
||||
* Notes:
|
||||
* - 'UTC/UTC' is mapped to 'UTC' to form a valid option
|
||||
* - a few timezone cities includes the country/state, such as Argentina/Buenos_Aires
|
||||
* - these arrays are designed to build timezone selects in template files with any HTML structure
|
||||
*
|
||||
* @param array $installedTimeZones List of installed timezones as string
|
||||
* @param string $preselectedTimezone preselected timezone (optional)
|
||||
*
|
||||
* @return array[] continents and cities
|
||||
* @return array containing the generated HTML form and Javascript code
|
||||
**/
|
||||
function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
|
||||
function generateTimeZoneForm($preselectedTimezone='')
|
||||
{
|
||||
// Select the server timezone
|
||||
if ($preselectedTimezone == '') {
|
||||
$preselectedTimezone = date_default_timezone_get();
|
||||
}
|
||||
|
||||
if ($preselectedTimezone == 'UTC') {
|
||||
$pcity = $pcontinent = 'UTC';
|
||||
} else {
|
||||
// Try to split the provided timezone
|
||||
$spos = strpos($preselectedTimezone, '/');
|
||||
$pcontinent = substr($preselectedTimezone, 0, $spos);
|
||||
$pcity = substr($preselectedTimezone, $spos + 1);
|
||||
$pcity = substr($preselectedTimezone, $spos+1);
|
||||
}
|
||||
|
||||
$continents = [];
|
||||
$cities = [];
|
||||
foreach ($installedTimeZones as $tz) {
|
||||
// The list is in the form 'Europe/Paris', 'America/Argentina/Buenos_Aires'
|
||||
// We split the list in continents/cities.
|
||||
$continents = array();
|
||||
$cities = array();
|
||||
|
||||
// TODO: use a template to generate the HTML/Javascript form
|
||||
|
||||
foreach (timezone_identifiers_list() as $tz) {
|
||||
if ($tz == 'UTC') {
|
||||
$tz = 'UTC/UTC';
|
||||
}
|
||||
$spos = strpos($tz, '/');
|
||||
|
||||
// Ignore invalid timezones
|
||||
if ($spos === false) {
|
||||
continue;
|
||||
}
|
||||
if ($spos !== false) {
|
||||
$continent = substr($tz, 0, $spos);
|
||||
$city = substr($tz, $spos+1);
|
||||
$continents[$continent] = 1;
|
||||
|
||||
$continent = substr($tz, 0, $spos);
|
||||
$city = substr($tz, $spos + 1);
|
||||
$cities[] = ['continent' => $continent, 'city' => $city];
|
||||
$continents[$continent] = true;
|
||||
if (!isset($cities[$continent])) {
|
||||
$cities[$continent] = '';
|
||||
}
|
||||
$cities[$continent] .= '<option value="'.$city.'"';
|
||||
if ($pcity == $city) {
|
||||
$cities[$continent] .= ' selected="selected"';
|
||||
}
|
||||
$cities[$continent] .= '>'.$city.'</option>';
|
||||
}
|
||||
}
|
||||
|
||||
$continentsHtml = '';
|
||||
$continents = array_keys($continents);
|
||||
$continents['selected'] = $pcontinent;
|
||||
$cities['selected'] = $pcity;
|
||||
|
||||
return [$continents, $cities];
|
||||
foreach ($continents as $continent) {
|
||||
$continentsHtml .= '<option value="'.$continent.'"';
|
||||
if ($pcontinent == $continent) {
|
||||
$continentsHtml .= ' selected="selected"';
|
||||
}
|
||||
$continentsHtml .= '>'.$continent.'</option>';
|
||||
}
|
||||
|
||||
// Timezone selection form
|
||||
$timezoneForm = 'Continent:';
|
||||
$timezoneForm .= '<select name="continent" id="continent" onChange="onChangecontinent();">';
|
||||
$timezoneForm .= $continentsHtml.'</select>';
|
||||
$timezoneForm .= ' City:';
|
||||
$timezoneForm .= '<select name="city" id="city">'.$cities[$pcontinent].'</select><br />';
|
||||
|
||||
// Javascript handler - updates the city list when the user selects a continent
|
||||
$timezoneJs = '<script>';
|
||||
$timezoneJs .= 'function onChangecontinent() {';
|
||||
$timezoneJs .= 'document.getElementById("city").innerHTML =';
|
||||
$timezoneJs .= ' citiescontinent[document.getElementById("continent").value]; }';
|
||||
$timezoneJs .= 'var citiescontinent = '.json_encode($cities).';';
|
||||
$timezoneJs .= '</script>';
|
||||
|
||||
return array($timezoneForm, $timezoneJs);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -86,7 +98,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
|
|||
function isTimeZoneValid($continent, $city)
|
||||
{
|
||||
return in_array(
|
||||
$continent . '/' . $city,
|
||||
$continent.'/'.$city,
|
||||
timezone_identifiers_list()
|
||||
);
|
||||
}
|
||||
|
|
311
application/Updater.php
Normal file
311
application/Updater.php
Normal file
|
@ -0,0 +1,311 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Class Updater.
|
||||
* Used to update stuff when a new Shaarli's version is reached.
|
||||
* Update methods are ran only once, and the stored in a JSON file.
|
||||
*/
|
||||
class Updater
|
||||
{
|
||||
/**
|
||||
* @var array Updates which are already done.
|
||||
*/
|
||||
protected $doneUpdates;
|
||||
|
||||
/**
|
||||
* @var LinkDB instance.
|
||||
*/
|
||||
protected $linkDB;
|
||||
|
||||
/**
|
||||
* @var ConfigManager $conf Configuration Manager instance.
|
||||
*/
|
||||
protected $conf;
|
||||
|
||||
/**
|
||||
* @var bool True if the user is logged in, false otherwise.
|
||||
*/
|
||||
protected $isLoggedIn;
|
||||
|
||||
/**
|
||||
* @var ReflectionMethod[] List of current class methods.
|
||||
*/
|
||||
protected $methods;
|
||||
|
||||
/**
|
||||
* Object constructor.
|
||||
*
|
||||
* @param array $doneUpdates Updates which are already done.
|
||||
* @param LinkDB $linkDB LinkDB instance.
|
||||
* @param ConfigManager $conf Configuration Manager instance.
|
||||
* @param boolean $isLoggedIn True if the user is logged in.
|
||||
*/
|
||||
public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn)
|
||||
{
|
||||
$this->doneUpdates = $doneUpdates;
|
||||
$this->linkDB = $linkDB;
|
||||
$this->conf = $conf;
|
||||
$this->isLoggedIn = $isLoggedIn;
|
||||
|
||||
// Retrieve all update methods.
|
||||
$class = new ReflectionClass($this);
|
||||
$this->methods = $class->getMethods();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all new updates.
|
||||
* Update methods have to start with 'updateMethod' and return true (on success).
|
||||
*
|
||||
* @return array An array containing ran updates.
|
||||
*
|
||||
* @throws UpdaterException If something went wrong.
|
||||
*/
|
||||
public function update()
|
||||
{
|
||||
$updatesRan = array();
|
||||
|
||||
// If the user isn't logged in, exit without updating.
|
||||
if ($this->isLoggedIn !== true) {
|
||||
return $updatesRan;
|
||||
}
|
||||
|
||||
if ($this->methods == null) {
|
||||
throw new UpdaterException('Couldn\'t retrieve Updater class methods.');
|
||||
}
|
||||
|
||||
foreach ($this->methods as $method) {
|
||||
// Not an update method or already done, pass.
|
||||
if (! startsWith($method->getName(), 'updateMethod')
|
||||
|| in_array($method->getName(), $this->doneUpdates)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$method->setAccessible(true);
|
||||
$res = $method->invoke($this);
|
||||
// Update method must return true to be considered processed.
|
||||
if ($res === true) {
|
||||
$updatesRan[] = $method->getName();
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
throw new UpdaterException($method, $e);
|
||||
}
|
||||
}
|
||||
|
||||
$this->doneUpdates = array_merge($this->doneUpdates, $updatesRan);
|
||||
|
||||
return $updatesRan;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array Updates methods already processed.
|
||||
*/
|
||||
public function getDoneUpdates()
|
||||
{
|
||||
return $this->doneUpdates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move deprecated options.php to config.php.
|
||||
*
|
||||
* Milestone 0.9 (old versioning) - shaarli/Shaarli#41:
|
||||
* options.php is not supported anymore.
|
||||
*/
|
||||
public function updateMethodMergeDeprecatedConfigFile()
|
||||
{
|
||||
if (is_file($this->conf->get('resource.data_dir') . '/options.php')) {
|
||||
include $this->conf->get('resource.data_dir') . '/options.php';
|
||||
|
||||
// Load GLOBALS into config
|
||||
$allowedKeys = array_merge(ConfigPhp::$ROOT_KEYS);
|
||||
$allowedKeys[] = 'config';
|
||||
foreach ($GLOBALS as $key => $value) {
|
||||
if (in_array($key, $allowedKeys)) {
|
||||
$this->conf->set($key, $value);
|
||||
}
|
||||
}
|
||||
$this->conf->write($this->isLoggedIn);
|
||||
unlink($this->conf->get('resource.data_dir').'/options.php');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename tags starting with a '-' to work with tag exclusion search.
|
||||
*/
|
||||
public function updateMethodRenameDashTags()
|
||||
{
|
||||
$linklist = $this->linkDB->filterSearch();
|
||||
foreach ($linklist as $link) {
|
||||
$link['tags'] = preg_replace('/(^| )\-/', '$1', $link['tags']);
|
||||
$link['tags'] = implode(' ', array_unique(LinkFilter::tagsStrToArray($link['tags'], true)));
|
||||
$this->linkDB[$link['linkdate']] = $link;
|
||||
}
|
||||
$this->linkDB->save($this->conf->get('resource.page_cache'));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move old configuration in PHP to the new config system in JSON format.
|
||||
*
|
||||
* Will rename 'config.php' into 'config.save.php' and create 'config.json.php'.
|
||||
* It will also convert legacy setting keys to the new ones.
|
||||
*/
|
||||
public function updateMethodConfigToJson()
|
||||
{
|
||||
// JSON config already exists, nothing to do.
|
||||
if ($this->conf->getConfigIO() instanceof ConfigJson) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$configPhp = new ConfigPhp();
|
||||
$configJson = new ConfigJson();
|
||||
$oldConfig = $configPhp->read($this->conf->getConfigFile() . '.php');
|
||||
rename($this->conf->getConfigFileExt(), $this->conf->getConfigFile() . '.save.php');
|
||||
$this->conf->setConfigIO($configJson);
|
||||
$this->conf->reload();
|
||||
|
||||
$legacyMap = array_flip(ConfigPhp::$LEGACY_KEYS_MAPPING);
|
||||
foreach (ConfigPhp::$ROOT_KEYS as $key) {
|
||||
$this->conf->set($legacyMap[$key], $oldConfig[$key]);
|
||||
}
|
||||
|
||||
// Set sub config keys (config and plugins)
|
||||
$subConfig = array('config', 'plugins');
|
||||
foreach ($subConfig as $sub) {
|
||||
foreach ($oldConfig[$sub] as $key => $value) {
|
||||
if (isset($legacyMap[$sub .'.'. $key])) {
|
||||
$configKey = $legacyMap[$sub .'.'. $key];
|
||||
} else {
|
||||
$configKey = $sub .'.'. $key;
|
||||
}
|
||||
$this->conf->set($configKey, $value);
|
||||
}
|
||||
}
|
||||
|
||||
try{
|
||||
$this->conf->write($this->isLoggedIn);
|
||||
return true;
|
||||
} catch (IOException $e) {
|
||||
error_log($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape settings which have been manually escaped in every request in previous versions:
|
||||
* - general.title
|
||||
* - general.header_link
|
||||
* - redirector.url
|
||||
*
|
||||
* @return bool true if the update is successful, false otherwise.
|
||||
*/
|
||||
public function updateMethodEscapeUnescapedConfig()
|
||||
{
|
||||
try {
|
||||
$this->conf->set('general.title', escape($this->conf->get('general.title')));
|
||||
$this->conf->set('general.header_link', escape($this->conf->get('general.header_link')));
|
||||
$this->conf->set('redirector.url', escape($this->conf->get('redirector.url')));
|
||||
$this->conf->write($this->isLoggedIn);
|
||||
} catch (Exception $e) {
|
||||
error_log($e->getMessage());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class UpdaterException.
|
||||
*/
|
||||
class UpdaterException extends Exception
|
||||
{
|
||||
/**
|
||||
* @var string Method where the error occurred.
|
||||
*/
|
||||
protected $method;
|
||||
|
||||
/**
|
||||
* @var Exception The parent exception.
|
||||
*/
|
||||
protected $previous;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param string $message Force the error message if set.
|
||||
* @param string $method Method where the error occurred.
|
||||
* @param Exception|bool $previous Parent exception.
|
||||
*/
|
||||
public function __construct($message = '', $method = '', $previous = false)
|
||||
{
|
||||
$this->method = $method;
|
||||
$this->previous = $previous;
|
||||
$this->message = $this->buildMessage($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the exception error message.
|
||||
*
|
||||
* @param string $message Optional given error message.
|
||||
*
|
||||
* @return string The built error message.
|
||||
*/
|
||||
private function buildMessage($message)
|
||||
{
|
||||
$out = '';
|
||||
if (! empty($message)) {
|
||||
$out .= $message . PHP_EOL;
|
||||
}
|
||||
|
||||
if (! empty($this->method)) {
|
||||
$out .= 'An error occurred while running the update '. $this->method . PHP_EOL;
|
||||
}
|
||||
|
||||
if (! empty($this->previous)) {
|
||||
$out .= ' '. $this->previous->getMessage();
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the updates file, and return already done updates.
|
||||
*
|
||||
* @param string $updatesFilepath Updates file path.
|
||||
*
|
||||
* @return array Already done update methods.
|
||||
*/
|
||||
function read_updates_file($updatesFilepath)
|
||||
{
|
||||
if (! empty($updatesFilepath) && is_file($updatesFilepath)) {
|
||||
$content = file_get_contents($updatesFilepath);
|
||||
if (! empty($content)) {
|
||||
return explode(';', $content);
|
||||
}
|
||||
}
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Write updates file.
|
||||
*
|
||||
* @param string $updatesFilepath Updates file path.
|
||||
* @param array $updates Updates array to write.
|
||||
*
|
||||
* @throws Exception Couldn't write version number.
|
||||
*/
|
||||
function write_updates_file($updatesFilepath, $updates)
|
||||
{
|
||||
if (empty($updatesFilepath)) {
|
||||
throw new Exception('Updates file path is not set, can\'t write updates.');
|
||||
}
|
||||
|
||||
$res = file_put_contents($updatesFilepath, implode(';', $updates));
|
||||
if ($res === false) {
|
||||
throw new Exception('Unable to write updates in '. $updatesFilepath . '.');
|
||||
}
|
||||
}
|
|
@ -1,6 +1,67 @@
|
|||
<?php
|
||||
/**
|
||||
* Converts an array-represented URL to a string
|
||||
*
|
||||
* Source: http://php.net/manual/en/function.parse-url.php#106731
|
||||
*
|
||||
* @see http://php.net/manual/en/function.parse-url.php
|
||||
*
|
||||
* @param array $parsedUrl an array-represented URL
|
||||
*
|
||||
* @return string the string representation of the URL
|
||||
*/
|
||||
function unparse_url($parsedUrl)
|
||||
{
|
||||
$scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'].'://' : '';
|
||||
$host = isset($parsedUrl['host']) ? $parsedUrl['host'] : '';
|
||||
$port = isset($parsedUrl['port']) ? ':'.$parsedUrl['port'] : '';
|
||||
$user = isset($parsedUrl['user']) ? $parsedUrl['user'] : '';
|
||||
$pass = isset($parsedUrl['pass']) ? ':'.$parsedUrl['pass'] : '';
|
||||
$pass = ($user || $pass) ? "$pass@" : '';
|
||||
$path = isset($parsedUrl['path']) ? $parsedUrl['path'] : '';
|
||||
$query = isset($parsedUrl['query']) ? '?'.$parsedUrl['query'] : '';
|
||||
$fragment = isset($parsedUrl['fragment']) ? '#'.$parsedUrl['fragment'] : '';
|
||||
|
||||
namespace Shaarli\Http;
|
||||
return "$scheme$user$pass$host$port$path$query$fragment";
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes undesired query parameters and fragments
|
||||
*
|
||||
* @param string url Url to be cleaned
|
||||
*
|
||||
* @return string the string representation of this URL after cleanup
|
||||
*/
|
||||
function cleanup_url($url)
|
||||
{
|
||||
$obj_url = new Url($url);
|
||||
return $obj_url->cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get URL scheme.
|
||||
*
|
||||
* @param string url Url for which the scheme is requested
|
||||
*
|
||||
* @return mixed the URL scheme or false if none is provided.
|
||||
*/
|
||||
function get_url_scheme($url)
|
||||
{
|
||||
$obj_url = new Url($url);
|
||||
return $obj_url->getScheme();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a trailing slash at the end of URL if necessary.
|
||||
*
|
||||
* @param string $url URL to check/edit.
|
||||
*
|
||||
* @return string $url URL with a end trailing slash.
|
||||
*/
|
||||
function add_trailing_slash($url)
|
||||
{
|
||||
return $url . (!endsWith($url, '/') ? '/' : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* URL representation and cleanup utilities
|
||||
|
@ -17,7 +78,7 @@
|
|||
*/
|
||||
class Url
|
||||
{
|
||||
private static $annoyingQueryParams = [
|
||||
private static $annoyingQueryParams = array(
|
||||
// Facebook
|
||||
'action_object_map=',
|
||||
'action_ref_map=',
|
||||
|
@ -33,19 +94,16 @@ class Url
|
|||
'utm_',
|
||||
|
||||
// ATInternet
|
||||
'xtor=',
|
||||
'xtor='
|
||||
);
|
||||
|
||||
// Other
|
||||
'campaign_'
|
||||
];
|
||||
|
||||
private static $annoyingFragments = [
|
||||
private static $annoyingFragments = array(
|
||||
// ATInternet
|
||||
'xtor=RSS-',
|
||||
|
||||
// Misc.
|
||||
'tk.rss_all'
|
||||
];
|
||||
);
|
||||
|
||||
/*
|
||||
* URL parts represented as an array
|
||||
|
@ -61,7 +119,6 @@ class Url
|
|||
*/
|
||||
public function __construct($url)
|
||||
{
|
||||
$url = $url ?? '';
|
||||
$url = self::cleanupUnparsedUrl(trim($url));
|
||||
$this->parts = parse_url($url);
|
||||
|
||||
|
@ -98,7 +155,7 @@ protected static function removeFirefoxAboutReader($input)
|
|||
}
|
||||
return $input;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a string representation of this URL
|
||||
*/
|
||||
|
@ -112,7 +169,7 @@ public function toString()
|
|||
*/
|
||||
protected function cleanupQuery()
|
||||
{
|
||||
if (!isset($this->parts['query'])) {
|
||||
if (! isset($this->parts['query'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -121,7 +178,7 @@ protected function cleanupQuery()
|
|||
foreach (self::$annoyingQueryParams as $annoying) {
|
||||
foreach ($queryParams as $param) {
|
||||
if (startsWith($param, $annoying)) {
|
||||
$queryParams = array_diff($queryParams, [$param]);
|
||||
$queryParams = array_diff($queryParams, array($param));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
@ -133,14 +190,14 @@ protected function cleanupQuery()
|
|||
}
|
||||
|
||||
$this->parts['query'] = implode('&', $queryParams);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes undesired fragments
|
||||
*/
|
||||
protected function cleanupFragment()
|
||||
{
|
||||
if (!isset($this->parts['fragment'])) {
|
||||
if (! isset($this->parts['fragment'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -173,10 +230,10 @@ public function cleanup()
|
|||
public function idnToAscii()
|
||||
{
|
||||
$out = $this->cleanup();
|
||||
if (!function_exists('idn_to_ascii') || !isset($this->parts['host'])) {
|
||||
if (! function_exists('idn_to_ascii') || ! isset($this->parts['host'])) {
|
||||
return $out;
|
||||
}
|
||||
$asciiHost = idn_to_ascii($this->parts['host'], 0, INTL_IDNA_VARIANT_UTS46);
|
||||
$asciiHost = idn_to_ascii($this->parts['host']);
|
||||
return str_replace($this->parts['host'], $asciiHost, $out);
|
||||
}
|
||||
|
||||
|
@ -185,8 +242,7 @@ public function idnToAscii()
|
|||
*
|
||||
* @return string the URL scheme or false if none is provided.
|
||||
*/
|
||||
public function getScheme()
|
||||
{
|
||||
public function getScheme() {
|
||||
if (!isset($this->parts['scheme'])) {
|
||||
return false;
|
||||
}
|
||||
|
@ -198,8 +254,7 @@ public function getScheme()
|
|||
*
|
||||
* @return string the URL host or false if none is provided.
|
||||
*/
|
||||
public function getHost()
|
||||
{
|
||||
public function getHost() {
|
||||
if (empty($this->parts['host'])) {
|
||||
return false;
|
||||
}
|
||||
|
@ -207,12 +262,11 @@ public function getHost()
|
|||
}
|
||||
|
||||
/**
|
||||
* Test if the UrlUtils is an HTTP one.
|
||||
* Test if the Url is an HTTP one.
|
||||
*
|
||||
* @return true is HTTP, false otherwise.
|
||||
*/
|
||||
public function isHttp()
|
||||
{
|
||||
public function isHttp() {
|
||||
return strpos(strtolower($this->parts['scheme']), 'http') !== false;
|
||||
}
|
||||
}
|
|
@ -1,27 +1,24 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Shaarli utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format log using provided data.
|
||||
* Logs a message to a text file
|
||||
*
|
||||
* @param string $message the message to log
|
||||
* @param string|null $clientIp the client's remote IPv4/IPv6 address
|
||||
* The log format is compatible with fail2ban.
|
||||
*
|
||||
* @return string Formatted message to log
|
||||
* @param string $logFile where to write the logs
|
||||
* @param string $clientIp the client's remote IPv4/IPv6 address
|
||||
* @param string $message the message to log
|
||||
*/
|
||||
function format_log(string $message, string $clientIp = null): string
|
||||
function logm($logFile, $clientIp, $message)
|
||||
{
|
||||
$out = $message;
|
||||
|
||||
if (!empty($clientIp)) {
|
||||
// Note: we keep the first dash to avoid breaking fail2ban configs
|
||||
$out = '- ' . $clientIp . ' - ' . $out;
|
||||
}
|
||||
|
||||
return $out;
|
||||
file_put_contents(
|
||||
$logFile,
|
||||
date('Y/m/d H:i:s').' - '.$clientIp.' - '.strval($message).PHP_EOL,
|
||||
FILE_APPEND
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -34,11 +31,7 @@ function format_log(string $message, string $clientIp = null): string
|
|||
* - are NOT cryptographically secure (they CAN be forged)
|
||||
*
|
||||
* In Shaarli, they are used as a tinyurl-like link to individual entries,
|
||||
* built once with the combination of the date and item ID.
|
||||
* e.g. smallHash('20111006_131924' . 142) --> eaWxtQ
|
||||
*
|
||||
* @warning before v0.8.1, smallhashes were built only with the date,
|
||||
* and their value has been preserved.
|
||||
* e.g. smallHash('20111006_131924') --> yZH23w
|
||||
*
|
||||
* @param string $text Create a hash from this text.
|
||||
*
|
||||
|
@ -61,7 +54,6 @@ function smallHash($text)
|
|||
*/
|
||||
function startsWith($haystack, $needle, $case = true)
|
||||
{
|
||||
$needle = $needle ?? '';
|
||||
if ($case) {
|
||||
return (strcmp(substr($haystack, 0, strlen($needle)), $needle) === 0);
|
||||
}
|
||||
|
@ -91,22 +83,14 @@ function endsWith($haystack, $needle, $case = true)
|
|||
*
|
||||
* @param mixed $input Data to escape: a single string or an array of strings.
|
||||
*
|
||||
* @return string|array escaped.
|
||||
* @return string escaped.
|
||||
*/
|
||||
function escape($input)
|
||||
{
|
||||
if (null === $input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_bool($input) || is_int($input) || is_float($input) || $input instanceof DateTimeInterface) {
|
||||
return $input;
|
||||
}
|
||||
|
||||
if (is_array($input)) {
|
||||
$out = [];
|
||||
foreach ($input as $key => $value) {
|
||||
$out[escape($key)] = escape($value);
|
||||
$out = array();
|
||||
foreach($input as $key => $value) {
|
||||
$out[$key] = escape($value);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
@ -165,12 +149,12 @@ function checkDateFormat($format, $string)
|
|||
*
|
||||
* @return string $referer - final referer.
|
||||
*/
|
||||
function generateLocation($referer, $host, $loopTerms = [])
|
||||
function generateLocation($referer, $host, $loopTerms = array())
|
||||
{
|
||||
$finalReferer = './?';
|
||||
$finalReferer = '?';
|
||||
|
||||
// No referer if it contains any value in $loopCriteria.
|
||||
foreach (array_filter($loopTerms) as $value) {
|
||||
foreach ($loopTerms as $value) {
|
||||
if (strpos($referer, $value) !== false) {
|
||||
return $finalReferer;
|
||||
}
|
||||
|
@ -181,7 +165,7 @@ function generateLocation($referer, $host, $loopTerms = [])
|
|||
$host = substr($host, 0, $pos);
|
||||
}
|
||||
|
||||
$refererHost = parse_url($referer, PHP_URL_HOST) ?? '';
|
||||
$refererHost = parse_url($referer, PHP_URL_HOST);
|
||||
if (!empty($referer) && (strpos($refererHost, $host) !== false || startsWith('?', $refererHost))) {
|
||||
$finalReferer = $referer;
|
||||
}
|
||||
|
@ -189,6 +173,36 @@ function generateLocation($referer, $host, $loopTerms = [])
|
|||
return $finalReferer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate session ID to prevent Full Path Disclosure.
|
||||
*
|
||||
* See #298.
|
||||
* The session ID's format depends on the hash algorithm set in PHP settings
|
||||
*
|
||||
* @param string $sessionId Session ID
|
||||
*
|
||||
* @return true if valid, false otherwise.
|
||||
*
|
||||
* @see http://php.net/manual/en/function.hash-algos.php
|
||||
* @see http://php.net/manual/en/session.configuration.php
|
||||
*/
|
||||
function is_session_id_valid($sessionId)
|
||||
{
|
||||
if (empty($sessionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$sessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!preg_match('/^[a-zA-Z0-9,-]{2,128}$/', $sessionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sniff browser language to set the locale automatically.
|
||||
* Note that is may not work on your server if the corresponding locale is not installed.
|
||||
|
@ -198,308 +212,28 @@ function generateLocation($referer, $host, $loopTerms = [])
|
|||
function autoLocale($headerLocale)
|
||||
{
|
||||
// Default if browser does not send HTTP_ACCEPT_LANGUAGE
|
||||
$locales = ['en_US.UTF-8', 'en_US.utf8', 'en_US'];
|
||||
if (! empty($headerLocale)) {
|
||||
if (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) {
|
||||
$attempts = [];
|
||||
foreach ($matches as $match) {
|
||||
$first = [strtolower($match[1]), strtoupper($match[1])];
|
||||
$separators = ['_', '-'];
|
||||
$encodings = ['utf8', 'UTF-8'];
|
||||
if (!empty($match[2])) {
|
||||
$second = [strtoupper($match[2]), strtolower($match[2])];
|
||||
$items = [$first, $separators, $second, ['.'], $encodings];
|
||||
} else {
|
||||
$items = [$first, $separators, $first, ['.'], $encodings];
|
||||
}
|
||||
$attempts = array_merge($attempts, iterator_to_array(cartesian_product_generator($items)));
|
||||
}
|
||||
|
||||
if (! empty($attempts)) {
|
||||
$locales = array_merge(array_map('implode', $attempts), $locales);
|
||||
}
|
||||
$attempts = array('en_US');
|
||||
if (isset($headerLocale)) {
|
||||
// (It's a bit crude, but it works very well. Preferred language is always presented first.)
|
||||
if (preg_match('/([a-z]{2})-?([a-z]{2})?/i', $headerLocale, $matches)) {
|
||||
$loc = $matches[1] . (!empty($matches[2]) ? '_' . strtoupper($matches[2]) : '');
|
||||
$attempts = array(
|
||||
$loc.'.UTF-8', $loc, str_replace('_', '-', $loc).'.UTF-8', str_replace('_', '-', $loc),
|
||||
$loc . '_' . strtoupper($loc).'.UTF-8', $loc . '_' . strtoupper($loc),
|
||||
$loc . '_' . $loc.'.UTF-8', $loc . '_' . $loc, $loc . '-' . strtoupper($loc).'.UTF-8',
|
||||
$loc . '-' . strtoupper($loc), $loc . '-' . $loc.'.UTF-8', $loc . '-' . $loc
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setlocale(LC_ALL, $locales);
|
||||
setlocale(LC_ALL, $attempts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Generator object representing the cartesian product from given $items.
|
||||
*
|
||||
* Example:
|
||||
* [['a'], ['b', 'c']]
|
||||
* will generate:
|
||||
* [
|
||||
* ['a', 'b'],
|
||||
* ['a', 'c'],
|
||||
* ]
|
||||
*
|
||||
* @param array $items array of array of string
|
||||
*
|
||||
* @return Generator representing the cartesian product of given array.
|
||||
*
|
||||
* @see https://en.wikipedia.org/wiki/Cartesian_product
|
||||
*/
|
||||
function cartesian_product_generator($items)
|
||||
function getAllTheme()
|
||||
{
|
||||
if (empty($items)) {
|
||||
yield [];
|
||||
}
|
||||
$subArray = array_pop($items);
|
||||
if (empty($subArray)) {
|
||||
return;
|
||||
}
|
||||
foreach (cartesian_product_generator($items) as $item) {
|
||||
foreach ($subArray as $value) {
|
||||
yield $item + [count($item) => $value];
|
||||
}
|
||||
$allTheme = glob('tpl/*', GLOB_ONLYDIR);
|
||||
foreach ($allTheme as $value) {
|
||||
$themes[] = str_replace('tpl/', '', $value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a default API secret.
|
||||
*
|
||||
* Note that the random-ish methods used in this function are predictable,
|
||||
* which makes them NOT suitable for crypto.
|
||||
* BUT the random string is salted with the salt and hashed with the username.
|
||||
* It makes the generated API secret secured enough for Shaarli.
|
||||
*
|
||||
* PHP 7 provides random_int(), designed for cryptography.
|
||||
* More info: http://stackoverflow.com/questions/4356289/php-random-string-generator
|
||||
|
||||
* @param string $username Shaarli login username
|
||||
* @param string $salt Shaarli password hash salt
|
||||
*
|
||||
* @return string|bool Generated API secret, 12 char length.
|
||||
* Or false if invalid parameters are provided (which will make the API unusable).
|
||||
*/
|
||||
function generate_api_secret($username, $salt)
|
||||
{
|
||||
if (empty($username) || empty($salt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_shuffle(substr(hash_hmac('sha512', uniqid($salt), $username), 10, 12));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim string, replace sequences of whitespaces by a single space.
|
||||
* PHP equivalent to `normalize-space` XSLT function.
|
||||
*
|
||||
* @param string $string Input string.
|
||||
*
|
||||
* @return mixed Normalized string.
|
||||
*/
|
||||
function normalize_spaces($string)
|
||||
{
|
||||
return preg_replace('/\s{2,}/', ' ', trim($string ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the date according to the locale.
|
||||
*
|
||||
* Requires php-intl to display international datetimes,
|
||||
* otherwise default format '%c' will be returned.
|
||||
*
|
||||
* @param DateTimeInterface $date to format.
|
||||
* @param bool $time Displays time if true.
|
||||
* @param bool $intl Use international format if true.
|
||||
*
|
||||
* @return bool|string Formatted date, or false if the input is invalid.
|
||||
*/
|
||||
function format_date($date, $time = true, $intl = true)
|
||||
{
|
||||
if (! $date instanceof DateTimeInterface) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $intl || ! class_exists('IntlDateFormatter')) {
|
||||
$format = 'F j, Y';
|
||||
if ($time) {
|
||||
$format .= ' h:i:s A \G\M\TP';
|
||||
}
|
||||
return $date->format($format);
|
||||
}
|
||||
$formatter = new IntlDateFormatter(
|
||||
setlocale(LC_TIME, 0),
|
||||
IntlDateFormatter::LONG,
|
||||
$time ? IntlDateFormatter::LONG : IntlDateFormatter::NONE
|
||||
);
|
||||
$formatter->setTimeZone($date->getTimezone());
|
||||
|
||||
return $formatter->format($date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the date month according to the locale.
|
||||
*
|
||||
* @param DateTimeInterface $date to format.
|
||||
*
|
||||
* @return bool|string Formatted date, or false if the input is invalid.
|
||||
*/
|
||||
function format_month(DateTimeInterface $date)
|
||||
{
|
||||
if (! $date instanceof DateTimeInterface) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return strftime('%B', $date->getTimestamp());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if the input is an integer, no matter its real type.
|
||||
*
|
||||
* PHP is a bit messy regarding this:
|
||||
* - is_int returns false if the input is a string
|
||||
* - ctype_digit returns false if the input is an integer or negative
|
||||
*
|
||||
* @param mixed $input value
|
||||
*
|
||||
* @return bool true if the input is an integer, false otherwise
|
||||
*/
|
||||
function is_integer_mixed($input)
|
||||
{
|
||||
if (is_array($input) || is_bool($input) || is_object($input)) {
|
||||
return false;
|
||||
}
|
||||
$input = strval($input);
|
||||
return ctype_digit($input) || (startsWith($input, '-') && ctype_digit(substr($input, 1)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert post_max_size/upload_max_filesize (e.g. '16M') parameters to bytes.
|
||||
*
|
||||
* @param string $val Size expressed in string.
|
||||
*
|
||||
* @return int Size expressed in bytes.
|
||||
*/
|
||||
function return_bytes($val)
|
||||
{
|
||||
if (is_integer_mixed($val) || $val === '0' || empty($val)) {
|
||||
return $val;
|
||||
}
|
||||
$val = trim($val);
|
||||
$last = strtolower($val[strlen($val) - 1]);
|
||||
$val = intval(substr($val, 0, -1));
|
||||
switch ($last) {
|
||||
case 'g':
|
||||
$val *= 1024;
|
||||
// do no break in order 1024^2 for each unit
|
||||
case 'm':
|
||||
$val *= 1024;
|
||||
// do no break in order 1024^2 for each unit
|
||||
case 'k':
|
||||
$val *= 1024;
|
||||
}
|
||||
return $val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a human readable size from bytes.
|
||||
*
|
||||
* @param int $bytes value
|
||||
*
|
||||
* @return string Human readable size
|
||||
*/
|
||||
function human_bytes($bytes)
|
||||
{
|
||||
if ($bytes === '') {
|
||||
return t('Setting not set');
|
||||
}
|
||||
if (! is_integer_mixed($bytes)) {
|
||||
return $bytes;
|
||||
}
|
||||
$bytes = intval($bytes);
|
||||
if ($bytes === 0) {
|
||||
return t('Unlimited');
|
||||
}
|
||||
|
||||
$units = [t('B'), t('kiB'), t('MiB'), t('GiB')];
|
||||
for ($i = 0; $i < count($units) && $bytes >= 1024; ++$i) {
|
||||
$bytes /= 1024;
|
||||
}
|
||||
|
||||
return round($bytes) . $units[$i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to determine max file size for uploads (POST).
|
||||
* Returns an integer (in bytes) or formatted depending on $format.
|
||||
*
|
||||
* @param mixed $limitPost post_max_size PHP setting
|
||||
* @param mixed $limitUpload upload_max_filesize PHP setting
|
||||
* @param bool $format Format max upload size to human readable size
|
||||
*
|
||||
* @return int|string max upload file size
|
||||
*/
|
||||
function get_max_upload_size($limitPost, $limitUpload, $format = true)
|
||||
{
|
||||
$size1 = return_bytes($limitPost);
|
||||
$size2 = return_bytes($limitUpload);
|
||||
// Return the smaller of two:
|
||||
$maxsize = min($size1, $size2);
|
||||
return $format ? human_bytes($maxsize) : $maxsize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort the given array alphabetically using php-intl if available.
|
||||
* Case sensitive.
|
||||
*
|
||||
* Note: doesn't support multidimensional arrays
|
||||
*
|
||||
* @param array $data Input array, passed by reference
|
||||
* @param bool $reverse Reverse sort if set to true
|
||||
* @param bool $byKeys Sort the array by keys if set to true, by value otherwise.
|
||||
*/
|
||||
function alphabetical_sort(&$data, $reverse = false, $byKeys = false)
|
||||
{
|
||||
$callback = function ($a, $b) use ($reverse) {
|
||||
// Collator is part of PHP intl.
|
||||
if (class_exists('Collator')) {
|
||||
$collator = new Collator(setlocale(LC_COLLATE, 0));
|
||||
if (!intl_is_failure(intl_get_error_code())) {
|
||||
return $collator->compare($a, $b) * ($reverse ? -1 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
return strcasecmp($a, $b) * ($reverse ? -1 : 1);
|
||||
};
|
||||
|
||||
if ($byKeys) {
|
||||
uksort($data, $callback);
|
||||
} else {
|
||||
usort($data, $callback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper function for translation which match the API
|
||||
* of gettext()/_() and ngettext().
|
||||
*
|
||||
* @param string $text Text to translate.
|
||||
* @param string $nText The plural message ID.
|
||||
* @param int $nb The number of items for plural forms.
|
||||
* @param string $domain The domain where the translation is stored (default: shaarli).
|
||||
* @param array $variables Associative array of variables to replace in translated text.
|
||||
* @param bool $fixCase Apply `ucfirst` on the translated string, might be useful for strings with variables.
|
||||
*
|
||||
* @return string Text translated.
|
||||
*/
|
||||
function t($text, $nText = '', $nb = 1, $domain = 'shaarli', $variables = [], $fixCase = false)
|
||||
{
|
||||
$postFunction = $fixCase ? 'ucfirst' : function ($input) {
|
||||
return $input;
|
||||
};
|
||||
|
||||
return $postFunction(dn__($domain, $text, $nText, $nb, $variables));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an exception into a printable stack trace string.
|
||||
*/
|
||||
function exception2text(Throwable $e): string
|
||||
{
|
||||
return $e->getMessage() . PHP_EOL . $e->getFile() . $e->getLine() . PHP_EOL . $e->getTraceAsString();
|
||||
|
||||
return $themes;
|
||||
}
|
||||
|
|
|
@ -1,155 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Api;
|
||||
|
||||
use malkusch\lock\mutex\FlockMutex;
|
||||
use Shaarli\Api\Exceptions\ApiAuthorizationException;
|
||||
use Shaarli\Api\Exceptions\ApiException;
|
||||
use Shaarli\Bookmark\BookmarkFileService;
|
||||
use Shaarli\Config\ConfigManager;
|
||||
use Slim\Container;
|
||||
use Slim\Http\Request;
|
||||
use Slim\Http\Response;
|
||||
|
||||
/**
|
||||
* Class ApiMiddleware
|
||||
*
|
||||
* This will be called before accessing any API Controller.
|
||||
* Its role is to make sure that the API is enabled, configured, and to validate the JWT token.
|
||||
*
|
||||
* If the request is validated, the controller is called, otherwise a JSON error response is returned.
|
||||
*
|
||||
* @package Api
|
||||
*/
|
||||
class ApiMiddleware
|
||||
{
|
||||
/**
|
||||
* @var int JWT token validity in seconds (9 min).
|
||||
*/
|
||||
public static $TOKEN_DURATION = 540;
|
||||
|
||||
/**
|
||||
* @var Container: contains conf, plugins, etc.
|
||||
*/
|
||||
protected $container;
|
||||
|
||||
/**
|
||||
* @var ConfigManager instance.
|
||||
*/
|
||||
protected $conf;
|
||||
|
||||
/**
|
||||
* ApiMiddleware constructor.
|
||||
*
|
||||
* @param Container $container instance.
|
||||
*/
|
||||
public function __construct($container)
|
||||
{
|
||||
$this->container = $container;
|
||||
$this->conf = $this->container->get('conf');
|
||||
$this->setLinkDb($this->conf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware execution:
|
||||
* - check the API request
|
||||
* - execute the controller
|
||||
* - return the response
|
||||
*
|
||||
* @param Request $request Slim request
|
||||
* @param Response $response Slim response
|
||||
* @param callable $next Next action
|
||||
*
|
||||
* @return Response response.
|
||||
*/
|
||||
public function __invoke($request, $response, $next)
|
||||
{
|
||||
try {
|
||||
$this->checkRequest($request);
|
||||
$response = $next($request, $response);
|
||||
} catch (ApiException $e) {
|
||||
$e->setResponse($response);
|
||||
$e->setDebug($this->conf->get('dev.debug', false));
|
||||
$response = $e->getApiResponse();
|
||||
}
|
||||
|
||||
return $response
|
||||
->withHeader('Access-Control-Allow-Origin', '*')
|
||||
->withHeader(
|
||||
'Access-Control-Allow-Headers',
|
||||
'X-Requested-With, Content-Type, Accept, Origin, Authorization'
|
||||
)
|
||||
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the request validity (HTTP method, request value, etc.),
|
||||
* that the API is enabled, and the JWT token validity.
|
||||
*
|
||||
* @param Request $request Slim request
|
||||
*
|
||||
* @throws ApiAuthorizationException The API is disabled or the token is invalid.
|
||||
*/
|
||||
protected function checkRequest($request)
|
||||
{
|
||||
if (! $this->conf->get('api.enabled', true)) {
|
||||
throw new ApiAuthorizationException('API is disabled');
|
||||
}
|
||||
$this->checkToken($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the JWT token is set and valid.
|
||||
* The API secret setting must be set.
|
||||
*
|
||||
* @param Request $request Slim request
|
||||
*
|
||||
* @throws ApiAuthorizationException The token couldn't be validated.
|
||||
*/
|
||||
protected function checkToken($request)
|
||||
{
|
||||
if (
|
||||
!$request->hasHeader('Authorization')
|
||||
&& !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])
|
||||
) {
|
||||
throw new ApiAuthorizationException('JWT token not provided');
|
||||
}
|
||||
|
||||
if (empty($this->conf->get('api.secret'))) {
|
||||
throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration');
|
||||
}
|
||||
|
||||
if (isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])) {
|
||||
$authorization = $this->container->environment['REDIRECT_HTTP_AUTHORIZATION'];
|
||||
} else {
|
||||
$authorization = $request->getHeaderLine('Authorization');
|
||||
}
|
||||
|
||||
if (! preg_match('/^Bearer (.*)/i', $authorization, $matches)) {
|
||||
throw new ApiAuthorizationException('Invalid JWT header');
|
||||
}
|
||||
|
||||
ApiUtils::validateJwtToken($matches[1], $this->conf->get('api.secret'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate a new LinkDB including private bookmarks,
|
||||
* and load in the Slim container.
|
||||
*
|
||||
* FIXME! LinkDB could use a refactoring to avoid this trick.
|
||||
*
|
||||
* @param ConfigManager $conf instance.
|
||||
*/
|
||||
protected function setLinkDb($conf)
|
||||
{
|
||||
$linkDb = new BookmarkFileService(
|
||||
$conf,
|
||||
$this->container->get('pluginManager'),
|
||||
$this->container->get('history'),
|
||||
new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2),
|
||||
true
|
||||
);
|
||||
$this->container['db'] = $linkDb;
|
||||
}
|
||||
}
|
|
@ -1,174 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Api;
|
||||
|
||||
use Shaarli\Api\Exceptions\ApiAuthorizationException;
|
||||
use Shaarli\Bookmark\Bookmark;
|
||||
use Shaarli\Http\Base64Url;
|
||||
|
||||
/**
|
||||
* REST API utilities
|
||||
*/
|
||||
class ApiUtils
|
||||
{
|
||||
/**
|
||||
* Validates a JWT token authenticity.
|
||||
*
|
||||
* @param string $token JWT token extracted from the headers.
|
||||
* @param string $secret API secret set in the settings.
|
||||
*
|
||||
* @return bool true on success
|
||||
*
|
||||
* @throws ApiAuthorizationException the token is not valid.
|
||||
*/
|
||||
public static function validateJwtToken($token, $secret)
|
||||
{
|
||||
$parts = explode('.', $token);
|
||||
if (count($parts) != 3 || strlen($parts[0]) == 0 || strlen($parts[1]) == 0) {
|
||||
throw new ApiAuthorizationException('Malformed JWT token');
|
||||
}
|
||||
|
||||
$genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] . '.' . $parts[1], $secret, true));
|
||||
if ($parts[2] != $genSign) {
|
||||
throw new ApiAuthorizationException('Invalid JWT signature');
|
||||
}
|
||||
|
||||
$header = json_decode(Base64Url::decode($parts[0]));
|
||||
if ($header === null) {
|
||||
throw new ApiAuthorizationException('Invalid JWT header');
|
||||
}
|
||||
|
||||
$payload = json_decode(Base64Url::decode($parts[1]));
|
||||
if ($payload === null) {
|
||||
throw new ApiAuthorizationException('Invalid JWT payload');
|
||||
}
|
||||
|
||||
if (
|
||||
empty($payload->iat)
|
||||
|| $payload->iat > time()
|
||||
|| time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION
|
||||
) {
|
||||
throw new ApiAuthorizationException('Invalid JWT issued time');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Link for the REST API.
|
||||
*
|
||||
* @param Bookmark $bookmark Bookmark data read from the datastore.
|
||||
* @param string $indexUrl Shaarli's index URL (used for relative URL).
|
||||
*
|
||||
* @return array Link data formatted for the REST API.
|
||||
*/
|
||||
public static function formatLink($bookmark, $indexUrl)
|
||||
{
|
||||
$out['id'] = $bookmark->getId();
|
||||
// Not an internal link
|
||||
if (! $bookmark->isNote()) {
|
||||
$out['url'] = $bookmark->getUrl();
|
||||
} else {
|
||||
$out['url'] = rtrim($indexUrl, '/') . '/' . ltrim($bookmark->getUrl(), '/');
|
||||
}
|
||||
$out['shorturl'] = $bookmark->getShortUrl();
|
||||
$out['title'] = $bookmark->getTitle();
|
||||
$out['description'] = $bookmark->getDescription();
|
||||
$out['tags'] = $bookmark->getTags();
|
||||
$out['private'] = $bookmark->isPrivate();
|
||||
$out['created'] = $bookmark->getCreated()->format(\DateTime::ATOM);
|
||||
if (! empty($bookmark->getUpdated())) {
|
||||
$out['updated'] = $bookmark->getUpdated()->format(\DateTime::ATOM);
|
||||
} else {
|
||||
$out['updated'] = '';
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a link given through a request, to a valid Bookmark for the datastore.
|
||||
*
|
||||
* If no URL is provided, it will generate a local note URL.
|
||||
* If no title is provided, it will use the URL as title.
|
||||
*
|
||||
* @param array|null $input Request Link.
|
||||
* @param bool $defaultPrivate Setting defined if a bookmark is private by default.
|
||||
* @param string $tagsSeparator Tags separator loaded from the config file.
|
||||
*
|
||||
* @return Bookmark instance.
|
||||
*/
|
||||
public static function buildBookmarkFromRequest(
|
||||
?array $input,
|
||||
bool $defaultPrivate,
|
||||
string $tagsSeparator
|
||||
): Bookmark {
|
||||
$bookmark = new Bookmark();
|
||||
$url = ! empty($input['url']) ? cleanup_url($input['url']) : '';
|
||||
if (isset($input['private'])) {
|
||||
$private = filter_var($input['private'], FILTER_VALIDATE_BOOLEAN);
|
||||
} else {
|
||||
$private = $defaultPrivate;
|
||||
}
|
||||
|
||||
$bookmark->setTitle(! empty($input['title']) ? $input['title'] : '');
|
||||
$bookmark->setUrl($url);
|
||||
$bookmark->setDescription(! empty($input['description']) ? $input['description'] : '');
|
||||
|
||||
// Be permissive with provided tags format
|
||||
if (is_string($input['tags'] ?? null)) {
|
||||
$input['tags'] = tags_str2array($input['tags'], $tagsSeparator);
|
||||
}
|
||||
if (is_array($input['tags'] ?? null) && count($input['tags']) === 1 && is_string($input['tags'][0])) {
|
||||
$input['tags'] = tags_str2array($input['tags'][0], $tagsSeparator);
|
||||
}
|
||||
|
||||
$bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []);
|
||||
$bookmark->setPrivate($private);
|
||||
|
||||
$created = \DateTime::createFromFormat(\DateTime::ATOM, $input['created'] ?? '');
|
||||
if ($created instanceof \DateTimeInterface) {
|
||||
$bookmark->setCreated($created);
|
||||
}
|
||||
$updated = \DateTime::createFromFormat(\DateTime::ATOM, $input['updated'] ?? '');
|
||||
if ($updated instanceof \DateTimeInterface) {
|
||||
$bookmark->setUpdated($updated);
|
||||
}
|
||||
|
||||
return $bookmark;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update link fields using an updated link object.
|
||||
*
|
||||
* @param Bookmark $oldLink data
|
||||
* @param Bookmark $newLink data
|
||||
*
|
||||
* @return Bookmark $oldLink updated with $newLink values
|
||||
*/
|
||||
public static function updateLink($oldLink, $newLink)
|
||||
{
|
||||
$oldLink->setTitle($newLink->getTitle());
|
||||
$oldLink->setUrl($newLink->getUrl());
|
||||
$oldLink->setDescription($newLink->getDescription());
|
||||
$oldLink->setTags($newLink->getTags());
|
||||
$oldLink->setPrivate($newLink->isPrivate());
|
||||
|
||||
return $oldLink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Tag for the REST API.
|
||||
*
|
||||
* @param string $tag Tag name
|
||||
* @param int $occurrences Number of bookmarks using this tag
|
||||
*
|
||||
* @return array Link data formatted for the REST API.
|
||||
*/
|
||||
public static function formatTag($tag, $occurences)
|
||||
{
|
||||
return [
|
||||
'name' => $tag,
|
||||
'occurrences' => $occurences,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -1,73 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Api\Controllers;
|
||||
|
||||
use Shaarli\Bookmark\BookmarkServiceInterface;
|
||||
use Shaarli\Config\ConfigManager;
|
||||
use Shaarli\History;
|
||||
use Slim\Container;
|
||||
|
||||
/**
|
||||
* Abstract Class ApiController
|
||||
*
|
||||
* Defines REST API Controller dependencies injected from the container.
|
||||
*
|
||||
* @package Api\Controllers
|
||||
*/
|
||||
abstract class ApiController
|
||||
{
|
||||
/**
|
||||
* @var Container
|
||||
*/
|
||||
protected $ci;
|
||||
|
||||
/**
|
||||
* @var ConfigManager
|
||||
*/
|
||||
protected $conf;
|
||||
|
||||
/**
|
||||
* @var BookmarkServiceInterface
|
||||
*/
|
||||
protected $bookmarkService;
|
||||
|
||||
/**
|
||||
* @var History
|
||||
*/
|
||||
protected $history;
|
||||
|
||||
/**
|
||||
* @var int|null JSON style option.
|
||||
*/
|
||||
protected $jsonStyle;
|
||||
|
||||
/**
|
||||
* ApiController constructor.
|
||||
*
|
||||
* Note: enabling debug mode displays JSON with readable formatting.
|
||||
*
|
||||
* @param Container $ci Slim container.
|
||||
*/
|
||||
public function __construct(Container $ci)
|
||||
{
|
||||
$this->ci = $ci;
|
||||
$this->conf = $ci->get('conf');
|
||||
$this->bookmarkService = $ci->get('db');
|
||||
$this->history = $ci->get('history');
|
||||
if ($this->conf->get('dev.debug', false)) {
|
||||
$this->jsonStyle = JSON_PRETTY_PRINT;
|
||||
} else {
|
||||
$this->jsonStyle = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the container.
|
||||
*
|
||||
* @return Container
|
||||
*/
|
||||
public function getCi()
|
||||
{
|
||||
return $this->ci;
|
||||
}
|
||||
}
|
|
@ -1,68 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Api\Controllers;
|
||||
|
||||
use Shaarli\Api\Exceptions\ApiBadParametersException;
|
||||
use Slim\Http\Request;
|
||||
use Slim\Http\Response;
|
||||
|
||||
/**
|
||||
* Class History
|
||||
*
|
||||
* REST API Controller: /history
|
||||
*
|
||||
* @package Shaarli\Api\Controllers
|
||||
*/
|
||||
class HistoryController extends ApiController
|
||||
{
|
||||
/**
|
||||
* Service providing operation regarding Shaarli datastore and settings.
|
||||
*
|
||||
* @param Request $request Slim request.
|
||||
* @param Response $response Slim response.
|
||||
*
|
||||
* @return Response response.
|
||||
*
|
||||
* @throws ApiBadParametersException Invalid parameters.
|
||||
*/
|
||||
public function getHistory($request, $response)
|
||||
{
|
||||
$history = $this->history->getHistory();
|
||||
|
||||
// Return history operations from the {offset}th, starting from {since}.
|
||||
$since = \DateTime::createFromFormat(\DateTime::ATOM, $request->getParam('since', ''));
|
||||
$offset = $request->getParam('offset');
|
||||
if (empty($offset)) {
|
||||
$offset = 0;
|
||||
} elseif (ctype_digit($offset)) {
|
||||
$offset = (int) $offset;
|
||||
} else {
|
||||
throw new ApiBadParametersException('Invalid offset');
|
||||
}
|
||||
|
||||
// limit parameter is either a number of bookmarks or 'all' for everything.
|
||||
$limit = $request->getParam('limit');
|
||||
if (empty($limit)) {
|
||||
$limit = count($history);
|
||||
} elseif (ctype_digit($limit)) {
|
||||
$limit = (int) $limit;
|
||||
} else {
|
||||
throw new ApiBadParametersException('Invalid limit');
|
||||
}
|
||||
|
||||
$out = [];
|
||||
$i = 0;
|
||||
foreach ($history as $entry) {
|
||||
if ((! empty($since) && $entry['datetime'] <= $since) || count($out) >= $limit) {
|
||||
break;
|
||||
}
|
||||
if (++$i > $offset) {
|
||||
$out[$i] = $entry;
|
||||
$out[$i]['datetime'] = $out[$i]['datetime']->format(\DateTime::ATOM);
|
||||
}
|
||||
}
|
||||
$out = array_values($out);
|
||||
|
||||
return $response->withJson($out, 200, $this->jsonStyle);
|
||||
}
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Api\Controllers;
|
||||
|
||||
use Shaarli\Bookmark\BookmarkFilter;
|
||||
use Slim\Http\Request;
|
||||
use Slim\Http\Response;
|
||||
|
||||
/**
|
||||
* Class Info
|
||||
*
|
||||
* REST API Controller: /info
|
||||
*
|
||||
* @package Api\Controllers
|
||||
* @see http://shaarli.github.io/api-documentation/#links-instance-information-get
|
||||
*/
|
||||
class Info extends ApiController
|
||||
{
|
||||
/**
|
||||
* Service providing various information about Shaarli instance.
|
||||
*
|
||||
* @param Request $request Slim request.
|
||||
* @param Response $response Slim response.
|
||||
*
|
||||
* @return Response response.
|
||||
*/
|
||||
public function getInfo($request, $response)
|
||||
{
|
||||
$info = [
|
||||
'global_counter' => $this->bookmarkService->count(),
|
||||
'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE),
|
||||
'settings' => [
|
||||
'title' => $this->conf->get('general.title', 'Shaarli'),
|
||||
'header_link' => $this->conf->get('general.header_link', '?'),
|
||||
'timezone' => $this->conf->get('general.timezone', 'UTC'),
|
||||
'enabled_plugins' => $this->conf->get('general.enabled_plugins', []),
|
||||
'default_private_links' => $this->conf->get('privacy.default_private_links', false),
|
||||
],
|
||||
];
|
||||
|
||||
return $response->withJson($info, 200, $this->jsonStyle);
|
||||
}
|
||||
}
|
|
@ -1,213 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Api\Controllers;
|
||||
|
||||
use Shaarli\Api\ApiUtils;
|
||||
use Shaarli\Api\Exceptions\ApiBadParametersException;
|
||||
use Shaarli\Api\Exceptions\ApiLinkNotFoundException;
|
||||
use Slim\Http\Request;
|
||||
use Slim\Http\Response;
|
||||
|
||||
/**
|
||||
* Class Links
|
||||
*
|
||||
* REST API Controller: all services related to bookmarks collection.
|
||||
*
|
||||
* @package Api\Controllers
|
||||
* @see http://shaarli.github.io/api-documentation/#links-links-collection
|
||||
*/
|
||||
class Links extends ApiController
|
||||
{
|
||||
/**
|
||||
* @var int Number of bookmarks returned if no limit is provided.
|
||||
*/
|
||||
public static $DEFAULT_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* Retrieve a list of bookmarks, allowing different filters.
|
||||
*
|
||||
* @param Request $request Slim request.
|
||||
* @param Response $response Slim response.
|
||||
*
|
||||
* @return Response response.
|
||||
*
|
||||
* @throws ApiBadParametersException Invalid parameters.
|
||||
*/
|
||||
public function getLinks($request, $response)
|
||||
{
|
||||
$private = $request->getParam('visibility');
|
||||
|
||||
// Return bookmarks from the {offset}th link, starting from 0.
|
||||
$offset = $request->getParam('offset');
|
||||
if (! empty($offset) && ! ctype_digit($offset)) {
|
||||
throw new ApiBadParametersException('Invalid offset');
|
||||
}
|
||||
$offset = ! empty($offset) ? intval($offset) : 0;
|
||||
|
||||
// limit parameter is either a number of bookmarks or 'all' for everything.
|
||||
$limit = $request->getParam('limit');
|
||||
if (empty($limit)) {
|
||||
$limit = self::$DEFAULT_LIMIT;
|
||||
} elseif (ctype_digit($limit)) {
|
||||
$limit = intval($limit);
|
||||
} elseif ($limit === 'all') {
|
||||
$limit = null;
|
||||
} else {
|
||||
throw new ApiBadParametersException('Invalid limit');
|
||||
}
|
||||
|
||||
$searchResult = $this->bookmarkService->search(
|
||||
[
|
||||
'searchtags' => $request->getParam('searchtags', ''),
|
||||
'searchterm' => $request->getParam('searchterm', ''),
|
||||
],
|
||||
$private,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
[
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
'allowOutOfBounds' => true,
|
||||
]
|
||||
);
|
||||
|
||||
// 'environment' is set by Slim and encapsulate $_SERVER.
|
||||
$indexUrl = index_url($this->ci['environment']);
|
||||
|
||||
$out = [];
|
||||
foreach ($searchResult->getBookmarks() as $bookmark) {
|
||||
$out[] = ApiUtils::formatLink($bookmark, $indexUrl);
|
||||
}
|
||||
|
||||
return $response->withJson($out, 200, $this->jsonStyle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a single formatted link by its ID.
|
||||
*
|
||||
* @param Request $request Slim request.
|
||||
* @param Response $response Slim response.
|
||||
* @param array $args Path parameters. including the ID.
|
||||
*
|
||||
* @return Response containing the link array.
|
||||
*
|
||||
* @throws ApiLinkNotFoundException generating a 404 error.
|
||||
*/
|
||||
public function getLink($request, $response, $args)
|
||||
{
|
||||
$id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
|
||||
if ($id === null || ! $this->bookmarkService->exists($id)) {
|
||||
throw new ApiLinkNotFoundException();
|
||||
}
|
||||
$index = index_url($this->ci['environment']);
|
||||
$out = ApiUtils::formatLink($this->bookmarkService->get($id), $index);
|
||||
|
||||
return $response->withJson($out, 200, $this->jsonStyle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new link from posted request body.
|
||||
*
|
||||
* @param Request $request Slim request.
|
||||
* @param Response $response Slim response.
|
||||
*
|
||||
* @return Response response.
|
||||
*/
|
||||
public function postLink($request, $response)
|
||||
{
|
||||
$data = (array) ($request->getParsedBody() ?? []);
|
||||
$bookmark = ApiUtils::buildBookmarkFromRequest(
|
||||
$data,
|
||||
$this->conf->get('privacy.default_private_links'),
|
||||
$this->conf->get('general.tags_separator', ' ')
|
||||
);
|
||||
// duplicate by URL, return 409 Conflict
|
||||
if (
|
||||
! empty($bookmark->getUrl())
|
||||
&& ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl()))
|
||||
) {
|
||||
return $response->withJson(
|
||||
ApiUtils::formatLink($dup, index_url($this->ci['environment'])),
|
||||
409,
|
||||
$this->jsonStyle
|
||||
);
|
||||
}
|
||||
|
||||
$this->bookmarkService->add($bookmark);
|
||||
$out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment']));
|
||||
$redirect = $this->ci->router->pathFor('getLink', ['id' => $bookmark->getId()]);
|
||||
return $response->withAddedHeader('Location', $redirect)
|
||||
->withJson($out, 201, $this->jsonStyle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing link from posted request body.
|
||||
*
|
||||
* @param Request $request Slim request.
|
||||
* @param Response $response Slim response.
|
||||
* @param array $args Path parameters. including the ID.
|
||||
*
|
||||
* @return Response response.
|
||||
*
|
||||
* @throws ApiLinkNotFoundException generating a 404 error.
|
||||
*/
|
||||
public function putLink($request, $response, $args)
|
||||
{
|
||||
$id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
|
||||
if ($id === null || !$this->bookmarkService->exists($id)) {
|
||||
throw new ApiLinkNotFoundException();
|
||||
}
|
||||
|
||||
$index = index_url($this->ci['environment']);
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$requestBookmark = ApiUtils::buildBookmarkFromRequest(
|
||||
$data,
|
||||
$this->conf->get('privacy.default_private_links'),
|
||||
$this->conf->get('general.tags_separator', ' ')
|
||||
);
|
||||
// duplicate URL on a different link, return 409 Conflict
|
||||
if (
|
||||
! empty($requestBookmark->getUrl())
|
||||
&& ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl()))
|
||||
&& $dup->getId() != $id
|
||||
) {
|
||||
return $response->withJson(
|
||||
ApiUtils::formatLink($dup, $index),
|
||||
409,
|
||||
$this->jsonStyle
|
||||
);
|
||||
}
|
||||
|
||||
$responseBookmark = $this->bookmarkService->get($id);
|
||||
$responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark);
|
||||
$this->bookmarkService->set($responseBookmark);
|
||||
|
||||
$out = ApiUtils::formatLink($responseBookmark, $index);
|
||||
return $response->withJson($out, 200, $this->jsonStyle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing link by its ID.
|
||||
*
|
||||
* @param Request $request Slim request.
|
||||
* @param Response $response Slim response.
|
||||
* @param array $args Path parameters. including the ID.
|
||||
*
|
||||
* @return Response response.
|
||||
*
|
||||
* @throws ApiLinkNotFoundException generating a 404 error.
|
||||
*/
|
||||
public function deleteLink($request, $response, $args)
|
||||
{
|
||||
$id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
|
||||
if ($id === null || !$this->bookmarkService->exists($id)) {
|
||||
throw new ApiLinkNotFoundException();
|
||||
}
|
||||
$bookmark = $this->bookmarkService->get($id);
|
||||
$this->bookmarkService->remove($bookmark);
|
||||
|
||||
return $response->withStatus(204);
|
||||
}
|
||||
}
|
|
@ -1,174 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Api\Controllers;
|
||||
|
||||
use Shaarli\Api\ApiUtils;
|
||||
use Shaarli\Api\Exceptions\ApiBadParametersException;
|
||||
use Shaarli\Api\Exceptions\ApiTagNotFoundException;
|
||||
use Shaarli\Bookmark\BookmarkFilter;
|
||||
use Slim\Http\Request;
|
||||
use Slim\Http\Response;
|
||||
|
||||
/**
|
||||
* Class Tags
|
||||
*
|
||||
* REST API Controller: all services related to tags collection.
|
||||
*
|
||||
* @package Api\Controllers
|
||||
*/
|
||||
class Tags extends ApiController
|
||||
{
|
||||
/**
|
||||
* @var int Number of bookmarks returned if no limit is provided.
|
||||
*/
|
||||
public static $DEFAULT_LIMIT = 'all';
|
||||
|
||||
/**
|
||||
* Retrieve a list of tags, allowing different filters.
|
||||
*
|
||||
* @param Request $request Slim request.
|
||||
* @param Response $response Slim response.
|
||||
*
|
||||
* @return Response response.
|
||||
*
|
||||
* @throws ApiBadParametersException Invalid parameters.
|
||||
*/
|
||||
public function getTags($request, $response)
|
||||
{
|
||||
$visibility = $request->getParam('visibility');
|
||||
$tags = $this->bookmarkService->bookmarksCountPerTag([], $visibility);
|
||||
|
||||
// Return tags from the {offset}th tag, starting from 0.
|
||||
$offset = $request->getParam('offset');
|
||||
if (! empty($offset) && ! ctype_digit($offset)) {
|
||||
throw new ApiBadParametersException('Invalid offset');
|
||||
}
|
||||
$offset = ! empty($offset) ? intval($offset) : 0;
|
||||
if ($offset > count($tags)) {
|
||||
return $response->withJson([], 200, $this->jsonStyle);
|
||||
}
|
||||
|
||||
// limit parameter is either a number of bookmarks or 'all' for everything.
|
||||
$limit = $request->getParam('limit');
|
||||
if (empty($limit)) {
|
||||
$limit = self::$DEFAULT_LIMIT;
|
||||
}
|
||||
if (ctype_digit($limit)) {
|
||||
$limit = intval($limit);
|
||||
} elseif ($limit === 'all') {
|
||||
$limit = count($tags);
|
||||
} else {
|
||||
throw new ApiBadParametersException('Invalid limit');
|
||||
}
|
||||
|
||||
$out = [];
|
||||
$index = 0;
|
||||
foreach ($tags as $tag => $occurrences) {
|
||||
if (count($out) >= $limit) {
|
||||
break;
|
||||
}
|
||||
if ($index++ >= $offset) {
|
||||
$out[] = ApiUtils::formatTag($tag, $occurrences);
|
||||
}
|
||||
}
|
||||
|
||||
return $response->withJson($out, 200, $this->jsonStyle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a single formatted tag by its name.
|
||||
*
|
||||
* @param Request $request Slim request.
|
||||
* @param Response $response Slim response.
|
||||
* @param array $args Path parameters. including the tag name.
|
||||
*
|
||||
* @return Response containing the link array.
|
||||
*
|
||||
* @throws ApiTagNotFoundException generating a 404 error.
|
||||
*/
|
||||
public function getTag($request, $response, $args)
|
||||
{
|
||||
$tags = $this->bookmarkService->bookmarksCountPerTag();
|
||||
if (!isset($tags[$args['tagName']])) {
|
||||
throw new ApiTagNotFoundException();
|
||||
}
|
||||
$out = ApiUtils::formatTag($args['tagName'], $tags[$args['tagName']]);
|
||||
|
||||
return $response->withJson($out, 200, $this->jsonStyle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a tag from the given name.
|
||||
* If the new name provided matches an existing tag, they will be merged.
|
||||
*
|
||||
* @param Request $request Slim request.
|
||||
* @param Response $response Slim response.
|
||||
* @param array $args Path parameters. including the tag name.
|
||||
*
|
||||
* @return Response response.
|
||||
*
|
||||
* @throws ApiTagNotFoundException generating a 404 error.
|
||||
* @throws ApiBadParametersException new tag name not provided
|
||||
*/
|
||||
public function putTag($request, $response, $args)
|
||||
{
|
||||
$tags = $this->bookmarkService->bookmarksCountPerTag();
|
||||
if (! isset($tags[$args['tagName']])) {
|
||||
throw new ApiTagNotFoundException();
|
||||
}
|
||||
|
||||
$data = $request->getParsedBody();
|
||||
if (empty($data['name'])) {
|
||||
throw new ApiBadParametersException('New tag name is required in the request body');
|
||||
}
|
||||
|
||||
$searchResult = $this->bookmarkService->search(
|
||||
['searchtags' => $args['tagName']],
|
||||
BookmarkFilter::$ALL,
|
||||
true
|
||||
);
|
||||
foreach ($searchResult->getBookmarks() as $bookmark) {
|
||||
$bookmark->renameTag($args['tagName'], $data['name']);
|
||||
$this->bookmarkService->set($bookmark, false);
|
||||
$this->history->updateLink($bookmark);
|
||||
}
|
||||
$this->bookmarkService->save();
|
||||
|
||||
$tags = $this->bookmarkService->bookmarksCountPerTag();
|
||||
$out = ApiUtils::formatTag($data['name'], $tags[$data['name']]);
|
||||
return $response->withJson($out, 200, $this->jsonStyle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an existing tag by its name.
|
||||
*
|
||||
* @param Request $request Slim request.
|
||||
* @param Response $response Slim response.
|
||||
* @param array $args Path parameters. including the tag name.
|
||||
*
|
||||
* @return Response response.
|
||||
*
|
||||
* @throws ApiTagNotFoundException generating a 404 error.
|
||||
*/
|
||||
public function deleteTag($request, $response, $args)
|
||||
{
|
||||
$tags = $this->bookmarkService->bookmarksCountPerTag();
|
||||
if (! isset($tags[$args['tagName']])) {
|
||||
throw new ApiTagNotFoundException();
|
||||
}
|
||||
|
||||
$searchResult = $this->bookmarkService->search(
|
||||
['searchtags' => $args['tagName']],
|
||||
BookmarkFilter::$ALL,
|
||||
true
|
||||
);
|
||||
foreach ($searchResult->getBookmarks() as $bookmark) {
|
||||
$bookmark->deleteTag($args['tagName']);
|
||||
$this->bookmarkService->set($bookmark, false);
|
||||
$this->history->updateLink($bookmark);
|
||||
}
|
||||
$this->bookmarkService->save();
|
||||
|
||||
return $response->withStatus(204);
|
||||
}
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Api\Exceptions;
|
||||
|
||||
/**
|
||||
* Class ApiAuthorizationException
|
||||
*
|
||||
* Request not authorized, return a 401 HTTP code.
|
||||
*/
|
||||
class ApiAuthorizationException extends ApiException
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getApiResponse()
|
||||
{
|
||||
$this->setMessage('Not authorized');
|
||||
return $this->buildApiResponse(401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the exception message.
|
||||
*
|
||||
* We only return a generic error message in production mode to avoid giving
|
||||
* to much security information.
|
||||
*
|
||||
* @param $message string the exception message.
|
||||
*/
|
||||
public function setMessage($message)
|
||||
{
|
||||
$original = $this->debug === true ? ': ' . $this->getMessage() : '';
|
||||
$this->message = $message . $original;
|
||||
}
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Api\Exceptions;
|
||||
|
||||
/**
|
||||
* Class ApiBadParametersException
|
||||
*
|
||||
* Invalid request exception, return a 400 HTTP code.
|
||||
*/
|
||||
class ApiBadParametersException extends ApiException
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getApiResponse()
|
||||
{
|
||||
return $this->buildApiResponse(400);
|
||||
}
|
||||
}
|
|
@ -1,78 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Api\Exceptions;
|
||||
|
||||
use Slim\Http\Response;
|
||||
|
||||
/**
|
||||
* Abstract class ApiException
|
||||
*
|
||||
* Parent Exception related to the API, able to generate a valid Response (ResponseInterface).
|
||||
* Also can include various information in debug mode.
|
||||
*/
|
||||
abstract class ApiException extends \Exception
|
||||
{
|
||||
/**
|
||||
* @var Response instance from Slim.
|
||||
*/
|
||||
protected $response;
|
||||
|
||||
/**
|
||||
* @var bool Debug mode enabled/disabled.
|
||||
*/
|
||||
protected $debug;
|
||||
|
||||
/**
|
||||
* Build the final response.
|
||||
*
|
||||
* @return Response Final response to give.
|
||||
*/
|
||||
abstract public function getApiResponse();
|
||||
|
||||
/**
|
||||
* Creates ApiResponse body.
|
||||
* In production mode, it will only return the exception message,
|
||||
* but in dev mode, it includes additional information in an array.
|
||||
*
|
||||
* @return array|string response body
|
||||
*/
|
||||
protected function getApiResponseBody()
|
||||
{
|
||||
if ($this->debug !== true) {
|
||||
return $this->getMessage();
|
||||
}
|
||||
return [
|
||||
'message' => $this->getMessage(),
|
||||
'stacktrace' => get_class($this) . ': ' . $this->getTraceAsString()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Response object to return.
|
||||
*
|
||||
* @param int $code HTTP status.
|
||||
*
|
||||
* @return Response with status + body.
|
||||
*/
|
||||
protected function buildApiResponse($code)
|
||||
{
|
||||
$style = $this->debug ? JSON_PRETTY_PRINT : null;
|
||||
return $this->response->withJson($this->getApiResponseBody(), $code, $style);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Response $response
|
||||
*/
|
||||
public function setResponse($response)
|
||||
{
|
||||
$this->response = $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $debug
|
||||
*/
|
||||
public function setDebug($debug)
|
||||
{
|
||||
$this->debug = $debug;
|
||||
}
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Api\Exceptions;
|
||||
|
||||
/**
|
||||
* Class ApiInternalException
|
||||
*
|
||||
* Generic exception, return a 500 HTTP code.
|
||||
*/
|
||||
class ApiInternalException extends ApiException
|
||||
{
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function getApiResponse()
|
||||
{
|
||||
return $this->buildApiResponse(500);
|
||||
}
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Api\Exceptions;
|
||||
|
||||
/**
|
||||
* Class ApiLinkNotFoundException
|
||||
*
|
||||
* Link selected by ID couldn't be found, results in a 404 error.
|
||||
*
|
||||
* @package Shaarli\Api\Exceptions
|
||||
*/
|
||||
class ApiLinkNotFoundException extends ApiException
|
||||
{
|
||||
/**
|
||||
* ApiLinkNotFoundException constructor.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->message = 'Link not found';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getApiResponse()
|
||||
{
|
||||
return $this->buildApiResponse(404);
|
||||
}
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Api\Exceptions;
|
||||
|
||||
/**
|
||||
* Class ApiTagNotFoundException
|
||||
*
|
||||
* Tag selected by name couldn't be found in the datastore, results in a 404 error.
|
||||
*
|
||||
* @package Shaarli\Api\Exceptions
|
||||
*/
|
||||
class ApiTagNotFoundException extends ApiException
|
||||
{
|
||||
/**
|
||||
* ApiLinkNotFoundException constructor.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->message = 'Tag not found';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getApiResponse()
|
||||
{
|
||||
return $this->buildApiResponse(404);
|
||||
}
|
||||
}
|
|
@ -1,542 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shaarli\Bookmark;
|
||||
|
||||
use DateTime;
|
||||
use DateTimeInterface;
|
||||
use Shaarli\Bookmark\Exception\InvalidBookmarkException;
|
||||
|
||||
/**
|
||||
* Class Bookmark
|
||||
*
|
||||
* This class represent a single Bookmark with all its attributes.
|
||||
* Every bookmark should manipulated using this, before being formatted.
|
||||
*
|
||||
* @package Shaarli\Bookmark
|
||||
*/
|
||||
class Bookmark
|
||||
{
|
||||
/** @var string Date format used in string (former ID format) */
|
||||
public const LINK_DATE_FORMAT = 'Ymd_His';
|
||||
|
||||
/** @var int Bookmark ID */
|
||||
protected $id;
|
||||
|
||||
/** @var string Permalink identifier */
|
||||
protected $shortUrl;
|
||||
|
||||
/** @var string Bookmark's URL - $shortUrl prefixed with `?` for notes */
|
||||
protected $url;
|
||||
|
||||
/** @var string Bookmark's title */
|
||||
protected $title;
|
||||
|
||||
/** @var string Raw bookmark's description */
|
||||
protected $description;
|
||||
|
||||
/** @var array List of bookmark's tags */
|
||||
protected $tags;
|
||||
|
||||
/** @var string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found */
|
||||
protected $thumbnail;
|
||||
|
||||
/** @var bool Set to true if the bookmark is set as sticky */
|
||||
protected $sticky;
|
||||
|
||||
/** @var DateTimeInterface Creation datetime */
|
||||
protected $created;
|
||||
|
||||
/** @var DateTimeInterface datetime */
|
||||
protected $updated;
|
||||
|
||||
/** @var bool True if the bookmark can only be seen while logged in */
|
||||
protected $private;
|
||||
|
||||
/** @var mixed[] Available to store any additional content for a bookmark. Currently used for search highlight. */
|
||||
protected $additionalContent = [];
|
||||
|
||||
/**
|
||||
* Initialize a link from array data. Especially useful to create a Bookmark from former link storage format.
|
||||
*
|
||||
* @param array $data
|
||||
* @param string $tagsSeparator Tags separator loaded from the config file.
|
||||
* This is a context data, and it should *never* be stored in the Bookmark object.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function fromArray(array $data, string $tagsSeparator = ' '): Bookmark
|
||||
{
|
||||
$this->id = $data['id'] ?? null;
|
||||
$this->shortUrl = $data['shorturl'] ?? null;
|
||||
$this->url = $data['url'] ?? null;
|
||||
$this->title = $data['title'] ?? null;
|
||||
$this->description = $data['description'] ?? null;
|
||||
$this->thumbnail = $data['thumbnail'] ?? null;
|
||||
$this->sticky = $data['sticky'] ?? false;
|
||||
$this->created = $data['created'] ?? null;
|
||||
if (is_array($data['tags'])) {
|
||||
$this->tags = $data['tags'];
|
||||
} else {
|
||||
$this->tags = tags_str2array($data['tags'] ?? '', $tagsSeparator);
|
||||
}
|
||||
if (! empty($data['updated'])) {
|
||||
$this->updated = $data['updated'];
|
||||
}
|
||||
$this->private = ($data['private'] ?? false) ? true : false;
|
||||
$this->additionalContent = $data['additional_content'] ?? [];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that the current instance of Bookmark is valid and can be saved into the data store.
|
||||
* A valid link requires:
|
||||
* - an integer ID
|
||||
* - a short URL (for permalinks)
|
||||
* - a creation date
|
||||
*
|
||||
* This function also initialize optional empty fields:
|
||||
* - the URL with the permalink
|
||||
* - the title with the URL
|
||||
*
|
||||
* Also make sure that we do not save search highlights in the datastore.
|
||||
*
|
||||
* @throws InvalidBookmarkException
|
||||
*/
|
||||
public function validate(): void
|
||||
{
|
||||
if (
|
||||
$this->id === null
|
||||
|| ! is_int($this->id)
|
||||
|| empty($this->shortUrl)
|
||||
|| empty($this->created)
|
||||
) {
|
||||
throw new InvalidBookmarkException($this);
|
||||
}
|
||||
if (empty($this->url)) {
|
||||
$this->url = '/shaare/' . $this->shortUrl;
|
||||
}
|
||||
if (empty($this->title)) {
|
||||
$this->title = $this->url;
|
||||
}
|
||||
if (array_key_exists('search_highlight', $this->additionalContent)) {
|
||||
unset($this->additionalContent['search_highlight']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Id.
|
||||
* If they're not already initialized, this function also set:
|
||||
* - created: with the current datetime
|
||||
* - shortUrl: with a generated small hash from the date and the given ID
|
||||
*
|
||||
* @param int|null $id
|
||||
*
|
||||
* @return Bookmark
|
||||
*/
|
||||
public function setId(?int $id): Bookmark
|
||||
{
|
||||
$this->id = $id;
|
||||
if (empty($this->created)) {
|
||||
$this->created = new DateTime();
|
||||
}
|
||||
if (empty($this->shortUrl)) {
|
||||
$this->shortUrl = link_small_hash($this->created, $this->id);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Id.
|
||||
*
|
||||
* @return int|null
|
||||
*/
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ShortUrl.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getShortUrl(): ?string
|
||||
{
|
||||
return $this->shortUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Url.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getUrl(): ?string
|
||||
{
|
||||
return $this->url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Title.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getTitle(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Description.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getDescription(): string
|
||||
{
|
||||
return ! empty($this->description) ? $this->description : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Created.
|
||||
*
|
||||
* @return DateTimeInterface
|
||||
*/
|
||||
public function getCreated(): ?DateTimeInterface
|
||||
{
|
||||
return $this->created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Updated.
|
||||
*
|
||||
* @return DateTimeInterface
|
||||
*/
|
||||
public function getUpdated(): ?DateTimeInterface
|
||||
{
|
||||
return $this->updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the ShortUrl.
|
||||
*
|
||||
* @param string|null $shortUrl
|
||||
*
|
||||
* @return Bookmark
|
||||
*/
|
||||
public function setShortUrl(?string $shortUrl): Bookmark
|
||||
{
|
||||
$this->shortUrl = $shortUrl;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Url.
|
||||
*
|
||||
* @param string|null $url
|
||||
* @param string[] $allowedProtocols
|
||||
*
|
||||
* @return Bookmark
|
||||
*/
|
||||
public function setUrl(?string $url, array $allowedProtocols = []): Bookmark
|
||||
{
|
||||
$url = $url !== null ? trim($url) : '';
|
||||
if (! empty($url)) {
|
||||
$url = whitelist_protocols($url, $allowedProtocols);
|
||||
}
|
||||
$this->url = $url;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Title.
|
||||
*
|
||||
* @param string|null $title
|
||||
*
|
||||
* @return Bookmark
|
||||
*/
|
||||
public function setTitle(?string $title): Bookmark
|
||||
{
|
||||
$this->title = $title !== null ? trim($title) : '';
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Description.
|
||||
*
|
||||
* @param string|null $description
|
||||
*
|
||||
* @return Bookmark
|
||||
*/
|
||||
public function setDescription(?string $description): Bookmark
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Created.
|
||||
* Note: you shouldn't set this manually except for special cases (like bookmark import)
|
||||
*
|
||||
* @param DateTimeInterface|null $created
|
||||
*
|
||||
* @return Bookmark
|
||||
*/
|
||||
public function setCreated(?DateTimeInterface $created): Bookmark
|
||||
{
|
||||
$this->created = $created;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Updated.
|
||||
*
|
||||
* @param DateTimeInterface|null $updated
|
||||
*
|
||||
* @return Bookmark
|
||||
*/
|
||||
public function setUpdated(?DateTimeInterface $updated): Bookmark
|
||||
{
|
||||
$this->updated = $updated;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Private.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isPrivate(): bool
|
||||
{
|
||||
return $this->private ? true : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Private.
|
||||
*
|
||||
* @param bool|null $private
|
||||
*
|
||||
* @return Bookmark
|
||||
*/
|
||||
public function setPrivate(?bool $private): Bookmark
|
||||
{
|
||||
$this->private = $private ? true : false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Tags.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getTags(): array
|
||||
{
|
||||
return is_array($this->tags) ? $this->tags : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Tags.
|
||||
*
|
||||
* @param string[]|null $tags
|
||||
*
|
||||
* @return Bookmark
|
||||
*/
|
||||
public function setTags(?array $tags): Bookmark
|
||||
{
|
||||
$this->tags = array_map(
|
||||
function (string $tag): string {
|
||||
return $tag[0] === '-' ? substr($tag, 1) : $tag;
|
||||
},
|
||||
tags_filter($tags, ' ')
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Thumbnail.
|
||||
*
|
||||
* @return string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found
|
||||
*/
|
||||
public function getThumbnail()
|
||||
{
|
||||
return !$this->isNote() ? $this->thumbnail : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Thumbnail.
|
||||
*
|
||||
* @param string|bool|null $thumbnail Thumbnail's URL - false if no thumbnail could be found
|
||||
*
|
||||
* @return Bookmark
|
||||
*/
|
||||
public function setThumbnail($thumbnail): Bookmark
|
||||
{
|
||||
$this->thumbnail = $thumbnail;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if:
|
||||
* - the bookmark's thumbnail is not already set to false (= not found)
|
||||
* - it's not a note
|
||||
* - it's an HTTP(S) link
|
||||
* - the thumbnail has not yet be retrieved (null) or its associated cache file doesn't exist anymore
|
||||
*
|
||||
* @return bool True if the bookmark's thumbnail needs to be retrieved.
|
||||
*/
|
||||
public function shouldUpdateThumbnail(): bool
|
||||
{
|
||||
return $this->thumbnail !== false
|
||||
&& !$this->isNote()
|
||||
&& startsWith(strtolower($this->url), 'http')
|
||||
&& (null === $this->thumbnail || !is_file($this->thumbnail))
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Sticky.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isSticky(): bool
|
||||
{
|
||||
return $this->sticky ? true : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Sticky.
|
||||
*
|
||||
* @param bool|null $sticky
|
||||
*
|
||||
* @return Bookmark
|
||||
*/
|
||||
public function setSticky(?bool $sticky): Bookmark
|
||||
{
|
||||
$this->sticky = $sticky ? true : false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $separator Tags separator loaded from the config file.
|
||||
*
|
||||
* @return string Bookmark's tags as a string, separated by a separator
|
||||
*/
|
||||
public function getTagsString(string $separator = ' '): string
|
||||
{
|
||||
return tags_array2str($this->getTags(), $separator);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function isNote(): bool
|
||||
{
|
||||
// We check empty value to get a valid result if the link has not been saved yet
|
||||
return empty($this->url) || startsWith($this->url, '/shaare/') || $this->url[0] === '?';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set tags from a string.
|
||||
* Note:
|
||||
* - tags must be separated whether by a space or a comma
|
||||
* - multiple spaces will be removed
|
||||
* - trailing dash in tags will be removed
|
||||
*
|
||||
* @param string|null $tags
|
||||
* @param string $separator Tags separator loaded from the config file.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setTagsString(?string $tags, string $separator = ' '): Bookmark
|
||||
{
|
||||
$this->setTags(tags_str2array($tags, $separator));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entire additionalContent array.
|
||||
*
|
||||
* @return mixed[]
|
||||
*/
|
||||
public function getAdditionalContent(): array
|
||||
{
|
||||
return $this->additionalContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a single entry in additionalContent, by key.
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed|null $value Any type of value can be set.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setAdditionalContentEntry(string $key, $value): self
|
||||
{
|
||||
$this->additionalContent[$key] = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single entry in additionalContent, by key.
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed|null $default
|
||||
*
|
||||
* @return mixed|null can be any type or even null.
|
||||
*/
|
||||
public function getAdditionalContentEntry(string $key, $default = null)
|
||||
{
|
||||
return array_key_exists($key, $this->additionalContent) ? $this->additionalContent[$key] : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a tag in tags list.
|
||||
*
|
||||
* @param string $fromTag
|
||||
* @param string $toTag
|
||||
*/
|
||||
public function renameTag(string $fromTag, string $toTag): void
|
||||
{
|
||||
if (($pos = array_search($fromTag, $this->tags ?? [])) !== false) {
|
||||
$this->tags[$pos] = trim($toTag);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a tag in tags list.
|
||||
*
|
||||
* @param string $tag
|
||||
*/
|
||||
public function addTag(string $tag): self
|
||||
{
|
||||
return $this->setTags(array_unique(array_merge($this->getTags(), [$tag])));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tag from tags list.
|
||||
*
|
||||
* @param string $tag
|
||||
*/
|
||||
public function deleteTag(string $tag): void
|
||||
{
|
||||
while (($pos = array_search($tag, $this->tags ?? [])) !== false) {
|
||||
unset($this->tags[$pos]);
|
||||
$this->tags = array_values($this->tags);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,264 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shaarli\Bookmark;
|
||||
|
||||
use Shaarli\Bookmark\Exception\InvalidBookmarkException;
|
||||
|
||||
/**
|
||||
* Class BookmarkArray
|
||||
*
|
||||
* Implementing ArrayAccess, this allows us to use the bookmark list
|
||||
* as an array and iterate over it.
|
||||
*
|
||||
* @package Shaarli\Bookmark
|
||||
*/
|
||||
class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
|
||||
{
|
||||
/**
|
||||
* @var Bookmark[]
|
||||
*/
|
||||
protected $bookmarks;
|
||||
|
||||
/**
|
||||
* @var array List of all bookmarks IDS mapped with their array offset.
|
||||
* Map: id->offset.
|
||||
*/
|
||||
protected $ids;
|
||||
|
||||
/**
|
||||
* @var int Position in the $this->keys array (for the Iterator interface)
|
||||
*/
|
||||
protected $position;
|
||||
|
||||
/**
|
||||
* @var array List of offset keys (for the Iterator interface implementation)
|
||||
*/
|
||||
protected $keys;
|
||||
|
||||
/**
|
||||
* @var array List of all recorded URLs (key=url, value=bookmark offset)
|
||||
* for fast reserve search (url-->bookmark offset)
|
||||
*/
|
||||
protected $urls;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->ids = [];
|
||||
$this->bookmarks = [];
|
||||
$this->keys = [];
|
||||
$this->urls = [];
|
||||
$this->position = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Countable - Counts elements of an object
|
||||
*
|
||||
* @return int Number of bookmarks
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->bookmarks);
|
||||
}
|
||||
|
||||
/**
|
||||
* ArrayAccess - Assigns a value to the specified offset
|
||||
*
|
||||
* @param int $offset Bookmark ID
|
||||
* @param Bookmark $value instance
|
||||
*
|
||||
* @throws InvalidBookmarkException
|
||||
*/
|
||||
public function offsetSet($offset, $value): void
|
||||
{
|
||||
if (
|
||||
! $value instanceof Bookmark
|
||||
|| $value->getId() === null || empty($value->getUrl())
|
||||
|| ($offset !== null && ! is_int($offset)) || ! is_int($value->getId())
|
||||
|| $offset !== null && $offset !== $value->getId()
|
||||
) {
|
||||
throw new InvalidBookmarkException($value);
|
||||
}
|
||||
|
||||
// If the bookmark exists, we reuse the real offset, otherwise new entry
|
||||
if ($offset !== null) {
|
||||
$existing = $this->getBookmarkOffset($offset);
|
||||
} else {
|
||||
$existing = $this->getBookmarkOffset($value->getId());
|
||||
}
|
||||
|
||||
if ($existing !== null) {
|
||||
$offset = $existing;
|
||||
} else {
|
||||
$offset = count($this->bookmarks);
|
||||
}
|
||||
|
||||
$this->bookmarks[$offset] = $value;
|
||||
$this->urls[$value->getUrl()] = $offset;
|
||||
$this->ids[$value->getId()] = $offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* ArrayAccess - Whether or not an offset exists
|
||||
*
|
||||
* @param int $offset Bookmark ID
|
||||
*
|
||||
* @return bool true if it exists, false otherwise
|
||||
*/
|
||||
public function offsetExists($offset): bool
|
||||
{
|
||||
return array_key_exists($this->getBookmarkOffset($offset), $this->bookmarks);
|
||||
}
|
||||
|
||||
/**
|
||||
* ArrayAccess - Unsets an offset
|
||||
*
|
||||
* @param int $offset Bookmark ID
|
||||
*/
|
||||
public function offsetUnset($offset): void
|
||||
{
|
||||
$realOffset = $this->getBookmarkOffset($offset);
|
||||
$url = $this->bookmarks[$realOffset]->getUrl();
|
||||
unset($this->urls[$url]);
|
||||
unset($this->ids[$offset]);
|
||||
unset($this->bookmarks[$realOffset]);
|
||||
}
|
||||
|
||||
/**
|
||||
* ArrayAccess - Returns the value at specified offset
|
||||
*
|
||||
* @param int $offset Bookmark ID
|
||||
*
|
||||
* @return Bookmark|null The Bookmark if found, null otherwise
|
||||
*/
|
||||
public function offsetGet($offset): ?Bookmark
|
||||
{
|
||||
$realOffset = $this->getBookmarkOffset($offset);
|
||||
return isset($this->bookmarks[$realOffset]) ? $this->bookmarks[$realOffset] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterator - Returns the current element
|
||||
*
|
||||
* @return Bookmark corresponding to the current position
|
||||
*/
|
||||
public function current(): Bookmark
|
||||
{
|
||||
return $this[$this->keys[$this->position]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterator - Returns the key of the current element
|
||||
*
|
||||
* @return int Bookmark ID corresponding to the current position
|
||||
*/
|
||||
public function key(): int
|
||||
{
|
||||
return $this->keys[$this->position];
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterator - Moves forward to next element
|
||||
*/
|
||||
public function next(): void
|
||||
{
|
||||
++$this->position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterator - Rewinds the Iterator to the first element
|
||||
*
|
||||
* Entries are sorted by date (latest first)
|
||||
*/
|
||||
public function rewind(): void
|
||||
{
|
||||
$this->keys = array_keys($this->ids);
|
||||
$this->position = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterator - Checks if current position is valid
|
||||
*
|
||||
* @return bool true if the current Bookmark ID exists, false otherwise
|
||||
*/
|
||||
public function valid(): bool
|
||||
{
|
||||
return isset($this->keys[$this->position]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a bookmark offset in bookmarks array from its unique ID.
|
||||
*
|
||||
* @param int|null $id Persistent ID of a bookmark.
|
||||
*
|
||||
* @return int Real offset in local array, or null if doesn't exist.
|
||||
*/
|
||||
protected function getBookmarkOffset(?int $id): ?int
|
||||
{
|
||||
if ($id !== null && isset($this->ids[$id])) {
|
||||
return $this->ids[$id];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the next key for bookmark creation.
|
||||
* E.g. If the last ID is 597, the next will be 598.
|
||||
*
|
||||
* @return int next ID.
|
||||
*/
|
||||
public function getNextId(): int
|
||||
{
|
||||
if (!empty($this->ids)) {
|
||||
return max(array_keys($this->ids)) + 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $url
|
||||
*
|
||||
* @return Bookmark|null
|
||||
*/
|
||||
public function getByUrl(string $url): ?Bookmark
|
||||
{
|
||||
if (
|
||||
! empty($url)
|
||||
&& isset($this->urls[$url])
|
||||
&& isset($this->bookmarks[$this->urls[$url]])
|
||||
) {
|
||||
return $this->bookmarks[$this->urls[$url]];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder links by creation date (newest first).
|
||||
*
|
||||
* Also update the urls and ids mapping arrays.
|
||||
*
|
||||
* @param string $order ASC|DESC
|
||||
* @param bool $ignoreSticky If set to true, sticky bookmarks won't be first
|
||||
*/
|
||||
public function reorder(string $order = 'DESC', bool $ignoreSticky = false): void
|
||||
{
|
||||
$order = $order === 'ASC' ? -1 : 1;
|
||||
// Reorder array by dates.
|
||||
usort($this->bookmarks, function ($a, $b) use ($order, $ignoreSticky) {
|
||||
/** @var $a Bookmark */
|
||||
/** @var $b Bookmark */
|
||||
if (false === $ignoreSticky && $a->isSticky() !== $b->isSticky()) {
|
||||
return $a->isSticky() ? -1 : 1;
|
||||
}
|
||||
return $a->getCreated() < $b->getCreated() ? 1 * $order : -1 * $order;
|
||||
});
|
||||
|
||||
$this->urls = [];
|
||||
$this->ids = [];
|
||||
foreach ($this->bookmarks as $key => $bookmark) {
|
||||
$this->urls[$bookmark->getUrl()] = $key;
|
||||
$this->ids[$bookmark->getId()] = $key;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,443 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shaarli\Bookmark;
|
||||
|
||||
use DateTime;
|
||||
use Exception;
|
||||
use malkusch\lock\mutex\Mutex;
|
||||
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
|
||||
use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
|
||||
use Shaarli\Bookmark\Exception\EmptyDataStoreException;
|
||||
use Shaarli\Config\ConfigManager;
|
||||
use Shaarli\Formatter\BookmarkMarkdownFormatter;
|
||||
use Shaarli\History;
|
||||
use Shaarli\Legacy\LegacyLinkDB;
|
||||
use Shaarli\Legacy\LegacyUpdater;
|
||||
use Shaarli\Plugin\PluginManager;
|
||||
use Shaarli\Render\PageCacheManager;
|
||||
use Shaarli\Updater\UpdaterUtils;
|
||||
|
||||
/**
|
||||
* Class BookmarksService
|
||||
*
|
||||
* This is the entry point to manipulate the bookmark DB.
|
||||
* It manipulates loads links from a file data store containing all bookmarks.
|
||||
*
|
||||
* It also triggers the legacy format (bookmarks as arrays) migration.
|
||||
*/
|
||||
class BookmarkFileService implements BookmarkServiceInterface
|
||||
{
|
||||
/** @var Bookmark[] instance */
|
||||
protected $bookmarks;
|
||||
|
||||
/** @var BookmarkIO instance */
|
||||
protected $bookmarksIO;
|
||||
|
||||
/** @var BookmarkFilter */
|
||||
protected $bookmarkFilter;
|
||||
|
||||
/** @var ConfigManager instance */
|
||||
protected $conf;
|
||||
|
||||
/** @var PluginManager */
|
||||
protected $pluginManager;
|
||||
|
||||
/** @var History instance */
|
||||
protected $history;
|
||||
|
||||
/** @var PageCacheManager instance */
|
||||
protected $pageCacheManager;
|
||||
|
||||
/** @var bool true for logged in users. Default value to retrieve private bookmarks. */
|
||||
protected $isLoggedIn;
|
||||
|
||||
/** @var Mutex */
|
||||
protected $mutex;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function __construct(
|
||||
ConfigManager $conf,
|
||||
PluginManager $pluginManager,
|
||||
History $history,
|
||||
Mutex $mutex,
|
||||
bool $isLoggedIn
|
||||
) {
|
||||
$this->conf = $conf;
|
||||
$this->history = $history;
|
||||
$this->mutex = $mutex;
|
||||
$this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn);
|
||||
$this->bookmarksIO = new BookmarkIO($this->conf, $this->mutex);
|
||||
$this->isLoggedIn = $isLoggedIn;
|
||||
|
||||
if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) {
|
||||
$this->bookmarks = new BookmarkArray();
|
||||
} else {
|
||||
try {
|
||||
$this->bookmarks = $this->bookmarksIO->read();
|
||||
} catch (EmptyDataStoreException | DatastoreNotInitializedException $e) {
|
||||
$this->bookmarks = new BookmarkArray();
|
||||
|
||||
if ($this->isLoggedIn) {
|
||||
// Datastore file does not exists, we initialize it with default bookmarks.
|
||||
if ($e instanceof DatastoreNotInitializedException) {
|
||||
$this->initialize();
|
||||
} else {
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $this->bookmarks instanceof BookmarkArray) {
|
||||
$this->migrate();
|
||||
exit(
|
||||
'Your data store has been migrated, please reload the page.' . PHP_EOL .
|
||||
'If this message keeps showing up, please delete data/updates.txt file.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->pluginManager = $pluginManager;
|
||||
$this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf, $this->pluginManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function findByHash(string $hash, string $privateKey = null): Bookmark
|
||||
{
|
||||
$bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash);
|
||||
// PHP 7.3 introduced array_key_first() to avoid this hack
|
||||
$first = reset($bookmark);
|
||||
if (
|
||||
!$this->isLoggedIn
|
||||
&& $first->isPrivate()
|
||||
&& (empty($privateKey) || $privateKey !== $first->getAdditionalContentEntry('private_key'))
|
||||
) {
|
||||
throw new BookmarkNotFoundException();
|
||||
}
|
||||
|
||||
return $first;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function findByUrl(string $url): ?Bookmark
|
||||
{
|
||||
return $this->bookmarks->getByUrl($url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function search(
|
||||
array $request = [],
|
||||
string $visibility = null,
|
||||
bool $caseSensitive = false,
|
||||
bool $untaggedOnly = false,
|
||||
bool $ignoreSticky = false,
|
||||
array $pagination = []
|
||||
): SearchResult {
|
||||
if ($visibility === null) {
|
||||
$visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
|
||||
}
|
||||
|
||||
// Filter bookmark database according to parameters.
|
||||
$searchTags = isset($request['searchtags']) ? $request['searchtags'] : '';
|
||||
$searchTerm = isset($request['searchterm']) ? $request['searchterm'] : '';
|
||||
|
||||
if ($ignoreSticky) {
|
||||
$this->bookmarks->reorder('DESC', true);
|
||||
}
|
||||
|
||||
$bookmarks = $this->bookmarkFilter->filter(
|
||||
BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT,
|
||||
[$searchTags, $searchTerm],
|
||||
$caseSensitive,
|
||||
$visibility,
|
||||
$untaggedOnly
|
||||
);
|
||||
|
||||
return SearchResult::getSearchResult(
|
||||
$bookmarks,
|
||||
$pagination['offset'] ?? 0,
|
||||
$pagination['limit'] ?? null,
|
||||
$pagination['allowOutOfBounds'] ?? false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function get(int $id, string $visibility = null): Bookmark
|
||||
{
|
||||
if (! isset($this->bookmarks[$id])) {
|
||||
throw new BookmarkNotFoundException();
|
||||
}
|
||||
|
||||
if ($visibility === null) {
|
||||
$visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
|
||||
}
|
||||
|
||||
$bookmark = $this->bookmarks[$id];
|
||||
if (
|
||||
($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
|
||||
|| (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
|
||||
) {
|
||||
throw new Exception('Unauthorized');
|
||||
}
|
||||
|
||||
return $bookmark;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function set(Bookmark $bookmark, bool $save = true): Bookmark
|
||||
{
|
||||
if (true !== $this->isLoggedIn) {
|
||||
throw new Exception(t('You\'re not authorized to alter the datastore'));
|
||||
}
|
||||
if (! isset($this->bookmarks[$bookmark->getId()])) {
|
||||
throw new BookmarkNotFoundException();
|
||||
}
|
||||
$bookmark->validate();
|
||||
|
||||
$bookmark->setUpdated(new DateTime());
|
||||
$this->bookmarks[$bookmark->getId()] = $bookmark;
|
||||
if ($save === true) {
|
||||
$this->save();
|
||||
$this->history->updateLink($bookmark);
|
||||
}
|
||||
return $this->bookmarks[$bookmark->getId()];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function add(Bookmark $bookmark, bool $save = true): Bookmark
|
||||
{
|
||||
if (true !== $this->isLoggedIn) {
|
||||
throw new Exception(t('You\'re not authorized to alter the datastore'));
|
||||
}
|
||||
if (!empty($bookmark->getId())) {
|
||||
throw new Exception(t('This bookmarks already exists'));
|
||||
}
|
||||
$bookmark->setId($this->bookmarks->getNextId());
|
||||
$bookmark->validate();
|
||||
|
||||
$this->bookmarks[$bookmark->getId()] = $bookmark;
|
||||
if ($save === true) {
|
||||
$this->save();
|
||||
$this->history->addLink($bookmark);
|
||||
}
|
||||
return $this->bookmarks[$bookmark->getId()];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark
|
||||
{
|
||||
if (true !== $this->isLoggedIn) {
|
||||
throw new Exception(t('You\'re not authorized to alter the datastore'));
|
||||
}
|
||||
if ($bookmark->getId() === null) {
|
||||
return $this->add($bookmark, $save);
|
||||
}
|
||||
return $this->set($bookmark, $save);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function remove(Bookmark $bookmark, bool $save = true): void
|
||||
{
|
||||
if (true !== $this->isLoggedIn) {
|
||||
throw new Exception(t('You\'re not authorized to alter the datastore'));
|
||||
}
|
||||
if (! isset($this->bookmarks[$bookmark->getId()])) {
|
||||
throw new BookmarkNotFoundException();
|
||||
}
|
||||
|
||||
unset($this->bookmarks[$bookmark->getId()]);
|
||||
if ($save === true) {
|
||||
$this->save();
|
||||
$this->history->deleteLink($bookmark);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function exists(int $id, string $visibility = null): bool
|
||||
{
|
||||
if (! isset($this->bookmarks[$id])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($visibility === null) {
|
||||
$visibility = $this->isLoggedIn ? 'all' : 'public';
|
||||
}
|
||||
|
||||
$bookmark = $this->bookmarks[$id];
|
||||
if (
|
||||
($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
|
||||
|| (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function count(string $visibility = null): int
|
||||
{
|
||||
return $this->search([], $visibility)->getResultCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function save(): void
|
||||
{
|
||||
if (true !== $this->isLoggedIn) {
|
||||
// TODO: raise an Exception instead
|
||||
die('You are not authorized to change the database.');
|
||||
}
|
||||
|
||||
$this->bookmarks->reorder();
|
||||
$this->bookmarksIO->write($this->bookmarks);
|
||||
$this->pageCacheManager->invalidateCaches();
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function bookmarksCountPerTag(array $filteringTags = [], string $visibility = null): array
|
||||
{
|
||||
$searchResult = $this->search(['searchtags' => $filteringTags], $visibility);
|
||||
$tags = [];
|
||||
$caseMapping = [];
|
||||
foreach ($searchResult->getBookmarks() as $bookmark) {
|
||||
foreach ($bookmark->getTags() as $tag) {
|
||||
if (
|
||||
empty($tag)
|
||||
|| (! $this->isLoggedIn && startsWith($tag, '.'))
|
||||
|| $tag === BookmarkMarkdownFormatter::NO_MD_TAG
|
||||
|| in_array($tag, $filteringTags, true)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The first case found will be displayed.
|
||||
if (!isset($caseMapping[strtolower($tag)])) {
|
||||
$caseMapping[strtolower($tag)] = $tag;
|
||||
$tags[$caseMapping[strtolower($tag)]] = 0;
|
||||
}
|
||||
$tags[$caseMapping[strtolower($tag)]]++;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Formerly used arsort(), which doesn't define the sort behaviour for equal values.
|
||||
* Also, this function doesn't produce the same result between PHP 5.6 and 7.
|
||||
*
|
||||
* So we now use array_multisort() to sort tags by DESC occurrences,
|
||||
* then ASC alphabetically for equal values.
|
||||
*
|
||||
* @see https://github.com/shaarli/Shaarli/issues/1142
|
||||
*/
|
||||
$keys = array_keys($tags);
|
||||
$tmpTags = array_combine($keys, $keys);
|
||||
array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
|
||||
|
||||
return $tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function findByDate(
|
||||
\DateTimeInterface $from,
|
||||
\DateTimeInterface $to,
|
||||
?\DateTimeInterface &$previous,
|
||||
?\DateTimeInterface &$next
|
||||
): array {
|
||||
$out = [];
|
||||
$previous = null;
|
||||
$next = null;
|
||||
|
||||
foreach ($this->search([], null, false, false, true)->getBookmarks() as $bookmark) {
|
||||
if ($to < $bookmark->getCreated()) {
|
||||
$next = $bookmark->getCreated();
|
||||
} elseif ($from < $bookmark->getCreated() && $to > $bookmark->getCreated()) {
|
||||
$out[] = $bookmark;
|
||||
} else {
|
||||
if ($previous !== null) {
|
||||
break;
|
||||
}
|
||||
$previous = $bookmark->getCreated();
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getLatest(): ?Bookmark
|
||||
{
|
||||
foreach ($this->search([], null, false, false, true)->getBookmarks() as $bookmark) {
|
||||
return $bookmark;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function initialize(): void
|
||||
{
|
||||
$initializer = new BookmarkInitializer($this);
|
||||
$initializer->initialize();
|
||||
|
||||
if (true === $this->isLoggedIn) {
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles migration to the new database format (BookmarksArray).
|
||||
*/
|
||||
protected function migrate(): void
|
||||
{
|
||||
$bookmarkDb = new LegacyLinkDB(
|
||||
$this->conf->get('resource.datastore'),
|
||||
true,
|
||||
false
|
||||
);
|
||||
$updater = new LegacyUpdater(
|
||||
UpdaterUtils::readUpdatesFile($this->conf->get('resource.updates')),
|
||||
$bookmarkDb,
|
||||
$this->conf,
|
||||
true
|
||||
);
|
||||
$newUpdates = $updater->update();
|
||||
if (! empty($newUpdates)) {
|
||||
UpdaterUtils::writeUpdatesFile(
|
||||
$this->conf->get('resource.updates'),
|
||||
$updater->getDoneUpdates()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,635 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shaarli\Bookmark;
|
||||
|
||||
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
|
||||
use Shaarli\Config\ConfigManager;
|
||||
use Shaarli\Plugin\PluginManager;
|
||||
|
||||
/**
|
||||
* Class LinkFilter.
|
||||
*
|
||||
* Perform search and filter operation on link data list.
|
||||
*/
|
||||
class BookmarkFilter
|
||||
{
|
||||
/**
|
||||
* @var string permalinks.
|
||||
*/
|
||||
public static $FILTER_HASH = 'permalink';
|
||||
|
||||
/**
|
||||
* @var string text search.
|
||||
*/
|
||||
public static $FILTER_TEXT = 'fulltext';
|
||||
|
||||
/**
|
||||
* @var string tag filter.
|
||||
*/
|
||||
public static $FILTER_TAG = 'tags';
|
||||
|
||||
/**
|
||||
* @var string filter by day.
|
||||
*/
|
||||
public static $DEFAULT = 'NO_FILTER';
|
||||
|
||||
/** @var string Visibility: all */
|
||||
public static $ALL = 'all';
|
||||
|
||||
/** @var string Visibility: public */
|
||||
public static $PUBLIC = 'public';
|
||||
|
||||
/** @var string Visibility: private */
|
||||
public static $PRIVATE = 'private';
|
||||
|
||||
/**
|
||||
* @var string Allowed characters for hashtags (regex syntax).
|
||||
*/
|
||||
public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}';
|
||||
|
||||
/**
|
||||
* @var Bookmark[] all available bookmarks.
|
||||
*/
|
||||
private $bookmarks;
|
||||
|
||||
/** @var ConfigManager */
|
||||
protected $conf;
|
||||
|
||||
/** @var PluginManager */
|
||||
protected $pluginManager;
|
||||
|
||||
/**
|
||||
* @param Bookmark[] $bookmarks initialization.
|
||||
*/
|
||||
public function __construct($bookmarks, ConfigManager $conf, PluginManager $pluginManager)
|
||||
{
|
||||
$this->bookmarks = $bookmarks;
|
||||
$this->conf = $conf;
|
||||
$this->pluginManager = $pluginManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter bookmarks according to parameters.
|
||||
*
|
||||
* @param string $type Type of filter (eg. tags, permalink, etc.).
|
||||
* @param mixed $request Filter content.
|
||||
* @param bool $casesensitive Optional: Perform case sensitive filter if true.
|
||||
* @param string $visibility Optional: return only all/private/public bookmarks
|
||||
* @param bool $untaggedonly Optional: return only untagged bookmarks. Applies only if $type includes FILTER_TAG
|
||||
*
|
||||
* @return Bookmark[] filtered bookmark list.
|
||||
*
|
||||
* @throws BookmarkNotFoundException
|
||||
*/
|
||||
public function filter(
|
||||
string $type,
|
||||
$request,
|
||||
bool $casesensitive = false,
|
||||
string $visibility = 'all',
|
||||
bool $untaggedonly = false
|
||||
) {
|
||||
if (!in_array($visibility, ['all', 'public', 'private'])) {
|
||||
$visibility = 'all';
|
||||
}
|
||||
|
||||
switch ($type) {
|
||||
case self::$FILTER_HASH:
|
||||
return $this->filterSmallHash($request);
|
||||
case self::$FILTER_TAG | self::$FILTER_TEXT: // == "vuotext"
|
||||
$noRequest = empty($request) || (empty($request[0]) && empty($request[1]));
|
||||
if ($noRequest) {
|
||||
if ($untaggedonly) {
|
||||
return $this->filterUntagged($visibility);
|
||||
}
|
||||
return $this->noFilter($visibility);
|
||||
}
|
||||
if ($untaggedonly) {
|
||||
$filtered = $this->filterUntagged($visibility);
|
||||
} else {
|
||||
$filtered = $this->bookmarks;
|
||||
}
|
||||
if (!empty($request[0])) {
|
||||
$filtered = (new BookmarkFilter($filtered, $this->conf, $this->pluginManager))
|
||||
->filterTags($request[0], $casesensitive, $visibility)
|
||||
;
|
||||
}
|
||||
if (!empty($request[1])) {
|
||||
$filtered = (new BookmarkFilter($filtered, $this->conf, $this->pluginManager))
|
||||
->filterFulltext($request[1], $visibility)
|
||||
;
|
||||
}
|
||||
return $filtered;
|
||||
case self::$FILTER_TEXT:
|
||||
return $this->filterFulltext($request, $visibility);
|
||||
case self::$FILTER_TAG:
|
||||
if ($untaggedonly) {
|
||||
return $this->filterUntagged($visibility);
|
||||
} else {
|
||||
return $this->filterTags($request, $casesensitive, $visibility);
|
||||
}
|
||||
default:
|
||||
return $this->noFilter($visibility);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unknown filter, but handle private only.
|
||||
*
|
||||
* @param string $visibility Optional: return only all/private/public bookmarks
|
||||
*
|
||||
* @return Bookmark[] filtered bookmarks.
|
||||
*/
|
||||
private function noFilter(string $visibility = 'all')
|
||||
{
|
||||
$out = [];
|
||||
foreach ($this->bookmarks as $key => $value) {
|
||||
if (
|
||||
!$this->pluginManager->filterSearchEntry(
|
||||
$value,
|
||||
['source' => 'no_filter', 'visibility' => $visibility]
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($visibility === 'all') {
|
||||
$out[$key] = $value;
|
||||
} elseif ($value->isPrivate() && $visibility === 'private') {
|
||||
$out[$key] = $value;
|
||||
} elseif (!$value->isPrivate() && $visibility === 'public') {
|
||||
$out[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the shaare corresponding to a smallHash.
|
||||
*
|
||||
* @param string $smallHash permalink hash.
|
||||
*
|
||||
* @return Bookmark[] $filtered array containing permalink data.
|
||||
*
|
||||
* @throws BookmarkNotFoundException if the smallhash doesn't match any link.
|
||||
*/
|
||||
private function filterSmallHash(string $smallHash)
|
||||
{
|
||||
foreach ($this->bookmarks as $key => $l) {
|
||||
if ($smallHash == $l->getShortUrl()) {
|
||||
// Yes, this is ugly and slow
|
||||
return [$key => $l];
|
||||
}
|
||||
}
|
||||
|
||||
throw new BookmarkNotFoundException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of bookmarks corresponding to a full-text search
|
||||
*
|
||||
* Searches:
|
||||
* - in the URLs, title and description;
|
||||
* - are case-insensitive;
|
||||
* - terms surrounded by quotes " are exact terms search.
|
||||
* - terms starting with a dash - are excluded (except exact terms).
|
||||
*
|
||||
* Example:
|
||||
* print_r($mydb->filterFulltext('hollandais'));
|
||||
*
|
||||
* mb_convert_case($val, MB_CASE_LOWER, 'UTF-8')
|
||||
* - allows to perform searches on Unicode text
|
||||
* - see https://github.com/shaarli/Shaarli/issues/75 for examples
|
||||
*
|
||||
* @param string $searchterms search query.
|
||||
* @param string $visibility Optional: return only all/private/public bookmarks.
|
||||
*
|
||||
* @return Bookmark[] search results.
|
||||
*/
|
||||
private function filterFulltext(string $searchterms, string $visibility = 'all')
|
||||
{
|
||||
if (empty($searchterms)) {
|
||||
return $this->noFilter($visibility);
|
||||
}
|
||||
|
||||
$filtered = [];
|
||||
$search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
|
||||
$exactRegex = '/"([^"]+)"/';
|
||||
// Retrieve exact search terms.
|
||||
preg_match_all($exactRegex, $search, $exactSearch);
|
||||
$exactSearch = array_values(array_filter($exactSearch[1]));
|
||||
|
||||
// Remove exact search terms to get AND terms search.
|
||||
$explodedSearchAnd = explode(' ', trim(preg_replace($exactRegex, '', $search)));
|
||||
$explodedSearchAnd = array_values(array_filter($explodedSearchAnd));
|
||||
|
||||
// Filter excluding terms and update andSearch.
|
||||
$excludeSearch = [];
|
||||
$andSearch = [];
|
||||
foreach ($explodedSearchAnd as $needle) {
|
||||
if ($needle[0] == '-' && strlen($needle) > 1) {
|
||||
$excludeSearch[] = substr($needle, 1);
|
||||
} else {
|
||||
$andSearch[] = $needle;
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate over every stored link.
|
||||
foreach ($this->bookmarks as $id => $bookmark) {
|
||||
if (
|
||||
!$this->pluginManager->filterSearchEntry(
|
||||
$bookmark,
|
||||
[
|
||||
'source' => 'fulltext',
|
||||
'searchterms' => $searchterms,
|
||||
'andSearch' => $andSearch,
|
||||
'exactSearch' => $exactSearch,
|
||||
'excludeSearch' => $excludeSearch,
|
||||
'visibility' => $visibility
|
||||
]
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ignore non private bookmarks when 'privatonly' is on.
|
||||
if ($visibility !== 'all') {
|
||||
if (!$bookmark->isPrivate() && $visibility === 'private') {
|
||||
continue;
|
||||
} elseif ($bookmark->isPrivate() && $visibility === 'public') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$lengths = [];
|
||||
$content = $this->buildFullTextSearchableLink($bookmark, $lengths);
|
||||
|
||||
// Be optimistic
|
||||
$found = true;
|
||||
$foundPositions = [];
|
||||
|
||||
// First, we look for exact term search
|
||||
// Then iterate over keywords, if keyword is not found,
|
||||
// no need to check for the others. We want all or nothing.
|
||||
foreach ([$exactSearch, $andSearch] as $search) {
|
||||
for ($i = 0; $i < count($search) && $found !== false; $i++) {
|
||||
$found = mb_strpos($content, $search[$i]);
|
||||
if ($found === false) {
|
||||
break;
|
||||
}
|
||||
|
||||
$foundPositions[] = ['start' => $found, 'end' => $found + mb_strlen($search[$i])];
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude terms.
|
||||
for ($i = 0; $i < count($excludeSearch) && $found !== false; $i++) {
|
||||
$found = strpos($content, $excludeSearch[$i]) === false;
|
||||
}
|
||||
|
||||
if ($found !== false) {
|
||||
$bookmark->setAdditionalContentEntry(
|
||||
'search_highlight',
|
||||
$this->postProcessFoundPositions($lengths, $foundPositions)
|
||||
);
|
||||
|
||||
$filtered[$id] = $bookmark;
|
||||
}
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of bookmarks associated with a given list of tags
|
||||
*
|
||||
* You can specify one or more tags, separated by space or a comma, e.g.
|
||||
* print_r($mydb->filterTags('linux programming'));
|
||||
*
|
||||
* @param string|array $tags list of tags, separated by commas or blank spaces if passed as string.
|
||||
* @param bool $casesensitive ignore case if false.
|
||||
* @param string $visibility Optional: return only all/private/public bookmarks.
|
||||
*
|
||||
* @return Bookmark[] filtered bookmarks.
|
||||
*/
|
||||
public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all')
|
||||
{
|
||||
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
|
||||
// get single tags (we may get passed an array, even though the docs say different)
|
||||
$inputTags = $tags;
|
||||
if (!is_array($tags)) {
|
||||
// we got an input string, split tags
|
||||
$inputTags = tags_str2array($inputTags, $tagsSeparator);
|
||||
}
|
||||
if (count($inputTags) === 0) {
|
||||
// no input tags
|
||||
return $this->noFilter($visibility);
|
||||
}
|
||||
|
||||
// If we only have public visibility, we can't look for hidden tags
|
||||
if ($visibility === self::$PUBLIC) {
|
||||
$inputTags = array_values(array_filter($inputTags, function ($tag) {
|
||||
return ! startsWith($tag, '.');
|
||||
}));
|
||||
|
||||
if (empty($inputTags)) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// build regex from all tags
|
||||
$re_and = implode(array_map([$this, 'tag2regex'], $inputTags));
|
||||
$re = '/^' . $re_and;
|
||||
|
||||
$orTags = array_filter(array_map(function ($tag) {
|
||||
return startsWith($tag, '~') ? substr($tag, 1) : null;
|
||||
}, $inputTags));
|
||||
|
||||
$re_or = implode('|', array_map([$this, 'tag2matchterm'], $orTags));
|
||||
if ($re_or) {
|
||||
$re_or = '(' . $re_or . ')';
|
||||
$re .= $this->term2match($re_or, false);
|
||||
}
|
||||
|
||||
$re .= '.*$/';
|
||||
if (!$casesensitive) {
|
||||
// make regex case insensitive
|
||||
$re .= 'i';
|
||||
}
|
||||
|
||||
// create resulting array
|
||||
$filtered = [];
|
||||
|
||||
// iterate over each link
|
||||
foreach ($this->bookmarks as $key => $bookmark) {
|
||||
if (
|
||||
!$this->pluginManager->filterSearchEntry(
|
||||
$bookmark,
|
||||
[
|
||||
'source' => 'tags',
|
||||
'tags' => $tags,
|
||||
'casesensitive' => $casesensitive,
|
||||
'visibility' => $visibility
|
||||
]
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// check level of visibility
|
||||
// ignore non private bookmarks when 'privateonly' is on.
|
||||
if ($visibility !== 'all') {
|
||||
if (!$bookmark->isPrivate() && $visibility === 'private') {
|
||||
continue;
|
||||
} elseif ($bookmark->isPrivate() && $visibility === 'public') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// build search string, start with tags of current link
|
||||
$search = $bookmark->getTagsString($tagsSeparator);
|
||||
if (strlen(trim($bookmark->getDescription())) && strpos($bookmark->getDescription(), '#') !== false) {
|
||||
// description given and at least one possible tag found
|
||||
$descTags = [];
|
||||
// find all tags in the form of #tag in the description
|
||||
preg_match_all(
|
||||
'/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
|
||||
$bookmark->getDescription(),
|
||||
$descTags
|
||||
);
|
||||
if (count($descTags[1])) {
|
||||
// there were some tags in the description, add them to the search string
|
||||
$search .= $tagsSeparator . tags_array2str($descTags[1], $tagsSeparator);
|
||||
}
|
||||
}
|
||||
// match regular expression with search string
|
||||
if (!preg_match($re, $search)) {
|
||||
// this entry does _not_ match our regex
|
||||
continue;
|
||||
}
|
||||
$filtered[$key] = $bookmark;
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only bookmarks without any tag.
|
||||
*
|
||||
* @param string $visibility return only all/private/public bookmarks.
|
||||
*
|
||||
* @return Bookmark[] filtered bookmarks.
|
||||
*/
|
||||
public function filterUntagged(string $visibility)
|
||||
{
|
||||
$filtered = [];
|
||||
foreach ($this->bookmarks as $key => $bookmark) {
|
||||
if (
|
||||
!$this->pluginManager->filterSearchEntry(
|
||||
$bookmark,
|
||||
['source' => 'untagged', 'visibility' => $visibility]
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($visibility !== 'all') {
|
||||
if (!$bookmark->isPrivate() && $visibility === 'private') {
|
||||
continue;
|
||||
} elseif ($bookmark->isPrivate() && $visibility === 'public') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($bookmark->getTags())) {
|
||||
$filtered[$key] = $bookmark;
|
||||
}
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a list of tags (str) to an array. Also
|
||||
* - handle case sensitivity.
|
||||
* - accepts spaces commas as separator.
|
||||
*
|
||||
* @param string $tags string containing a list of tags.
|
||||
* @param bool $casesensitive will convert everything to lowercase if false.
|
||||
*
|
||||
* @return string[] filtered tags string.
|
||||
*/
|
||||
public static function tagsStrToArray(string $tags, bool $casesensitive): array
|
||||
{
|
||||
// We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
|
||||
$tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8');
|
||||
$tagsOut = str_replace(',', ' ', $tagsOut);
|
||||
|
||||
return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY);
|
||||
}
|
||||
|
||||
/**
|
||||
* generate a regex fragment out of a tag
|
||||
*
|
||||
* @param string $tag to generate regexs from. may start with '-'
|
||||
* to negate, contain '*' as wildcard. Tags starting with '~' are
|
||||
* treated separately as an 'OR' clause.
|
||||
*
|
||||
* @return string generated regex fragment
|
||||
*/
|
||||
protected function tag2regex(string $tag): string
|
||||
{
|
||||
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
|
||||
if (!$tag || $tag === "-" || $tag === "*" || $tag[0] === "~") {
|
||||
// nothing to search, return empty regex
|
||||
return '';
|
||||
}
|
||||
$negate = false;
|
||||
if ($tag[0] === "+" && $tag[1]) {
|
||||
$tag = substr($tag, 1); // use offset to start after '+' character
|
||||
}
|
||||
if ($tag[0] === "-") {
|
||||
// query is negated
|
||||
$tag = substr($tag, 1); // use offset to start after '-' character
|
||||
$negate = true;
|
||||
}
|
||||
$term = $this->tag2matchterm($tag);
|
||||
|
||||
return $this->term2match($term, $negate);
|
||||
}
|
||||
|
||||
/**
|
||||
* generate a regex match term fragment out of a tag
|
||||
*
|
||||
* @param string $tag to to generate regexs from. This function
|
||||
* assumes any leading flags ('-', '~') have been stripped. The
|
||||
* wildcard flag '*' is expanded by this function and any other
|
||||
* regex characters are escaped.
|
||||
*
|
||||
* @return string generated regex match term fragment
|
||||
*/
|
||||
protected function tag2matchterm(string $tag): string
|
||||
{
|
||||
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
|
||||
$len = strlen($tag);
|
||||
$term = '';
|
||||
// iterate over string, separating it into placeholder and content
|
||||
$i = 0; // start at first character
|
||||
for (; $i < $len; $i++) {
|
||||
if ($tag[$i] === '*') {
|
||||
// placeholder found
|
||||
$term .= '[^' . $tagsSeparator . ']*?';
|
||||
} else {
|
||||
// regular characters
|
||||
$offset = strpos($tag, '*', $i);
|
||||
if ($offset === false) {
|
||||
// no placeholder found, set offset to end of string
|
||||
$offset = $len;
|
||||
}
|
||||
// subtract one, as we want to get before the placeholder or end of string
|
||||
$offset -= 1;
|
||||
// we got a tag name that we want to search for. escape any regex characters to prevent conflicts.
|
||||
$term .= preg_quote(substr($tag, $i, $offset - $i + 1), '/');
|
||||
// move $i on
|
||||
$i = $offset;
|
||||
}
|
||||
}
|
||||
|
||||
return $term;
|
||||
}
|
||||
|
||||
/**
|
||||
* generate a regex fragment out of a match term
|
||||
*
|
||||
* @param string $term is the match term already generated by tag2matchterm
|
||||
* @param bool $negate if true create a negative lookahead
|
||||
*
|
||||
* @return string generated regex fragment
|
||||
*/
|
||||
protected function term2match(string $term, bool $negate): string
|
||||
{
|
||||
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
|
||||
$regex = $negate ? '(?!' : '(?='; // use negative or positive lookahead
|
||||
|
||||
// before tag may only be the separator or the beginning
|
||||
$regex .= '.*(?:^|' . $tagsSeparator . ')';
|
||||
|
||||
$regex .= $term;
|
||||
|
||||
// after the tag may only be the separator or the end
|
||||
$regex .= '(?:$|' . $tagsSeparator . '))';
|
||||
return $regex;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method finalize the content of the foundPositions array,
|
||||
* by associated all search results to their associated bookmark field,
|
||||
* making sure that there is no overlapping results, etc.
|
||||
*
|
||||
* @param array $fieldLengths Start and end positions of every bookmark fields in the aggregated bookmark content.
|
||||
* @param array $foundPositions Positions where the search results were found in the aggregated content.
|
||||
*
|
||||
* @return array Updated $foundPositions, by bookmark field.
|
||||
*/
|
||||
protected function postProcessFoundPositions(array $fieldLengths, array $foundPositions): array
|
||||
{
|
||||
// Sort results by starting position ASC.
|
||||
usort($foundPositions, function (array $entryA, array $entryB): int {
|
||||
return $entryA['start'] > $entryB['start'] ? 1 : -1;
|
||||
});
|
||||
|
||||
$out = [];
|
||||
$currentMax = -1;
|
||||
foreach ($foundPositions as $foundPosition) {
|
||||
// we do not allow overlapping highlights
|
||||
if ($foundPosition['start'] < $currentMax) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentMax = $foundPosition['end'];
|
||||
foreach ($fieldLengths as $part => $length) {
|
||||
if ($foundPosition['start'] < $length['start'] || $foundPosition['start'] > $length['end']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[$part][] = [
|
||||
'start' => $foundPosition['start'] - $length['start'],
|
||||
'end' => $foundPosition['end'] - $length['start'],
|
||||
];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate link fields to search across fields. Adds a '\' separator for exact search terms.
|
||||
* Also populate $length array with starting and ending positions of every bookmark field
|
||||
* inside concatenated content.
|
||||
*
|
||||
* @param Bookmark $link
|
||||
* @param array $lengths (by reference)
|
||||
*
|
||||
* @return string Lowercase concatenated fields content.
|
||||
*/
|
||||
protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string
|
||||
{
|
||||
$tagString = $link->getTagsString($this->conf->get('general.tags_separator', ' '));
|
||||
$content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') . '\\';
|
||||
$content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') . '\\';
|
||||
$content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') . '\\';
|
||||
$content .= mb_convert_case($tagString, MB_CASE_LOWER, 'UTF-8') . '\\';
|
||||
|
||||
$lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())];
|
||||
$nextField = $lengths['title']['end'] + 1;
|
||||
$lengths['description'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getDescription())];
|
||||
$nextField = $lengths['description']['end'] + 1;
|
||||
$lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())];
|
||||
$nextField = $lengths['url']['end'] + 1;
|
||||
$lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($tagString)];
|
||||
|
||||
return $content;
|
||||
}
|
||||
}
|
|
@ -1,177 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shaarli\Bookmark;
|
||||
|
||||
use malkusch\lock\exception\LockAcquireException;
|
||||
use malkusch\lock\mutex\Mutex;
|
||||
use malkusch\lock\mutex\NoMutex;
|
||||
use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
|
||||
use Shaarli\Bookmark\Exception\EmptyDataStoreException;
|
||||
use Shaarli\Bookmark\Exception\InvalidWritableDataException;
|
||||
use Shaarli\Bookmark\Exception\NotEnoughSpaceException;
|
||||
use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
|
||||
use Shaarli\Config\ConfigManager;
|
||||
|
||||
/**
|
||||
* Class BookmarkIO
|
||||
*
|
||||
* This class performs read/write operation to the file data store.
|
||||
* Used by BookmarkFileService.
|
||||
*
|
||||
* @package Shaarli\Bookmark
|
||||
*/
|
||||
class BookmarkIO
|
||||
{
|
||||
/**
|
||||
* @var string Datastore file path
|
||||
*/
|
||||
protected $datastore;
|
||||
|
||||
/**
|
||||
* @var ConfigManager instance
|
||||
*/
|
||||
protected $conf;
|
||||
|
||||
|
||||
/** @var Mutex */
|
||||
protected $mutex;
|
||||
|
||||
/**
|
||||
* string Datastore PHP prefix
|
||||
*/
|
||||
protected static $phpPrefix = '<?php /* ';
|
||||
/**
|
||||
* string Datastore PHP suffix
|
||||
*/
|
||||
protected static $phpSuffix = ' */ ?>';
|
||||
|
||||
/**
|
||||
* LinksIO constructor.
|
||||
*
|
||||
* @param ConfigManager $conf instance
|
||||
*/
|
||||
public function __construct(ConfigManager $conf, Mutex $mutex = null)
|
||||
{
|
||||
if ($mutex === null) {
|
||||
// This should only happen with legacy classes
|
||||
$mutex = new NoMutex();
|
||||
}
|
||||
$this->conf = $conf;
|
||||
$this->datastore = $conf->get('resource.datastore');
|
||||
$this->mutex = $mutex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads database from disk to memory
|
||||
*
|
||||
* @return Bookmark[]
|
||||
*
|
||||
* @throws NotWritableDataStoreException Data couldn't be loaded
|
||||
* @throws EmptyDataStoreException Datastore file exists but does not contain any bookmark
|
||||
* @throws DatastoreNotInitializedException File does not exists
|
||||
*/
|
||||
public function read()
|
||||
{
|
||||
if (! file_exists($this->datastore)) {
|
||||
throw new DatastoreNotInitializedException();
|
||||
}
|
||||
|
||||
if (!is_writable($this->datastore)) {
|
||||
throw new NotWritableDataStoreException($this->datastore);
|
||||
}
|
||||
|
||||
$content = null;
|
||||
$this->synchronized(function () use (&$content) {
|
||||
$content = file_get_contents($this->datastore);
|
||||
});
|
||||
|
||||
// Note that gzinflate is faster than gzuncompress.
|
||||
// See: http://www.php.net/manual/en/function.gzdeflate.php#96439
|
||||
$links = unserialize(gzinflate(base64_decode(
|
||||
substr($content, strlen(self::$phpPrefix), -strlen(self::$phpSuffix))
|
||||
)));
|
||||
|
||||
if (empty($links)) {
|
||||
if (filesize($this->datastore) > 100) {
|
||||
throw new NotWritableDataStoreException($this->datastore);
|
||||
}
|
||||
throw new EmptyDataStoreException();
|
||||
}
|
||||
|
||||
return $links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the database from memory to disk
|
||||
*
|
||||
* @param Bookmark[] $links
|
||||
*
|
||||
* @throws NotWritableDataStoreException the datastore is not writable
|
||||
* @throws InvalidWritableDataException
|
||||
*/
|
||||
public function write($links)
|
||||
{
|
||||
if (is_file($this->datastore) && !is_writeable($this->datastore)) {
|
||||
// The datastore exists but is not writeable
|
||||
throw new NotWritableDataStoreException($this->datastore);
|
||||
} elseif (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
|
||||
// The datastore does not exist and its parent directory is not writeable
|
||||
throw new NotWritableDataStoreException(dirname($this->datastore));
|
||||
}
|
||||
|
||||
$data = base64_encode(gzdeflate(serialize($links)));
|
||||
|
||||
if (empty($data)) {
|
||||
throw new InvalidWritableDataException();
|
||||
}
|
||||
|
||||
$data = self::$phpPrefix . $data . self::$phpSuffix;
|
||||
|
||||
$this->synchronized(function () use ($data) {
|
||||
if (!$this->checkDiskSpace($data)) {
|
||||
throw new NotEnoughSpaceException();
|
||||
}
|
||||
|
||||
file_put_contents(
|
||||
$this->datastore,
|
||||
$data
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper applying mutex to provided function.
|
||||
* If the lock can't be acquired (e.g. some shared hosting provider), we execute the function without mutex.
|
||||
*
|
||||
* @see https://github.com/shaarli/Shaarli/issues/1650
|
||||
*
|
||||
* @param callable $function
|
||||
*/
|
||||
protected function synchronized(callable $function): void
|
||||
{
|
||||
try {
|
||||
$this->mutex->synchronized($function);
|
||||
} catch (LockAcquireException $exception) {
|
||||
$function();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that there is enough disk space available to save the current data store.
|
||||
* We add an arbitrary margin of 500kB.
|
||||
*
|
||||
* @param string $data to be saved
|
||||
*
|
||||
* @return bool True if data can safely be saved
|
||||
*/
|
||||
public function checkDiskSpace(string $data): bool
|
||||
{
|
||||
if (function_exists('disk_free_space') === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return disk_free_space(dirname($this->datastore)) > (strlen($data) + 1024 * 500);
|
||||
}
|
||||
}
|
|
@ -1,115 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shaarli\Bookmark;
|
||||
|
||||
/**
|
||||
* Class BookmarkInitializer
|
||||
*
|
||||
* This class is used to initialized default bookmarks after a fresh install of Shaarli.
|
||||
* It should be only called if the datastore file does not exist(users might want to delete the default bookmarks).
|
||||
*
|
||||
* To prevent data corruption, it does not overwrite existing bookmarks,
|
||||
* even though there should not be any.
|
||||
*
|
||||
* We disable this because otherwise it creates indentation issues, and heredoc is not supported by PHP gettext.
|
||||
* @phpcs:disable Generic.Files.LineLength.TooLong
|
||||
*
|
||||
* @package Shaarli\Bookmark
|
||||
*/
|
||||
class BookmarkInitializer
|
||||
{
|
||||
/** @var BookmarkServiceInterface */
|
||||
protected $bookmarkService;
|
||||
|
||||
/**
|
||||
* BookmarkInitializer constructor.
|
||||
*
|
||||
* @param BookmarkServiceInterface $bookmarkService
|
||||
*/
|
||||
public function __construct(BookmarkServiceInterface $bookmarkService)
|
||||
{
|
||||
$this->bookmarkService = $bookmarkService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the data store with default bookmarks
|
||||
*/
|
||||
public function initialize(): void
|
||||
{
|
||||
$bookmark = new Bookmark();
|
||||
$bookmark->setTitle('Calm Jazz Music - YouTube ' . t('(private bookmark with thumbnail demo)'));
|
||||
$bookmark->setUrl('https://www.youtube.com/watch?v=DVEUcbPkb-c');
|
||||
$bookmark->setDescription(t(
|
||||
'Shaarli will automatically pick up the thumbnail for links to a variety of websites.
|
||||
|
||||
Explore your new Shaarli instance by trying out controls and menus.
|
||||
Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli.
|
||||
|
||||
Now you can edit or delete the default shaares.
|
||||
'
|
||||
));
|
||||
$bookmark->setTagsString('shaarli help thumbnail');
|
||||
$bookmark->setPrivate(true);
|
||||
$this->bookmarkService->add($bookmark, false);
|
||||
|
||||
$bookmark = new Bookmark();
|
||||
$bookmark->setTitle(t('Note: Shaare descriptions'));
|
||||
$bookmark->setDescription(t(
|
||||
'Adding a shaare without entering a URL creates a text-only "note" post such as this one.
|
||||
This note is private, so you are the only one able to see it while logged in.
|
||||
|
||||
You can use this to keep notes, post articles, code snippets, and much more.
|
||||
|
||||
The Markdown formatting setting allows you to format your notes and bookmark description:
|
||||
|
||||
### Title headings
|
||||
|
||||
#### Multiple headings levels
|
||||
* bullet lists
|
||||
* _italic_ text
|
||||
* **bold** text
|
||||
* ~~strike through~~ text
|
||||
* `code` blocks
|
||||
* images
|
||||
* [links](https://en.wikipedia.org/wiki/Markdown)
|
||||
|
||||
Markdown also supports tables:
|
||||
|
||||
| Name | Type | Color | Qty |
|
||||
| ------- | --------- | ------ | ----- |
|
||||
| Orange | Fruit | Orange | 126 |
|
||||
| Apple | Fruit | Any | 62 |
|
||||
| Lemon | Fruit | Yellow | 30 |
|
||||
| Carrot | Vegetable | Red | 14 |
|
||||
'
|
||||
));
|
||||
$bookmark->setTagsString('shaarli help');
|
||||
$bookmark->setPrivate(true);
|
||||
$this->bookmarkService->add($bookmark, false);
|
||||
|
||||
$bookmark = new Bookmark();
|
||||
$bookmark->setTitle(
|
||||
'Shaarli - ' . t('The personal, minimalist, super fast, database-free, bookmarking service')
|
||||
);
|
||||
$bookmark->setDescription(t(
|
||||
'Welcome to Shaarli!
|
||||
|
||||
Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately.
|
||||
You can add a description to your bookmarks, such as this one, and tag them.
|
||||
|
||||
Create a new shaare by clicking the `+Shaare` button, or using any of the recommended tools (browser extension, mobile app, bookmarklet, REST API, etc.).
|
||||
|
||||
You can easily retrieve your links, even with thousands of them, using the internal search engine, or search through tags (e.g. this Shaare is tagged with `shaarli` and `help`).
|
||||
Hashtags such as #shaarli #help are also supported.
|
||||
You can also filter the available [RSS feed](/feed/atom) and picture wall by tag or plaintext search.
|
||||
|
||||
We hope that you will enjoy using Shaarli, maintained with ❤️ by the community!
|
||||
Feel free to open [an issue](https://github.com/shaarli/Shaarli/issues) if you have a suggestion or encounter an issue.
|
||||
'
|
||||
));
|
||||
$bookmark->setTagsString('shaarli help');
|
||||
$this->bookmarkService->add($bookmark, false);
|
||||
}
|
||||
}
|
|
@ -1,189 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shaarli\Bookmark;
|
||||
|
||||
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
|
||||
use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
|
||||
|
||||
/**
|
||||
* Class BookmarksService
|
||||
*
|
||||
* This is the entry point to manipulate the bookmark DB.
|
||||
*
|
||||
* Regarding return types of a list of bookmarks, it can either be an array or an ArrayAccess implementation,
|
||||
* so until PHP 8.0 is the minimal supported version with union return types it cannot be explicitly added.
|
||||
*/
|
||||
interface BookmarkServiceInterface
|
||||
{
|
||||
/**
|
||||
* Find a bookmark by hash
|
||||
*
|
||||
* @param string $hash Bookmark's hash
|
||||
* @param string|null $privateKey Optional key used to access private links while logged out
|
||||
*
|
||||
* @return Bookmark
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function findByHash(string $hash, string $privateKey = null);
|
||||
|
||||
/**
|
||||
* @param $url
|
||||
*
|
||||
* @return Bookmark|null
|
||||
*/
|
||||
public function findByUrl(string $url): ?Bookmark;
|
||||
|
||||
/**
|
||||
* Search bookmarks
|
||||
*
|
||||
* @param array $request
|
||||
* @param ?string $visibility
|
||||
* @param bool $caseSensitive
|
||||
* @param bool $untaggedOnly
|
||||
* @param bool $ignoreSticky
|
||||
* @param array $pagination This array can contain the following keys for pagination: limit, offset.
|
||||
*
|
||||
* @return SearchResult
|
||||
*/
|
||||
public function search(
|
||||
array $request = [],
|
||||
string $visibility = null,
|
||||
bool $caseSensitive = false,
|
||||
bool $untaggedOnly = false,
|
||||
bool $ignoreSticky = false,
|
||||
array $pagination = []
|
||||
): SearchResult;
|
||||
|
||||
/**
|
||||
* Get a single bookmark by its ID.
|
||||
*
|
||||
* @param int $id Bookmark ID
|
||||
* @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
|
||||
* exception
|
||||
*
|
||||
* @return Bookmark
|
||||
*
|
||||
* @throws BookmarkNotFoundException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function get(int $id, string $visibility = null);
|
||||
|
||||
/**
|
||||
* Updates an existing bookmark (depending on its ID).
|
||||
*
|
||||
* @param Bookmark $bookmark
|
||||
* @param bool $save Writes to the datastore if set to true
|
||||
*
|
||||
* @return Bookmark Updated bookmark
|
||||
*
|
||||
* @throws BookmarkNotFoundException
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function set(Bookmark $bookmark, bool $save = true): Bookmark;
|
||||
|
||||
/**
|
||||
* Adds a new bookmark (the ID must be empty).
|
||||
*
|
||||
* @param Bookmark $bookmark
|
||||
* @param bool $save Writes to the datastore if set to true
|
||||
*
|
||||
* @return Bookmark new bookmark
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function add(Bookmark $bookmark, bool $save = true): Bookmark;
|
||||
|
||||
/**
|
||||
* Adds or updates a bookmark depending on its ID:
|
||||
* - a Bookmark without ID will be added
|
||||
* - a Bookmark with an existing ID will be updated
|
||||
*
|
||||
* @param Bookmark $bookmark
|
||||
* @param bool $save
|
||||
*
|
||||
* @return Bookmark
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark;
|
||||
|
||||
/**
|
||||
* Deletes a bookmark.
|
||||
*
|
||||
* @param Bookmark $bookmark
|
||||
* @param bool $save
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function remove(Bookmark $bookmark, bool $save = true): void;
|
||||
|
||||
/**
|
||||
* Get a single bookmark by its ID.
|
||||
*
|
||||
* @param int $id Bookmark ID
|
||||
* @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
|
||||
* exception
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function exists(int $id, string $visibility = null): bool;
|
||||
|
||||
/**
|
||||
* Return the number of available bookmarks for given visibility.
|
||||
*
|
||||
* @param ?string $visibility public|private|all
|
||||
*
|
||||
* @return int Number of bookmarks
|
||||
*/
|
||||
public function count(string $visibility = null): int;
|
||||
|
||||
/**
|
||||
* Write the datastore.
|
||||
*
|
||||
* @throws NotWritableDataStoreException
|
||||
*/
|
||||
public function save(): void;
|
||||
|
||||
/**
|
||||
* Returns the list tags appearing in the bookmarks with the given tags
|
||||
*
|
||||
* @param array|null $filteringTags tags selecting the bookmarks to consider
|
||||
* @param string|null $visibility process only all/private/public bookmarks
|
||||
*
|
||||
* @return array tag => bookmarksCount
|
||||
*/
|
||||
public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array;
|
||||
|
||||
/**
|
||||
* Return a list of bookmark matching provided period of time.
|
||||
* It also update directly previous and next date outside of given period found in the datastore.
|
||||
*
|
||||
* @param \DateTimeInterface $from Starting date.
|
||||
* @param \DateTimeInterface $to Ending date.
|
||||
* @param \DateTimeInterface|null $previous (by reference) updated with first created date found before $from.
|
||||
* @param \DateTimeInterface|null $next (by reference) updated with first created date found after $to.
|
||||
*
|
||||
* @return array List of bookmarks matching provided period of time.
|
||||
*/
|
||||
public function findByDate(
|
||||
\DateTimeInterface $from,
|
||||
\DateTimeInterface $to,
|
||||
?\DateTimeInterface &$previous,
|
||||
?\DateTimeInterface &$next
|
||||
): array;
|
||||
|
||||
/**
|
||||
* Returns the latest bookmark by creation date.
|
||||
*
|
||||
* @return Bookmark|null Found Bookmark or null if the datastore is empty.
|
||||
*/
|
||||
public function getLatest(): ?Bookmark;
|
||||
|
||||
/**
|
||||
* Creates the default database after a fresh install.
|
||||
*/
|
||||
public function initialize(): void;
|
||||
}
|
|
@ -1,253 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Shaarli\Bookmark\Bookmark;
|
||||
use Shaarli\Formatter\BookmarkDefaultFormatter;
|
||||
|
||||
/**
|
||||
* Extract title from an HTML document.
|
||||
*
|
||||
* @param string $html HTML content where to look for a title.
|
||||
*
|
||||
* @return bool|string Extracted title if found, false otherwise.
|
||||
*/
|
||||
function html_extract_title($html)
|
||||
{
|
||||
if (preg_match('!<title.*?>(.*?)</title>!is', $html, $matches)) {
|
||||
return trim(str_replace("\n", '', $matches[1]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract charset from HTTP header if it's defined.
|
||||
*
|
||||
* @param string $header HTTP header Content-Type line.
|
||||
*
|
||||
* @return bool|string Charset string if found (lowercase), false otherwise.
|
||||
*/
|
||||
function header_extract_charset($header)
|
||||
{
|
||||
preg_match('/charset=["\']?([^; "\']+)/i', $header, $match);
|
||||
if (! empty($match[1])) {
|
||||
return strtolower(trim($match[1]));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract charset HTML content (tag <meta charset>).
|
||||
*
|
||||
* @param string $html HTML content where to look for charset.
|
||||
*
|
||||
* @return bool|string Charset string if found, false otherwise.
|
||||
*/
|
||||
function html_extract_charset($html)
|
||||
{
|
||||
// Get encoding specified in HTML header.
|
||||
preg_match('#<meta .*charset=["\']?([^";\'>/]+)["\']? */?>#Usi', $html, $enc);
|
||||
if (!empty($enc[1])) {
|
||||
return strtolower($enc[1]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meta tag from HTML content in either:
|
||||
* - OpenGraph: <meta property="og:[tag]" ...>
|
||||
* - Meta tag: <meta name="[tag]" ...>
|
||||
*
|
||||
* @param string $tag Name of the tag to retrieve.
|
||||
* @param string $html HTML content where to look for charset.
|
||||
*
|
||||
* @return bool|string Charset string if found, false otherwise.
|
||||
*/
|
||||
function html_extract_tag($tag, $html)
|
||||
{
|
||||
$propertiesKey = ['property', 'name', 'itemprop'];
|
||||
$properties = implode('|', $propertiesKey);
|
||||
// We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"'
|
||||
$orCondition = '["\']?(?:og:)?' . $tag . '["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]';
|
||||
// Support quotes in double quoted content, and the other way around
|
||||
$content = 'content=(["\'])((?:(?!\1).)*)\1';
|
||||
// Try to retrieve OpenGraph tag.
|
||||
$ogRegex = '#<meta[^>]+(?:' . $properties . ')=(?:' . $orCondition . ')[^>]*' . $content . '.*?>#';
|
||||
// If the attributes are not in the order property => content (e.g. Github)
|
||||
// New regex to keep this readable... more or less.
|
||||
$ogRegexReverse = '#<meta[^>]+' . $content . '[^>]+(?:' . $properties . ')=(?:' . $orCondition . ').*?>#';
|
||||
|
||||
if (
|
||||
preg_match($ogRegex, $html, $matches) > 0
|
||||
|| preg_match($ogRegexReverse, $html, $matches) > 0
|
||||
) {
|
||||
return $matches[2];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* In a string, converts URLs to clickable bookmarks.
|
||||
*
|
||||
* @param string $text input string.
|
||||
*
|
||||
* @return string returns $text with all bookmarks converted to HTML bookmarks.
|
||||
*
|
||||
* @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
|
||||
*/
|
||||
function text2clickable($text)
|
||||
{
|
||||
$regex = '!(((?:https?|ftp|file)://|apt:|magnet:)\S+[a-z0-9\(\)]/?)!si';
|
||||
$format = function (array $match): string {
|
||||
return '<a href="' .
|
||||
str_replace(
|
||||
BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN,
|
||||
'',
|
||||
str_replace(BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE, '', $match[1])
|
||||
) .
|
||||
'">' . $match[1] . '</a>'
|
||||
;
|
||||
};
|
||||
|
||||
return preg_replace_callback($regex, $format, $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-link hashtags.
|
||||
*
|
||||
* @param string $description Given description.
|
||||
* @param string $indexUrl Root URL.
|
||||
*
|
||||
* @return string Description with auto-linked hashtags.
|
||||
*/
|
||||
function hashtag_autolink($description, $indexUrl = '')
|
||||
{
|
||||
$tokens = '(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN . ')' .
|
||||
'(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE . ')'
|
||||
;
|
||||
/*
|
||||
* To support unicode: http://stackoverflow.com/a/35498078/1484919
|
||||
* \p{Pc} - to match underscore
|
||||
* \p{N} - numeric character in any script
|
||||
* \p{L} - letter from any language
|
||||
* \p{Mn} - any non marking space (accents, umlauts, etc)
|
||||
*/
|
||||
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}' . $tokens . ']+)/mui';
|
||||
$format = function (array $match) use ($indexUrl): string {
|
||||
$cleanMatch = str_replace(
|
||||
BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN,
|
||||
'',
|
||||
str_replace(BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE, '', $match[2])
|
||||
);
|
||||
return $match[1] . '<a href="' . $indexUrl . './add-tag/' . $cleanMatch . '"' .
|
||||
' title="Hashtag ' . $cleanMatch . '">' .
|
||||
'#' . $match[2] .
|
||||
'</a>';
|
||||
};
|
||||
|
||||
return preg_replace_callback($regex, $format, $description);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function inserts where relevant so that multiple spaces are properly displayed in HTML
|
||||
* even in the absence of <pre> (This is used in description to keep text formatting).
|
||||
*
|
||||
* @param string $text input text.
|
||||
*
|
||||
* @return string formatted text.
|
||||
*/
|
||||
function space2nbsp($text)
|
||||
{
|
||||
return preg_replace('/(^| ) /m', '$1 ', $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Shaarli's description
|
||||
*
|
||||
* @param string $description shaare's description.
|
||||
* @param string $indexUrl URL to Shaarli's index.
|
||||
* @param bool $autolink Turn on/off automatic linkifications of URLs and hashtags
|
||||
*
|
||||
* @return string formatted description.
|
||||
*/
|
||||
function format_description($description, $indexUrl = '', $autolink = true)
|
||||
{
|
||||
if ($autolink) {
|
||||
$description = hashtag_autolink(text2clickable($description), $indexUrl);
|
||||
}
|
||||
|
||||
return nl2br(space2nbsp($description));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a small hash for a link.
|
||||
*
|
||||
* @param DateTime $date Link creation date.
|
||||
* @param int $id Link ID.
|
||||
*
|
||||
* @return string the small hash generated from link data.
|
||||
*/
|
||||
function link_small_hash($date, $id)
|
||||
{
|
||||
return smallHash($date->format(Bookmark::LINK_DATE_FORMAT) . $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether or not the link is an internal note.
|
||||
* Its URL starts by `?` because it's actually a permalink.
|
||||
*
|
||||
* @param string $linkUrl
|
||||
*
|
||||
* @return bool true if internal note, false otherwise.
|
||||
*/
|
||||
function is_note($linkUrl)
|
||||
{
|
||||
return isset($linkUrl[0]) && $linkUrl[0] === '?';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an array of tags from a given tag string, with provided separator.
|
||||
*
|
||||
* @param string|null $tags String containing a list of tags separated by $separator.
|
||||
* @param string $separator Shaarli's default: ' ' (whitespace)
|
||||
*
|
||||
* @return array List of tags
|
||||
*/
|
||||
function tags_str2array(?string $tags, string $separator): array
|
||||
{
|
||||
// For whitespaces, we use the special \s regex character
|
||||
$separator = str_replace([' ', '/'], ['\s', '\/'], $separator);
|
||||
|
||||
return preg_split('/\s*' . $separator . '+\s*/', trim($tags ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a tag string with provided separator from a list of tags.
|
||||
* Note that given array is clean up by tags_filter().
|
||||
*
|
||||
* @param array|null $tags List of tags
|
||||
* @param string $separator
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
function tags_array2str(?array $tags, string $separator): string
|
||||
{
|
||||
return implode($separator, tags_filter($tags, $separator));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean an array of tags: trim + remove empty entries
|
||||
*
|
||||
* @param array|null $tags List of tags
|
||||
* @param string $separator
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
function tags_filter(?array $tags, string $separator): array
|
||||
{
|
||||
$trimDefault = " \t\n\r\0\x0B";
|
||||
return array_values(array_filter(array_map(function (string $entry) use ($separator, $trimDefault): string {
|
||||
return trim($entry, $trimDefault . $separator);
|
||||
}, $tags ?? [])));
|
||||
}
|
|
@ -1,136 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shaarli\Bookmark;
|
||||
|
||||
/**
|
||||
* Read-only class used to represent search result, including pagination.
|
||||
*/
|
||||
class SearchResult
|
||||
{
|
||||
/** @var Bookmark[] List of result bookmarks with pagination applied */
|
||||
protected $bookmarks;
|
||||
|
||||
/** @var int number of Bookmarks found, with pagination applied */
|
||||
protected $resultCount;
|
||||
|
||||
/** @var int total number of result found */
|
||||
protected $totalCount;
|
||||
|
||||
/** @var int pagination: limit number of result bookmarks */
|
||||
protected $limit;
|
||||
|
||||
/** @var int pagination: offset to apply to complete result list */
|
||||
protected $offset;
|
||||
|
||||
public function __construct(array $bookmarks, int $totalCount, int $offset, ?int $limit)
|
||||
{
|
||||
$this->bookmarks = $bookmarks;
|
||||
$this->resultCount = count($bookmarks);
|
||||
$this->totalCount = $totalCount;
|
||||
$this->limit = $limit;
|
||||
$this->offset = $offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a SearchResult from provided full result set and pagination settings.
|
||||
*
|
||||
* @param Bookmark[] $bookmarks Full set of result which will be filtered
|
||||
* @param int $offset Start recording results from $offset
|
||||
* @param int|null $limit End recording results after $limit bookmarks is reached
|
||||
* @param bool $allowOutOfBounds Set to false to display the last page if the offset is out of bound,
|
||||
* return empty result set otherwise (default: false)
|
||||
*
|
||||
* @return SearchResult
|
||||
*/
|
||||
public static function getSearchResult(
|
||||
$bookmarks,
|
||||
int $offset = 0,
|
||||
?int $limit = null,
|
||||
bool $allowOutOfBounds = false
|
||||
): self {
|
||||
$totalCount = count($bookmarks);
|
||||
if (!$allowOutOfBounds && $offset > $totalCount) {
|
||||
$offset = $limit === null ? 0 : $limit * -1;
|
||||
}
|
||||
|
||||
if ($bookmarks instanceof BookmarkArray) {
|
||||
$buffer = [];
|
||||
foreach ($bookmarks as $key => $value) {
|
||||
$buffer[$key] = $value;
|
||||
}
|
||||
$bookmarks = $buffer;
|
||||
}
|
||||
|
||||
return new static(
|
||||
array_slice($bookmarks, $offset, $limit, true),
|
||||
$totalCount,
|
||||
$offset,
|
||||
$limit
|
||||
);
|
||||
}
|
||||
|
||||
/** @return Bookmark[] List of result bookmarks with pagination applied */
|
||||
public function getBookmarks(): array
|
||||
{
|
||||
return $this->bookmarks;
|
||||
}
|
||||
|
||||
/** @return int number of Bookmarks found, with pagination applied */
|
||||
public function getResultCount(): int
|
||||
{
|
||||
return $this->resultCount;
|
||||
}
|
||||
|
||||
/** @return int total number of result found */
|
||||
public function getTotalCount(): int
|
||||
{
|
||||
return $this->totalCount;
|
||||
}
|
||||
|
||||
/** @return int pagination: limit number of result bookmarks */
|
||||
public function getLimit(): ?int
|
||||
{
|
||||
return $this->limit;
|
||||
}
|
||||
|
||||
/** @return int pagination: offset to apply to complete result list */
|
||||
public function getOffset(): int
|
||||
{
|
||||
return $this->offset;
|
||||
}
|
||||
|
||||
/** @return int Current page of result set in complete results */
|
||||
public function getPage(): int
|
||||
{
|
||||
if (empty($this->limit)) {
|
||||
return $this->offset === 0 ? 1 : 2;
|
||||
}
|
||||
$base = $this->offset >= 0 ? $this->offset : $this->totalCount + $this->offset;
|
||||
|
||||
return (int) ceil($base / $this->limit) + 1;
|
||||
}
|
||||
|
||||
/** @return int Get the # of the last page */
|
||||
public function getLastPage(): int
|
||||
{
|
||||
if (empty($this->limit)) {
|
||||
return $this->offset === 0 ? 1 : 2;
|
||||
}
|
||||
|
||||
return (int) ceil($this->totalCount / $this->limit);
|
||||
}
|
||||
|
||||
/** @return bool Either the current page is the last one or not */
|
||||
public function isLastPage(): bool
|
||||
{
|
||||
return $this->getPage() === $this->getLastPage();
|
||||
}
|
||||
|
||||
/** @return bool Either the current page is the first one or not */
|
||||
public function isFirstPage(): bool
|
||||
{
|
||||
return $this->offset === 0;
|
||||
}
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Bookmark\Exception;
|
||||
|
||||
use Exception;
|
||||
|
||||
class BookmarkNotFoundException extends Exception
|
||||
{
|
||||
/**
|
||||
* LinkNotFoundException constructor.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->message = t('The link you are trying to reach does not exist or has been deleted.');
|
||||
}
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shaarli\Bookmark\Exception;
|
||||
|
||||
class DatastoreNotInitializedException extends \Exception
|
||||
{
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Bookmark\Exception;
|
||||
|
||||
class EmptyDataStoreException extends \Exception
|
||||
{
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Bookmark\Exception;
|
||||
|
||||
use Shaarli\Bookmark\Bookmark;
|
||||
|
||||
class InvalidBookmarkException extends \Exception
|
||||
{
|
||||
public function __construct($bookmark)
|
||||
{
|
||||
if ($bookmark instanceof Bookmark) {
|
||||
if ($bookmark->getCreated() instanceof \DateTime) {
|
||||
$created = $bookmark->getCreated()->format(\DateTime::ATOM);
|
||||
} elseif (empty($bookmark->getCreated())) {
|
||||
$created = '';
|
||||
} else {
|
||||
$created = 'Not a DateTime object';
|
||||
}
|
||||
$this->message = 'This bookmark is not valid' . PHP_EOL;
|
||||
$this->message .= ' - ID: ' . $bookmark->getId() . PHP_EOL;
|
||||
$this->message .= ' - Title: ' . $bookmark->getTitle() . PHP_EOL;
|
||||
$this->message .= ' - Url: ' . $bookmark->getUrl() . PHP_EOL;
|
||||
$this->message .= ' - ShortUrl: ' . $bookmark->getShortUrl() . PHP_EOL;
|
||||
$this->message .= ' - Created: ' . $created . PHP_EOL;
|
||||
} else {
|
||||
$this->message = 'The provided data is not a bookmark' . PHP_EOL;
|
||||
$this->message .= var_export($bookmark, true);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Bookmark\Exception;
|
||||
|
||||
class InvalidWritableDataException extends \Exception
|
||||
{
|
||||
/**
|
||||
* InvalidWritableDataException constructor.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->message = 'Couldn\'t generate bookmark data to store in the datastore. Skipping file writing.';
|
||||
}
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Bookmark\Exception;
|
||||
|
||||
class NotEnoughSpaceException extends \Exception
|
||||
{
|
||||
/**
|
||||
* NotEnoughSpaceException constructor.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->message = 'Not enough available disk space to save the datastore.';
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Bookmark\Exception;
|
||||
|
||||
class NotWritableDataStoreException extends \Exception
|
||||
{
|
||||
/**
|
||||
* NotReadableDataStore constructor.
|
||||
*
|
||||
* @param string $dataStore file path
|
||||
*/
|
||||
public function __construct($dataStore)
|
||||
{
|
||||
$this->message = 'Couldn\'t load data from the data store file "' . $dataStore . '". ' .
|
||||
'Your data might be corrupted, or your file isn\'t readable.';
|
||||
}
|
||||
}
|
|
@ -1,7 +1,5 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Config;
|
||||
|
||||
/**
|
||||
* Interface ConfigIO
|
||||
*
|
||||
|
@ -16,7 +14,7 @@ interface ConfigIO
|
|||
*
|
||||
* @return array All configuration in an array.
|
||||
*/
|
||||
public function read($filepath);
|
||||
function read($filepath);
|
||||
|
||||
/**
|
||||
* Write configuration.
|
||||
|
@ -24,12 +22,12 @@ public function read($filepath);
|
|||
* @param string $filepath Config file absolute path.
|
||||
* @param array $conf All configuration in an array.
|
||||
*/
|
||||
public function write($filepath, $conf);
|
||||
function write($filepath, $conf);
|
||||
|
||||
/**
|
||||
* Get config file extension according to config type.
|
||||
*
|
||||
* @return string Config file extension.
|
||||
*/
|
||||
public function getExtension();
|
||||
function getExtension();
|
||||
}
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
<?php
|
||||
namespace Shaarli\Config;
|
||||
|
||||
/**
|
||||
* Class ConfigJson (ConfigIO implementation)
|
||||
|
@ -11,7 +10,7 @@ class ConfigJson implements ConfigIO
|
|||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function read($filepath)
|
||||
function read($filepath)
|
||||
{
|
||||
if (! is_readable($filepath)) {
|
||||
return array();
|
||||
|
@ -19,21 +18,10 @@ public function read($filepath)
|
|||
$data = file_get_contents($filepath);
|
||||
$data = str_replace(self::getPhpHeaders(), '', $data);
|
||||
$data = str_replace(self::getPhpSuffix(), '', $data);
|
||||
$data = json_decode(trim($data), true);
|
||||
$data = json_decode($data, true);
|
||||
if ($data === null) {
|
||||
$errorCode = json_last_error();
|
||||
$error = sprintf(
|
||||
'An error occurred while parsing JSON configuration file (%s): error code #%d',
|
||||
$filepath,
|
||||
$errorCode
|
||||
);
|
||||
$error .= '<br>➜ <code>' . json_last_error_msg() .'</code>';
|
||||
if ($errorCode === JSON_ERROR_SYNTAX) {
|
||||
$error .= '<br>';
|
||||
$error .= 'Please check your JSON syntax (without PHP comment tags) using a JSON lint tool such as ';
|
||||
$error .= '<a href="http://jsonlint.com/">jsonlint.com</a>.';
|
||||
}
|
||||
throw new \Exception($error);
|
||||
$error = json_last_error();
|
||||
throw new Exception('An error occurred while parsing JSON file: error code #'. $error);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
@ -41,16 +29,16 @@ public function read($filepath)
|
|||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function write($filepath, $conf)
|
||||
function write($filepath, $conf)
|
||||
{
|
||||
// JSON_PRETTY_PRINT is available from PHP 5.4.
|
||||
$print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0;
|
||||
$data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix();
|
||||
if (empty($filepath) || !file_put_contents($filepath, $data)) {
|
||||
throw new \Shaarli\Exceptions\IOException(
|
||||
if (!file_put_contents($filepath, $data)) {
|
||||
throw new IOException(
|
||||
$filepath,
|
||||
t('Shaarli could not create the config file. '.
|
||||
'Please make sure Shaarli has the right to write in the folder is it installed in.')
|
||||
'Shaarli could not create the config file.
|
||||
Please make sure Shaarli has the right to write in the folder is it installed in.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -58,7 +46,7 @@ public function write($filepath, $conf)
|
|||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function getExtension()
|
||||
function getExtension()
|
||||
{
|
||||
return '.json.php';
|
||||
}
|
||||
|
@ -73,7 +61,7 @@ public function getExtension()
|
|||
*/
|
||||
public static function getPhpHeaders()
|
||||
{
|
||||
return '<?php /*';
|
||||
return '<?php /*'. PHP_EOL;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -85,6 +73,6 @@ public static function getPhpHeaders()
|
|||
*/
|
||||
public static function getPhpSuffix()
|
||||
{
|
||||
return '*/ ?>';
|
||||
return PHP_EOL . '*/ ?>';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,18 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Config;
|
||||
|
||||
use Shaarli\Config\Exception\MissingFieldConfigException;
|
||||
use Shaarli\Config\Exception\UnauthorizedConfigException;
|
||||
use Shaarli\Thumbnailer;
|
||||
// FIXME! Namespaces...
|
||||
require_once 'ConfigIO.php';
|
||||
require_once 'ConfigJson.php';
|
||||
require_once 'ConfigPhp.php';
|
||||
|
||||
/**
|
||||
* Class ConfigManager
|
||||
*
|
||||
* Manages all Shaarli's settings.
|
||||
* See the documentation for more information on settings:
|
||||
* - doc/md/Shaarli-configuration.md
|
||||
* - https://shaarli.readthedocs.io/en/master/Shaarli-configuration/#configuration
|
||||
* - doc/Shaarli-configuration.html
|
||||
* - https://github.com/shaarli/Shaarli/wiki/Shaarli-configuration
|
||||
*/
|
||||
class ConfigManager
|
||||
{
|
||||
|
@ -21,8 +20,6 @@ class ConfigManager
|
|||
*/
|
||||
protected static $NOT_FOUND = 'NOT_FOUND';
|
||||
|
||||
public static $DEFAULT_PLUGINS = ['qrcode'];
|
||||
|
||||
/**
|
||||
* @var string Config folder.
|
||||
*/
|
||||
|
@ -83,11 +80,7 @@ protected function initialize()
|
|||
*/
|
||||
protected function load()
|
||||
{
|
||||
try {
|
||||
$this->loadedConfig = $this->configIO->read($this->getConfigFileExt());
|
||||
} catch (\Exception $e) {
|
||||
die($e->getMessage());
|
||||
}
|
||||
$this->loadedConfig = $this->configIO->read($this->getConfigFileExt());
|
||||
$this->setDefaultValues();
|
||||
}
|
||||
|
||||
|
@ -125,16 +118,16 @@ public function get($setting, $default = '')
|
|||
* Supports nested settings with dot separated keys.
|
||||
*
|
||||
* @param string $setting Asked setting, keys separated with dots.
|
||||
* @param mixed $value Value to set.
|
||||
* @param string $value Value to set.
|
||||
* @param bool $write Write the new setting in the config file, default false.
|
||||
* @param bool $isLoggedIn User login state, default false.
|
||||
*
|
||||
* @throws \Exception Invalid
|
||||
* @throws Exception Invalid
|
||||
*/
|
||||
public function set($setting, $value, $write = false, $isLoggedIn = false)
|
||||
{
|
||||
if (empty($setting) || ! is_string($setting)) {
|
||||
throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting));
|
||||
throw new Exception('Invalid setting key parameter. String expected, got: '. gettype($setting));
|
||||
}
|
||||
|
||||
// During the ConfigIO transition, map legacy settings to the new ones.
|
||||
|
@ -149,33 +142,6 @@ public function set($setting, $value, $write = false, $isLoggedIn = false)
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a config element from the config file.
|
||||
*
|
||||
* @param string $setting Asked setting, keys separated with dots.
|
||||
* @param bool $write Write the new setting in the config file, default false.
|
||||
* @param bool $isLoggedIn User login state, default false.
|
||||
*
|
||||
* @throws \Exception Invalid
|
||||
*/
|
||||
public function remove($setting, $write = false, $isLoggedIn = false)
|
||||
{
|
||||
if (empty($setting) || ! is_string($setting)) {
|
||||
throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting));
|
||||
}
|
||||
|
||||
// During the ConfigIO transition, map legacy settings to the new ones.
|
||||
if ($this->configIO instanceof ConfigPhp && isset(ConfigPhp::$LEGACY_KEYS_MAPPING[$setting])) {
|
||||
$setting = ConfigPhp::$LEGACY_KEYS_MAPPING[$setting];
|
||||
}
|
||||
|
||||
$settings = explode('.', $setting);
|
||||
self::removeConfig($settings, $this->loadedConfig);
|
||||
if ($write) {
|
||||
$this->write($isLoggedIn);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a settings exists.
|
||||
*
|
||||
|
@ -209,12 +175,12 @@ public function exists($setting)
|
|||
*
|
||||
* @throws MissingFieldConfigException: a mandatory field has not been provided in $conf.
|
||||
* @throws UnauthorizedConfigException: user is not authorize to change configuration.
|
||||
* @throws \Shaarli\Exceptions\IOException: an error occurred while writing the new config file.
|
||||
* @throws IOException: an error occurred while writing the new config file.
|
||||
*/
|
||||
public function write($isLoggedIn)
|
||||
{
|
||||
// These fields are required in configuration.
|
||||
$mandatoryFields = [
|
||||
$mandatoryFields = array(
|
||||
'credentials.login',
|
||||
'credentials.hash',
|
||||
'credentials.salt',
|
||||
|
@ -223,7 +189,8 @@ public function write($isLoggedIn)
|
|||
'general.title',
|
||||
'general.header_link',
|
||||
'privacy.default_private_links',
|
||||
];
|
||||
'redirector.url',
|
||||
);
|
||||
|
||||
// Only logged in user can alter config.
|
||||
if (is_file($this->getConfigFileExt()) && !$isLoggedIn) {
|
||||
|
@ -300,7 +267,7 @@ protected static function getConfig($settings, $conf)
|
|||
*
|
||||
* @param array $settings Ordered array which contains keys to find.
|
||||
* @param mixed $value
|
||||
* @param array $conf Loaded settings, then sub-array.
|
||||
* @param array $conf Loaded settings, then sub-array.
|
||||
*
|
||||
* @return mixed Found setting or NOT_FOUND flag.
|
||||
*/
|
||||
|
@ -317,27 +284,6 @@ protected static function setConfig($settings, $value, &$conf)
|
|||
$conf[$setting] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive function which find asked setting in the loaded config and deletes it.
|
||||
*
|
||||
* @param array $settings Ordered array which contains keys to find.
|
||||
* @param array $conf Loaded settings, then sub-array.
|
||||
*
|
||||
* @return mixed Found setting or NOT_FOUND flag.
|
||||
*/
|
||||
protected static function removeConfig($settings, &$conf)
|
||||
{
|
||||
if (!is_array($settings) || count($settings) == 0) {
|
||||
return self::$NOT_FOUND;
|
||||
}
|
||||
|
||||
$setting = array_shift($settings);
|
||||
if (count($settings) > 0) {
|
||||
return self::removeConfig($settings, $conf[$setting]);
|
||||
}
|
||||
unset($conf[$setting]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a bunch of default values allowing Shaarli to start without a config file.
|
||||
*/
|
||||
|
@ -350,7 +296,6 @@ protected function setDefaultValues()
|
|||
$this->setEmpty('resource.updates', 'data/updates.txt');
|
||||
$this->setEmpty('resource.log', 'data/log.txt');
|
||||
$this->setEmpty('resource.update_check', 'data/lastupdatecheck.txt');
|
||||
$this->setEmpty('resource.history', 'data/history.php');
|
||||
$this->setEmpty('resource.raintpl_tpl', 'tpl/');
|
||||
$this->setEmpty('resource.theme', 'default');
|
||||
$this->setEmpty('resource.raintpl_tmp', 'tmp/');
|
||||
|
@ -361,41 +306,29 @@ protected function setDefaultValues()
|
|||
$this->setEmpty('security.ban_duration', 1800);
|
||||
$this->setEmpty('security.session_protection_disabled', false);
|
||||
$this->setEmpty('security.open_shaarli', false);
|
||||
$this->setEmpty('security.allowed_protocols', ['ftp', 'ftps', 'magnet']);
|
||||
|
||||
$this->setEmpty('general.header_link', '/');
|
||||
$this->setEmpty('general.header_link', '?');
|
||||
$this->setEmpty('general.links_per_page', 20);
|
||||
$this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
|
||||
$this->setEmpty('general.default_note_title', 'Note: ');
|
||||
$this->setEmpty('general.retrieve_description', true);
|
||||
$this->setEmpty('general.enable_async_metadata', true);
|
||||
$this->setEmpty('general.tags_separator', ' ');
|
||||
$this->setEmpty('general.enabled_plugins', array('qrcode'));
|
||||
|
||||
$this->setEmpty('updates.check_updates', true);
|
||||
$this->setEmpty('updates.check_updates_branch', 'latest');
|
||||
$this->setEmpty('updates.check_updates', false);
|
||||
$this->setEmpty('updates.check_updates_branch', 'stable');
|
||||
$this->setEmpty('updates.check_updates_interval', 86400);
|
||||
|
||||
$this->setEmpty('feed.rss_permalinks', true);
|
||||
$this->setEmpty('feed.show_atom', true);
|
||||
$this->setEmpty('feed.show_atom', false);
|
||||
|
||||
$this->setEmpty('privacy.default_private_links', false);
|
||||
$this->setEmpty('privacy.hide_public_links', false);
|
||||
$this->setEmpty('privacy.force_login', false);
|
||||
$this->setEmpty('privacy.hide_timestamps', false);
|
||||
// default state of the 'remember me' checkbox of the login form
|
||||
$this->setEmpty('privacy.remember_user_default', true);
|
||||
|
||||
$this->setEmpty('thumbnails.mode', Thumbnailer::MODE_ALL);
|
||||
$this->setEmpty('thumbnails.width', '125');
|
||||
$this->setEmpty('thumbnails.height', '90');
|
||||
$this->setEmpty('thumbnail.enable_thumbnails', true);
|
||||
$this->setEmpty('thumbnail.enable_localcache', true);
|
||||
|
||||
$this->setEmpty('translation.language', 'auto');
|
||||
$this->setEmpty('translation.mode', 'php');
|
||||
$this->setEmpty('translation.extensions', []);
|
||||
$this->setEmpty('redirector.url', '');
|
||||
$this->setEmpty('redirector.encode_url', true);
|
||||
|
||||
$this->setEmpty('plugins', []);
|
||||
|
||||
$this->setEmpty('formatter', 'markdown');
|
||||
$this->setEmpty('plugins', array());
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -427,3 +360,36 @@ public function setConfigIO($configIO)
|
|||
$this->configIO = $configIO;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception used if a mandatory field is missing in given configuration.
|
||||
*/
|
||||
class MissingFieldConfigException extends Exception
|
||||
{
|
||||
public $field;
|
||||
|
||||
/**
|
||||
* Construct exception.
|
||||
*
|
||||
* @param string $field field name missing.
|
||||
*/
|
||||
public function __construct($field)
|
||||
{
|
||||
$this->field = $field;
|
||||
$this->message = 'Configuration value is required for '. $this->field;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception used if an unauthorized attempt to edit configuration has been made.
|
||||
*/
|
||||
class UnauthorizedConfigException extends Exception
|
||||
{
|
||||
/**
|
||||
* Construct exception.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->message = 'You are not authorized to alter config.';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Config;
|
||||
|
||||
/**
|
||||
* Class ConfigPhp (ConfigIO implementation)
|
||||
*
|
||||
|
@ -13,7 +11,7 @@ class ConfigPhp implements ConfigIO
|
|||
/**
|
||||
* @var array List of config key without group.
|
||||
*/
|
||||
public static $ROOT_KEYS = [
|
||||
public static $ROOT_KEYS = array(
|
||||
'login',
|
||||
'hash',
|
||||
'salt',
|
||||
|
@ -23,16 +21,16 @@ class ConfigPhp implements ConfigIO
|
|||
'redirector',
|
||||
'disablesessionprotection',
|
||||
'privateLinkByDefault',
|
||||
];
|
||||
);
|
||||
|
||||
/**
|
||||
* Map legacy config keys with the new ones.
|
||||
* If ConfigPhp is used, getting <newkey> will actually look for <legacykey>.
|
||||
* The updater will use this array to transform keys when switching to JSON.
|
||||
* The Updater will use this array to transform keys when switching to JSON.
|
||||
*
|
||||
* @var array current key => legacy key.
|
||||
*/
|
||||
public static $LEGACY_KEYS_MAPPING = [
|
||||
public static $LEGACY_KEYS_MAPPING = array(
|
||||
'credentials.login' => 'login',
|
||||
'credentials.hash' => 'hash',
|
||||
'credentials.salt' => 'salt',
|
||||
|
@ -69,34 +67,34 @@ class ConfigPhp implements ConfigIO
|
|||
'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS',
|
||||
'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS',
|
||||
'security.open_shaarli' => 'config.OPEN_SHAARLI',
|
||||
];
|
||||
);
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function read($filepath)
|
||||
function read($filepath)
|
||||
{
|
||||
if (! file_exists($filepath) || ! is_readable($filepath)) {
|
||||
return [];
|
||||
return array();
|
||||
}
|
||||
|
||||
include $filepath;
|
||||
|
||||
$out = [];
|
||||
$out = array();
|
||||
foreach (self::$ROOT_KEYS as $key) {
|
||||
$out[$key] = isset($GLOBALS[$key]) ? $GLOBALS[$key] : '';
|
||||
$out[$key] = $GLOBALS[$key];
|
||||
}
|
||||
$out['config'] = isset($GLOBALS['config']) ? $GLOBALS['config'] : [];
|
||||
$out['plugins'] = isset($GLOBALS['plugins']) ? $GLOBALS['plugins'] : [];
|
||||
$out['config'] = $GLOBALS['config'];
|
||||
$out['plugins'] = !empty($GLOBALS['plugins']) ? $GLOBALS['plugins'] : array();
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function write($filepath, $conf)
|
||||
function write($filepath, $conf)
|
||||
{
|
||||
$configStr = '<?php ' . PHP_EOL;
|
||||
$configStr = '<?php '. PHP_EOL;
|
||||
foreach (self::$ROOT_KEYS as $key) {
|
||||
if (isset($conf[$key])) {
|
||||
$configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL;
|
||||
|
@ -105,31 +103,22 @@ public function write($filepath, $conf)
|
|||
|
||||
// Store all $conf['config']
|
||||
foreach ($conf['config'] as $key => $value) {
|
||||
$configStr .= '$GLOBALS[\'config\'][\''
|
||||
. $key
|
||||
. '\'] = '
|
||||
. var_export($conf['config'][$key], true) . ';'
|
||||
. PHP_EOL;
|
||||
$configStr .= '$GLOBALS[\'config\'][\''. $key .'\'] = '.var_export($conf['config'][$key], true).';'. PHP_EOL;
|
||||
}
|
||||
|
||||
if (isset($conf['plugins'])) {
|
||||
foreach ($conf['plugins'] as $key => $value) {
|
||||
$configStr .= '$GLOBALS[\'plugins\'][\''
|
||||
. $key
|
||||
. '\'] = '
|
||||
. var_export($conf['plugins'][$key], true) . ';'
|
||||
. PHP_EOL;
|
||||
$configStr .= '$GLOBALS[\'plugins\'][\''. $key .'\'] = '.var_export($conf['plugins'][$key], true).';'. PHP_EOL;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!file_put_contents($filepath, $configStr)
|
||||
if (!file_put_contents($filepath, $configStr)
|
||||
|| strcmp(file_get_contents($filepath), $configStr) != 0
|
||||
) {
|
||||
throw new \Shaarli\Exceptions\IOException(
|
||||
throw new IOException(
|
||||
$filepath,
|
||||
t('Shaarli could not create the config file. ' .
|
||||
'Please make sure Shaarli has the right to write in the folder is it installed in.')
|
||||
'Shaarli could not create the config file.
|
||||
Please make sure Shaarli has the right to write in the folder is it installed in.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -137,7 +126,7 @@ public function write($filepath, $conf)
|
|||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function getExtension()
|
||||
function getExtension()
|
||||
{
|
||||
return '.php';
|
||||
}
|
||||
|
|
|
@ -1,8 +1,4 @@
|
|||
<?php
|
||||
|
||||
use Shaarli\Config\Exception\PluginConfigOrderException;
|
||||
use Shaarli\Plugin\PluginManager;
|
||||
|
||||
/**
|
||||
* Plugin configuration helper functions.
|
||||
*
|
||||
|
@ -20,27 +16,13 @@
|
|||
*/
|
||||
function save_plugin_config($formData)
|
||||
{
|
||||
// We can only save existing plugins
|
||||
$directories = str_replace(
|
||||
PluginManager::$PLUGINS_PATH . '/',
|
||||
'',
|
||||
glob(PluginManager::$PLUGINS_PATH . '/*')
|
||||
);
|
||||
$formData = array_filter(
|
||||
$formData,
|
||||
function ($value, string $key) use ($directories) {
|
||||
return startsWith($key, 'order') || in_array($key, $directories);
|
||||
},
|
||||
ARRAY_FILTER_USE_BOTH
|
||||
);
|
||||
|
||||
// Make sure there are no duplicates in orders.
|
||||
if (!validate_plugin_order($formData)) {
|
||||
throw new PluginConfigOrderException();
|
||||
}
|
||||
|
||||
$plugins = [];
|
||||
$newEnabledPlugins = [];
|
||||
$plugins = array();
|
||||
$newEnabledPlugins = array();
|
||||
foreach ($formData as $key => $data) {
|
||||
if (startsWith($key, 'order')) {
|
||||
continue;
|
||||
|
@ -49,7 +31,8 @@ function ($value, string $key) use ($directories) {
|
|||
// If there is no order, it means a disabled plugin has been enabled.
|
||||
if (isset($formData['order_' . $key])) {
|
||||
$plugins[(int) $formData['order_' . $key]] = $key;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
$newEnabledPlugins[] = $key;
|
||||
}
|
||||
}
|
||||
|
@ -62,7 +45,7 @@ function ($value, string $key) use ($directories) {
|
|||
throw new PluginConfigOrderException();
|
||||
}
|
||||
|
||||
$finalPlugins = [];
|
||||
$finalPlugins = array();
|
||||
// Make plugins order continuous.
|
||||
foreach ($plugins as $plugin) {
|
||||
$finalPlugins[] = $plugin;
|
||||
|
@ -81,10 +64,10 @@ function ($value, string $key) use ($directories) {
|
|||
*/
|
||||
function validate_plugin_order($formData)
|
||||
{
|
||||
$orders = [];
|
||||
$orders = array();
|
||||
foreach ($formData as $key => $value) {
|
||||
// No duplicate order allowed.
|
||||
if (in_array($value, $orders, true)) {
|
||||
if (in_array($value, $orders)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -125,3 +108,17 @@ function load_plugin_parameter_values($plugins, $conf)
|
|||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception used if an error occur while saving plugin configuration.
|
||||
*/
|
||||
class PluginConfigOrderException extends Exception
|
||||
{
|
||||
/**
|
||||
* Construct exception.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->message = 'An error occurred while trying to save plugins loading order.';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Config\Exception;
|
||||
|
||||
/**
|
||||
* Exception used if a mandatory field is missing in given configuration.
|
||||
*/
|
||||
class MissingFieldConfigException extends \Exception
|
||||
{
|
||||
public $field;
|
||||
|
||||
/**
|
||||
* Construct exception.
|
||||
*
|
||||
* @param string $field field name missing.
|
||||
*/
|
||||
public function __construct($field)
|
||||
{
|
||||
$this->field = $field;
|
||||
$this->message = sprintf(t('Configuration value is required for %s'), $this->field);
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Config\Exception;
|
||||
|
||||
/**
|
||||
* Exception used if an error occur while saving plugin configuration.
|
||||
*/
|
||||
class PluginConfigOrderException extends \Exception
|
||||
{
|
||||
/**
|
||||
* Construct exception.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->message = t('An error occurred while trying to save plugins loading order.');
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Config\Exception;
|
||||
|
||||
/**
|
||||
* Exception used if an unauthorized attempt to edit configuration has been made.
|
||||
*/
|
||||
class UnauthorizedConfigException extends \Exception
|
||||
{
|
||||
/**
|
||||
* Construct exception.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->message = t('You are not authorized to alter config.');
|
||||
}
|
||||
}
|
|
@ -1,176 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shaarli\Container;
|
||||
|
||||
use malkusch\lock\mutex\FlockMutex;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shaarli\Bookmark\BookmarkFileService;
|
||||
use Shaarli\Bookmark\BookmarkServiceInterface;
|
||||
use Shaarli\Config\ConfigManager;
|
||||
use Shaarli\Feed\FeedBuilder;
|
||||
use Shaarli\Formatter\FormatterFactory;
|
||||
use Shaarli\Front\Controller\Visitor\ErrorController;
|
||||
use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
|
||||
use Shaarli\History;
|
||||
use Shaarli\Http\HttpAccess;
|
||||
use Shaarli\Http\MetadataRetriever;
|
||||
use Shaarli\Netscape\NetscapeBookmarkUtils;
|
||||
use Shaarli\Plugin\PluginManager;
|
||||
use Shaarli\Render\PageBuilder;
|
||||
use Shaarli\Render\PageCacheManager;
|
||||
use Shaarli\Security\CookieManager;
|
||||
use Shaarli\Security\LoginManager;
|
||||
use Shaarli\Security\SessionManager;
|
||||
use Shaarli\Thumbnailer;
|
||||
use Shaarli\Updater\Updater;
|
||||
use Shaarli\Updater\UpdaterUtils;
|
||||
|
||||
/**
|
||||
* Class ContainerBuilder
|
||||
*
|
||||
* Helper used to build a Slim container instance with Shaarli's object dependencies.
|
||||
* Note that most injected objects MUST be added as closures, to let the container instantiate
|
||||
* only the objects it requires during the execution.
|
||||
*
|
||||
* @package Container
|
||||
*/
|
||||
class ContainerBuilder
|
||||
{
|
||||
/** @var ConfigManager */
|
||||
protected $conf;
|
||||
|
||||
/** @var SessionManager */
|
||||
protected $session;
|
||||
|
||||
/** @var CookieManager */
|
||||
protected $cookieManager;
|
||||
|
||||
/** @var LoginManager */
|
||||
protected $login;
|
||||
|
||||
/** @var PluginManager */
|
||||
protected $pluginManager;
|
||||
|
||||
/** @var LoggerInterface */
|
||||
protected $logger;
|
||||
|
||||
/** @var string|null */
|
||||
protected $basePath = null;
|
||||
|
||||
public function __construct(
|
||||
ConfigManager $conf,
|
||||
SessionManager $session,
|
||||
CookieManager $cookieManager,
|
||||
LoginManager $login,
|
||||
PluginManager $pluginManager,
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
$this->conf = $conf;
|
||||
$this->session = $session;
|
||||
$this->login = $login;
|
||||
$this->cookieManager = $cookieManager;
|
||||
$this->pluginManager = $pluginManager;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
public function build(): ShaarliContainer
|
||||
{
|
||||
$container = new ShaarliContainer();
|
||||
|
||||
$container['conf'] = $this->conf;
|
||||
$container['sessionManager'] = $this->session;
|
||||
$container['cookieManager'] = $this->cookieManager;
|
||||
$container['loginManager'] = $this->login;
|
||||
$container['pluginManager'] = $this->pluginManager;
|
||||
$container['logger'] = $this->logger;
|
||||
$container['basePath'] = $this->basePath;
|
||||
|
||||
|
||||
$container['history'] = function (ShaarliContainer $container): History {
|
||||
return new History($container->conf->get('resource.history'));
|
||||
};
|
||||
|
||||
$container['bookmarkService'] = function (ShaarliContainer $container): BookmarkServiceInterface {
|
||||
return new BookmarkFileService(
|
||||
$container->conf,
|
||||
$container->pluginManager,
|
||||
$container->history,
|
||||
new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2),
|
||||
$container->loginManager->isLoggedIn()
|
||||
);
|
||||
};
|
||||
|
||||
$container['metadataRetriever'] = function (ShaarliContainer $container): MetadataRetriever {
|
||||
return new MetadataRetriever($container->conf, $container->httpAccess);
|
||||
};
|
||||
|
||||
$container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder {
|
||||
return new PageBuilder(
|
||||
$container->conf,
|
||||
$container->sessionManager->getSession(),
|
||||
$container->logger,
|
||||
$container->bookmarkService,
|
||||
$container->sessionManager->generateToken(),
|
||||
$container->loginManager->isLoggedIn()
|
||||
);
|
||||
};
|
||||
|
||||
$container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory {
|
||||
return new FormatterFactory(
|
||||
$container->conf,
|
||||
$container->loginManager->isLoggedIn()
|
||||
);
|
||||
};
|
||||
|
||||
$container['pageCacheManager'] = function (ShaarliContainer $container): PageCacheManager {
|
||||
return new PageCacheManager(
|
||||
$container->conf->get('resource.page_cache'),
|
||||
$container->loginManager->isLoggedIn()
|
||||
);
|
||||
};
|
||||
|
||||
$container['feedBuilder'] = function (ShaarliContainer $container): FeedBuilder {
|
||||
return new FeedBuilder(
|
||||
$container->bookmarkService,
|
||||
$container->formatterFactory->getFormatter(),
|
||||
$container->environment,
|
||||
$container->loginManager->isLoggedIn()
|
||||
);
|
||||
};
|
||||
|
||||
$container['thumbnailer'] = function (ShaarliContainer $container): Thumbnailer {
|
||||
return new Thumbnailer($container->conf);
|
||||
};
|
||||
|
||||
$container['httpAccess'] = function (): HttpAccess {
|
||||
return new HttpAccess();
|
||||
};
|
||||
|
||||
$container['netscapeBookmarkUtils'] = function (ShaarliContainer $container): NetscapeBookmarkUtils {
|
||||
return new NetscapeBookmarkUtils($container->bookmarkService, $container->conf, $container->history);
|
||||
};
|
||||
|
||||
$container['updater'] = function (ShaarliContainer $container): Updater {
|
||||
return new Updater(
|
||||
UpdaterUtils::readUpdatesFile($container->conf->get('resource.updates')),
|
||||
$container->bookmarkService,
|
||||
$container->conf,
|
||||
$container->loginManager->isLoggedIn()
|
||||
);
|
||||
};
|
||||
|
||||
$container['notFoundHandler'] = function (ShaarliContainer $container): ErrorNotFoundController {
|
||||
return new ErrorNotFoundController($container);
|
||||
};
|
||||
$container['errorHandler'] = function (ShaarliContainer $container): ErrorController {
|
||||
return new ErrorController($container);
|
||||
};
|
||||
$container['phpErrorHandler'] = function (ShaarliContainer $container): ErrorController {
|
||||
return new ErrorController($container);
|
||||
};
|
||||
|
||||
return $container;
|
||||
}
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shaarli\Container;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shaarli\Bookmark\BookmarkServiceInterface;
|
||||
use Shaarli\Config\ConfigManager;
|
||||
use Shaarli\Feed\FeedBuilder;
|
||||
use Shaarli\Formatter\FormatterFactory;
|
||||
use Shaarli\History;
|
||||
use Shaarli\Http\HttpAccess;
|
||||
use Shaarli\Http\MetadataRetriever;
|
||||
use Shaarli\Netscape\NetscapeBookmarkUtils;
|
||||
use Shaarli\Plugin\PluginManager;
|
||||
use Shaarli\Render\PageBuilder;
|
||||
use Shaarli\Render\PageCacheManager;
|
||||
use Shaarli\Security\CookieManager;
|
||||
use Shaarli\Security\LoginManager;
|
||||
use Shaarli\Security\SessionManager;
|
||||
use Shaarli\Thumbnailer;
|
||||
use Shaarli\Updater\Updater;
|
||||
use Slim\Container;
|
||||
|
||||
/**
|
||||
* Extension of Slim container to document the injected objects.
|
||||
*
|
||||
* @property string $basePath Shaarli's instance base path (e.g. `/shaarli/`)
|
||||
* @property BookmarkServiceInterface $bookmarkService
|
||||
* @property CookieManager $cookieManager
|
||||
* @property ConfigManager $conf
|
||||
* @property mixed[] $environment $_SERVER automatically injected by Slim
|
||||
* @property callable $errorHandler Overrides default Slim exception display
|
||||
* @property FeedBuilder $feedBuilder
|
||||
* @property FormatterFactory $formatterFactory
|
||||
* @property History $history
|
||||
* @property HttpAccess $httpAccess
|
||||
* @property LoginManager $loginManager
|
||||
* @property LoggerInterface $logger
|
||||
* @property MetadataRetriever $metadataRetriever
|
||||
* @property NetscapeBookmarkUtils $netscapeBookmarkUtils
|
||||
* @property callable $notFoundHandler Overrides default Slim exception display
|
||||
* @property PageBuilder $pageBuilder
|
||||
* @property PageCacheManager $pageCacheManager
|
||||
* @property callable $phpErrorHandler Overrides default Slim PHP error display
|
||||
* @property PluginManager $pluginManager
|
||||
* @property SessionManager $sessionManager
|
||||
* @property Thumbnailer $thumbnailer
|
||||
* @property Updater $updater
|
||||
*/
|
||||
class ShaarliContainer extends Container
|
||||
{
|
||||
}
|
|
@ -1,81 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shaarli\Feed;
|
||||
|
||||
use DatePeriod;
|
||||
|
||||
/**
|
||||
* Simple cache system, mainly for the RSS/ATOM feeds
|
||||
*/
|
||||
class CachedPage
|
||||
{
|
||||
/** Directory containing page caches */
|
||||
protected $cacheDir;
|
||||
|
||||
/** Should this URL be cached (boolean)? */
|
||||
protected $shouldBeCached;
|
||||
|
||||
/** Name of the cache file for this URL */
|
||||
protected $filename;
|
||||
|
||||
/** @var DatePeriod|null Optionally specify a period of time for cache validity */
|
||||
protected $validityPeriod;
|
||||
|
||||
/**
|
||||
* Creates a new CachedPage
|
||||
*
|
||||
* @param string $cacheDir page cache directory
|
||||
* @param string $url page URL
|
||||
* @param bool $shouldBeCached whether this page needs to be cached
|
||||
* @param ?DatePeriod $validityPeriod Optionally specify a time limit on requested cache
|
||||
*/
|
||||
public function __construct($cacheDir, $url, $shouldBeCached, ?DatePeriod $validityPeriod)
|
||||
{
|
||||
// TODO: check write access to the cache directory
|
||||
$this->cacheDir = $cacheDir;
|
||||
$this->filename = $this->cacheDir . '/' . sha1($url) . '.cache';
|
||||
$this->shouldBeCached = $shouldBeCached;
|
||||
$this->validityPeriod = $validityPeriod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached version of a page, if it exists and should be cached
|
||||
*
|
||||
* @return string a cached version of the page if it exists, null otherwise
|
||||
*/
|
||||
public function cachedVersion()
|
||||
{
|
||||
if (!$this->shouldBeCached) {
|
||||
return null;
|
||||
}
|
||||
if (!is_file($this->filename)) {
|
||||
return null;
|
||||
}
|
||||
if ($this->validityPeriod !== null) {
|
||||
$cacheDate = \DateTime::createFromFormat('U', (string) filemtime($this->filename));
|
||||
if (
|
||||
$cacheDate < $this->validityPeriod->getStartDate()
|
||||
|| $cacheDate > $this->validityPeriod->getEndDate()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return file_get_contents($this->filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a page in the cache
|
||||
*
|
||||
* @param string $pageContent XML content to cache
|
||||
*/
|
||||
public function cache($pageContent)
|
||||
{
|
||||
if (!$this->shouldBeCached) {
|
||||
return;
|
||||
}
|
||||
file_put_contents($this->filename, $pageContent);
|
||||
}
|
||||
}
|
|
@ -1,286 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Feed;
|
||||
|
||||
use DateTime;
|
||||
use Shaarli\Bookmark\Bookmark;
|
||||
use Shaarli\Bookmark\BookmarkServiceInterface;
|
||||
use Shaarli\Formatter\BookmarkFormatter;
|
||||
|
||||
/**
|
||||
* FeedBuilder class.
|
||||
*
|
||||
* Used to build ATOM and RSS feeds data.
|
||||
*/
|
||||
class FeedBuilder
|
||||
{
|
||||
/**
|
||||
* @var string Constant: RSS feed type.
|
||||
*/
|
||||
public static $FEED_RSS = 'rss';
|
||||
|
||||
/**
|
||||
* @var string Constant: ATOM feed type.
|
||||
*/
|
||||
public static $FEED_ATOM = 'atom';
|
||||
|
||||
/**
|
||||
* @var string Default language if the locale isn't set.
|
||||
*/
|
||||
public static $DEFAULT_LANGUAGE = 'en-en';
|
||||
|
||||
/**
|
||||
* @var int Number of bookmarks to display in a feed by default.
|
||||
*/
|
||||
public static $DEFAULT_NB_LINKS = 50;
|
||||
|
||||
/**
|
||||
* @var BookmarkServiceInterface instance.
|
||||
*/
|
||||
protected $linkDB;
|
||||
|
||||
/**
|
||||
* @var BookmarkFormatter instance.
|
||||
*/
|
||||
protected $formatter;
|
||||
|
||||
/** @var mixed[] $_SERVER */
|
||||
protected $serverInfo;
|
||||
|
||||
/**
|
||||
* @var boolean True if the user is currently logged in, false otherwise.
|
||||
*/
|
||||
protected $isLoggedIn;
|
||||
|
||||
/**
|
||||
* @var boolean Use permalinks instead of direct bookmarks if true.
|
||||
*/
|
||||
protected $usePermalinks;
|
||||
|
||||
/**
|
||||
* @var boolean true to hide dates in feeds.
|
||||
*/
|
||||
protected $hideDates;
|
||||
|
||||
/**
|
||||
* @var string server locale.
|
||||
*/
|
||||
protected $locale;
|
||||
/**
|
||||
* @var DateTime Latest item date.
|
||||
*/
|
||||
protected $latestDate;
|
||||
|
||||
/**
|
||||
* Feed constructor.
|
||||
*
|
||||
* @param BookmarkServiceInterface $linkDB LinkDB instance.
|
||||
* @param BookmarkFormatter $formatter instance.
|
||||
* @param array $serverInfo $_SERVER.
|
||||
* @param boolean $isLoggedIn True if the user is currently logged in, false otherwise.
|
||||
*/
|
||||
public function __construct($linkDB, $formatter, $serverInfo, $isLoggedIn)
|
||||
{
|
||||
$this->linkDB = $linkDB;
|
||||
$this->formatter = $formatter;
|
||||
$this->serverInfo = $serverInfo;
|
||||
$this->isLoggedIn = $isLoggedIn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build data for feed templates.
|
||||
*
|
||||
* @param string $feedType Type of feed (RSS/ATOM).
|
||||
* @param array $userInput $_GET.
|
||||
*
|
||||
* @return array Formatted data for feeds templates.
|
||||
*/
|
||||
public function buildData(string $feedType, ?array $userInput)
|
||||
{
|
||||
// Search for untagged bookmarks
|
||||
if (isset($this->userInput['searchtags']) && empty($userInput['searchtags'])) {
|
||||
$userInput['searchtags'] = false;
|
||||
}
|
||||
|
||||
$limit = $this->getLimit($userInput);
|
||||
|
||||
// Optionally filter the results:
|
||||
$searchResult = $this->linkDB->search($userInput ?? [], null, false, false, true, ['limit' => $limit]);
|
||||
|
||||
$pageaddr = escape(index_url($this->serverInfo));
|
||||
$this->formatter->addContextData('index_url', $pageaddr);
|
||||
$links = [];
|
||||
foreach ($searchResult->getBookmarks() as $key => $bookmark) {
|
||||
$links[$key] = $this->buildItem($feedType, $bookmark, $pageaddr);
|
||||
}
|
||||
|
||||
$data['language'] = $this->getTypeLanguage($feedType);
|
||||
$data['last_update'] = $this->getLatestDateFormatted($feedType);
|
||||
$data['show_dates'] = !$this->hideDates || $this->isLoggedIn;
|
||||
// Remove leading path from REQUEST_URI (already contained in $pageaddr).
|
||||
$requestUri = preg_replace('#(.*?/)(feed.*)#', '$2', escape($this->serverInfo['REQUEST_URI']));
|
||||
$data['self_link'] = $pageaddr . $requestUri;
|
||||
$data['index_url'] = $pageaddr;
|
||||
$data['usepermalinks'] = $this->usePermalinks === true;
|
||||
$data['links'] = $links;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this to true to use permalinks instead of direct bookmarks.
|
||||
*
|
||||
* @param boolean $usePermalinks true to force permalinks.
|
||||
*/
|
||||
public function setUsePermalinks($usePermalinks)
|
||||
{
|
||||
$this->usePermalinks = $usePermalinks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this to true to hide timestamps in feeds.
|
||||
*
|
||||
* @param boolean $hideDates true to enable.
|
||||
*/
|
||||
public function setHideDates($hideDates)
|
||||
{
|
||||
$this->hideDates = $hideDates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the locale. Used to show feed language.
|
||||
*
|
||||
* @param string $locale The locale (eg. 'fr_FR.UTF8').
|
||||
*/
|
||||
public function setLocale($locale)
|
||||
{
|
||||
$this->locale = strtolower($locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a feed item (one per shaare).
|
||||
*
|
||||
* @param string $feedType Type of feed (RSS/ATOM).
|
||||
* @param Bookmark $link Single link array extracted from LinkDB.
|
||||
* @param string $pageaddr Index URL.
|
||||
*
|
||||
* @return array Link array with feed attributes.
|
||||
*/
|
||||
protected function buildItem(string $feedType, $link, $pageaddr)
|
||||
{
|
||||
$data = $this->formatter->format($link);
|
||||
$data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl'];
|
||||
if ($this->usePermalinks === true) {
|
||||
$permalink = '<a href="' . $data['url'] . '" title="' . t('Direct link') . '">' . t('Direct link') . '</a>';
|
||||
} else {
|
||||
$permalink = '<a href="' . $data['guid'] . '" title="' . t('Permalink') . '">' . t('Permalink') . '</a>';
|
||||
}
|
||||
$data['description'] .= PHP_EOL . PHP_EOL . '<br>— ' . $permalink;
|
||||
|
||||
$data['pub_iso_date'] = $this->getIsoDate($feedType, $data['created']);
|
||||
|
||||
// atom:entry elements MUST contain exactly one atom:updated element.
|
||||
if (!empty($link->getUpdated())) {
|
||||
$data['up_iso_date'] = $this->getIsoDate($feedType, $data['updated'], DateTime::ATOM);
|
||||
} else {
|
||||
$data['up_iso_date'] = $this->getIsoDate($feedType, $data['created'], DateTime::ATOM);
|
||||
}
|
||||
|
||||
// Save the more recent item.
|
||||
if (empty($this->latestDate) || $this->latestDate < $data['created']) {
|
||||
$this->latestDate = $data['created'];
|
||||
}
|
||||
if (!empty($data['updated']) && $this->latestDate < $data['updated']) {
|
||||
$this->latestDate = $data['updated'];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the language according to the feed type, based on the locale:
|
||||
*
|
||||
* - RSS format: en-us (default: 'en-en').
|
||||
* - ATOM format: fr (default: 'en').
|
||||
*
|
||||
* @param string $feedType Type of feed (RSS/ATOM).
|
||||
*
|
||||
* @return string The language.
|
||||
*/
|
||||
protected function getTypeLanguage(string $feedType)
|
||||
{
|
||||
// Use the locale do define the language, if available.
|
||||
if (!empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) {
|
||||
$length = ($feedType === self::$FEED_RSS) ? 5 : 2;
|
||||
return str_replace('_', '-', substr($this->locale, 0, $length));
|
||||
}
|
||||
return ($feedType === self::$FEED_RSS) ? 'en-en' : 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the latest item date found according to the feed type.
|
||||
*
|
||||
* Return an empty string if invalid DateTime is passed.
|
||||
*
|
||||
* @param string $feedType Type of feed (RSS/ATOM).
|
||||
*
|
||||
* @return string Formatted date.
|
||||
*/
|
||||
protected function getLatestDateFormatted(string $feedType)
|
||||
{
|
||||
if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$type = ($feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM;
|
||||
return $this->latestDate->format($type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ISO date from DateTime according to feed type.
|
||||
*
|
||||
* @param string $feedType Type of feed (RSS/ATOM).
|
||||
* @param DateTime $date Date to format.
|
||||
* @param string|bool $format Force format.
|
||||
*
|
||||
* @return string Formatted date.
|
||||
*/
|
||||
protected function getIsoDate(string $feedType, DateTime $date, $format = false)
|
||||
{
|
||||
if ($format !== false) {
|
||||
return $date->format($format);
|
||||
}
|
||||
if ($feedType == self::$FEED_RSS) {
|
||||
return $date->format(DateTime::RSS);
|
||||
}
|
||||
return $date->format(DateTime::ATOM);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of link to display according to 'nb' user input parameter.
|
||||
*
|
||||
* If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS.
|
||||
* If 'nb' is set to 'all', display all filtered bookmarks (max parameter).
|
||||
*
|
||||
* @param array $userInput $_GET.
|
||||
*
|
||||
* @return int number of bookmarks to display.
|
||||
*/
|
||||
protected function getLimit(?array $userInput)
|
||||
{
|
||||
if (empty($userInput['nb'])) {
|
||||
return self::$DEFAULT_NB_LINKS;
|
||||
}
|
||||
|
||||
if ($userInput['nb'] == 'all') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$intNb = intval($userInput['nb']);
|
||||
if (!is_int($intNb) || $intNb == 0) {
|
||||
return self::$DEFAULT_NB_LINKS;
|
||||
}
|
||||
|
||||
return $intNb;
|
||||
}
|
||||
}
|
|
@ -1,229 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Formatter;
|
||||
|
||||
use Shaarli\Bookmark\Bookmark;
|
||||
|
||||
/**
|
||||
* Class BookmarkDefaultFormatter
|
||||
*
|
||||
* Default bookmark formatter.
|
||||
* Escape values for HTML display and automatically add link to URL and hashtags.
|
||||
*
|
||||
* @package Shaarli\Formatter
|
||||
*/
|
||||
class BookmarkDefaultFormatter extends BookmarkFormatter
|
||||
{
|
||||
public const SEARCH_HIGHLIGHT_OPEN = 'SHAARLI_O_HIGHLIGHT';
|
||||
public const SEARCH_HIGHLIGHT_CLOSE = 'SHAARLI_C_HIGHLIGHT';
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected function formatTitle($bookmark)
|
||||
{
|
||||
return escape($bookmark->getTitle());
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected function formatTitleHtml($bookmark)
|
||||
{
|
||||
$title = $this->tokenizeSearchHighlightField(
|
||||
$bookmark->getTitle() ?? '',
|
||||
$bookmark->getAdditionalContentEntry('search_highlight')['title'] ?? []
|
||||
);
|
||||
|
||||
return $this->replaceTokens(escape($title));
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected function formatDescription($bookmark)
|
||||
{
|
||||
$indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
|
||||
$description = $this->tokenizeSearchHighlightField(
|
||||
$bookmark->getDescription() ?? '',
|
||||
$bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
|
||||
);
|
||||
$description = format_description(
|
||||
escape($description),
|
||||
$indexUrl,
|
||||
$this->conf->get('formatter_settings.autolink', true)
|
||||
);
|
||||
|
||||
return $this->replaceTokens($description);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected function formatTagList($bookmark)
|
||||
{
|
||||
return escape(parent::formatTagList($bookmark));
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected function formatTagListHtml($bookmark)
|
||||
{
|
||||
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
|
||||
if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) {
|
||||
return $this->formatTagList($bookmark);
|
||||
}
|
||||
|
||||
$tags = $this->tokenizeSearchHighlightField(
|
||||
$bookmark->getTagsString($tagsSeparator),
|
||||
$bookmark->getAdditionalContentEntry('search_highlight')['tags']
|
||||
);
|
||||
$tags = $this->filterTagList(tags_str2array($tags, $tagsSeparator));
|
||||
$tags = escape($tags);
|
||||
$tags = $this->replaceTokensArray($tags);
|
||||
|
||||
return $tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected function formatTagString($bookmark)
|
||||
{
|
||||
return implode($this->conf->get('general.tags_separator'), $this->formatTagList($bookmark));
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected function formatUrl($bookmark)
|
||||
{
|
||||
if ($bookmark->isNote() && isset($this->contextData['index_url'])) {
|
||||
return rtrim($this->contextData['index_url'], '/') . '/' . escape(ltrim($bookmark->getUrl(), '/'));
|
||||
}
|
||||
|
||||
return escape($bookmark->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected function formatRealUrl($bookmark)
|
||||
{
|
||||
if ($bookmark->isNote()) {
|
||||
if (isset($this->contextData['index_url'])) {
|
||||
$prefix = rtrim($this->contextData['index_url'], '/') . '/';
|
||||
}
|
||||
|
||||
if (isset($this->contextData['base_path'])) {
|
||||
$prefix = rtrim($this->contextData['base_path'], '/') . '/';
|
||||
}
|
||||
|
||||
return escape($prefix ?? '') . escape(ltrim($bookmark->getUrl() ?? '', '/'));
|
||||
}
|
||||
|
||||
return escape($bookmark->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected function formatUrlHtml($bookmark)
|
||||
{
|
||||
$url = $this->tokenizeSearchHighlightField(
|
||||
$bookmark->getUrl() ?? '',
|
||||
$bookmark->getAdditionalContentEntry('search_highlight')['url'] ?? []
|
||||
);
|
||||
|
||||
return $this->replaceTokens(escape($url));
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected function formatThumbnail($bookmark)
|
||||
{
|
||||
return escape($bookmark->getThumbnail());
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function formatAdditionalContent(Bookmark $bookmark): array
|
||||
{
|
||||
$additionalContent = parent::formatAdditionalContent($bookmark);
|
||||
|
||||
unset($additionalContent['search_highlight']);
|
||||
|
||||
return $additionalContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert search highlight token in provided field content based on a list of search result positions
|
||||
*
|
||||
* @param string $fieldContent
|
||||
* @param array|null $positions List of of search results with 'start' and 'end' positions.
|
||||
*
|
||||
* @return string Updated $fieldContent.
|
||||
*/
|
||||
protected function tokenizeSearchHighlightField(string $fieldContent, ?array $positions): string
|
||||
{
|
||||
if (empty($positions)) {
|
||||
return $fieldContent;
|
||||
}
|
||||
|
||||
$insertedTokens = 0;
|
||||
$tokenLength = strlen(static::SEARCH_HIGHLIGHT_OPEN);
|
||||
foreach ($positions as $position) {
|
||||
$position = [
|
||||
'start' => $position['start'] + ($insertedTokens * $tokenLength),
|
||||
'end' => $position['end'] + ($insertedTokens * $tokenLength),
|
||||
];
|
||||
|
||||
$content = mb_substr($fieldContent, 0, $position['start']);
|
||||
$content .= static::SEARCH_HIGHLIGHT_OPEN;
|
||||
$content .= mb_substr($fieldContent, $position['start'], $position['end'] - $position['start']);
|
||||
$content .= static::SEARCH_HIGHLIGHT_CLOSE;
|
||||
$content .= mb_substr($fieldContent, $position['end']);
|
||||
|
||||
$fieldContent = $content;
|
||||
|
||||
$insertedTokens += 2;
|
||||
}
|
||||
|
||||
return $fieldContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace search highlight tokens with HTML highlighted span.
|
||||
*
|
||||
* @param string $fieldContent
|
||||
*
|
||||
* @return string updated content.
|
||||
*/
|
||||
protected function replaceTokens(string $fieldContent): string
|
||||
{
|
||||
return str_replace(
|
||||
[static::SEARCH_HIGHLIGHT_OPEN, static::SEARCH_HIGHLIGHT_CLOSE],
|
||||
['<span class="search-highlight">', '</span>'],
|
||||
$fieldContent
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply replaceTokens to an array of content strings.
|
||||
*
|
||||
* @param string[] $fieldContents
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function replaceTokensArray(array $fieldContents): array
|
||||
{
|
||||
foreach ($fieldContents as &$entry) {
|
||||
$entry = $this->replaceTokens($entry);
|
||||
}
|
||||
|
||||
return $fieldContents;
|
||||
}
|
||||
}
|
|
@ -1,390 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Formatter;
|
||||
|
||||
use DateTimeInterface;
|
||||
use Shaarli\Bookmark\Bookmark;
|
||||
use Shaarli\Config\ConfigManager;
|
||||
|
||||
/**
|
||||
* Class BookmarkFormatter
|
||||
*
|
||||
* Abstract class processing all bookmark attributes through methods designed to be overridden.
|
||||
*
|
||||
* List of available formatted fields:
|
||||
* - id ID
|
||||
* - shorturl Unique identifier, used in permalinks
|
||||
* - url URL, can be altered in some way, e.g. passing through an HTTP reverse proxy
|
||||
* - real_url (legacy) same as `url`
|
||||
* - url_html URL to be displayed in HTML content (it can contain HTML tags)
|
||||
* - title Title
|
||||
* - title_html Title to be displayed in HTML content (it can contain HTML tags)
|
||||
* - description Description content. It most likely contains HTML tags
|
||||
* - thumbnail Thumbnail: path to local cache file, false if there is none, null if hasn't been retrieved
|
||||
* - taglist List of tags (array)
|
||||
* - taglist_urlencoded List of tags (array) URL encoded: it must be used to create a link to a URL containing a tag
|
||||
* - taglist_html List of tags (array) to be displayed in HTML content (it can contain HTML tags)
|
||||
* - tags Tags separated by a single whitespace
|
||||
* - tags_urlencoded Tags separated by a single whitespace, URL encoded: must be used to create a link
|
||||
* - sticky Is sticky (bool)
|
||||
* - private Is private (bool)
|
||||
* - class Additional CSS class
|
||||
* - created Creation DateTime
|
||||
* - updated Last edit DateTime
|
||||
* - timestamp Creation timestamp
|
||||
* - updated_timestamp Last edit timestamp
|
||||
*
|
||||
* @package Shaarli\Formatter
|
||||
*/
|
||||
abstract class BookmarkFormatter
|
||||
{
|
||||
/**
|
||||
* @var ConfigManager
|
||||
*/
|
||||
protected $conf;
|
||||
|
||||
/** @var bool */
|
||||
protected $isLoggedIn;
|
||||
|
||||
/**
|
||||
* @var array Additional parameters than can be used for specific formatting
|
||||
* e.g. index_url for Feed formatting
|
||||
*/
|
||||
protected $contextData = [];
|
||||
|
||||
/**
|
||||
* LinkDefaultFormatter constructor.
|
||||
* @param ConfigManager $conf
|
||||
*/
|
||||
public function __construct(ConfigManager $conf, bool $isLoggedIn)
|
||||
{
|
||||
$this->conf = $conf;
|
||||
$this->isLoggedIn = $isLoggedIn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Bookmark into an array usable by templates and plugins.
|
||||
*
|
||||
* All Bookmark attributes are formatted through a format method
|
||||
* that can be overridden in a formatter extending this class.
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return array formatted representation of a Bookmark
|
||||
*/
|
||||
public function format($bookmark)
|
||||
{
|
||||
$out['id'] = $this->formatId($bookmark);
|
||||
$out['shorturl'] = $this->formatShortUrl($bookmark);
|
||||
$out['url'] = $this->formatUrl($bookmark);
|
||||
$out['real_url'] = $this->formatRealUrl($bookmark);
|
||||
$out['url_html'] = $this->formatUrlHtml($bookmark);
|
||||
$out['title'] = $this->formatTitle($bookmark);
|
||||
$out['title_html'] = $this->formatTitleHtml($bookmark);
|
||||
$out['description'] = $this->formatDescription($bookmark);
|
||||
$out['thumbnail'] = $this->formatThumbnail($bookmark);
|
||||
$out['taglist'] = $this->formatTagList($bookmark);
|
||||
$out['taglist_urlencoded'] = $this->formatTagListUrlEncoded($bookmark);
|
||||
$out['taglist_html'] = $this->formatTagListHtml($bookmark);
|
||||
$out['tags'] = $this->formatTagString($bookmark);
|
||||
$out['tags_urlencoded'] = $this->formatTagStringUrlEncoded($bookmark);
|
||||
$out['sticky'] = $bookmark->isSticky();
|
||||
$out['private'] = $bookmark->isPrivate();
|
||||
$out['class'] = $this->formatClass($bookmark);
|
||||
$out['created'] = $this->formatCreated($bookmark);
|
||||
$out['updated'] = $this->formatUpdated($bookmark);
|
||||
$out['timestamp'] = $this->formatCreatedTimestamp($bookmark);
|
||||
$out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark);
|
||||
$out['additional_content'] = $this->formatAdditionalContent($bookmark);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add additional data available to formatters.
|
||||
* This is used for example to add `index_url` in description's links.
|
||||
*
|
||||
* @param string $key Context data key
|
||||
* @param string $value Context data value
|
||||
*/
|
||||
public function addContextData($key, $value)
|
||||
{
|
||||
$this->contextData[$key] = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ID
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return int formatted ID
|
||||
*/
|
||||
protected function formatId($bookmark)
|
||||
{
|
||||
return $bookmark->getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ShortUrl
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return string formatted ShortUrl
|
||||
*/
|
||||
protected function formatShortUrl($bookmark)
|
||||
{
|
||||
return $bookmark->getShortUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Url
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return string formatted Url
|
||||
*/
|
||||
protected function formatUrl($bookmark)
|
||||
{
|
||||
return $bookmark->getUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format RealUrl
|
||||
* Legacy: identical to Url
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return string formatted RealUrl
|
||||
*/
|
||||
protected function formatRealUrl($bookmark)
|
||||
{
|
||||
return $this->formatUrl($bookmark);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Url Html: to be displayed in HTML content, it can contains HTML tags.
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return string formatted Url HTML
|
||||
*/
|
||||
protected function formatUrlHtml($bookmark)
|
||||
{
|
||||
return $this->formatUrl($bookmark);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Title
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return string formatted Title
|
||||
*/
|
||||
protected function formatTitle($bookmark)
|
||||
{
|
||||
return $bookmark->getTitle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Title HTML: to be displayed in HTML content, it can contains HTML tags.
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return string formatted Title
|
||||
*/
|
||||
protected function formatTitleHtml($bookmark)
|
||||
{
|
||||
return $bookmark->getTitle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Description
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return string formatted Description
|
||||
*/
|
||||
protected function formatDescription($bookmark)
|
||||
{
|
||||
return $bookmark->getDescription();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Thumbnail
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return string formatted Thumbnail
|
||||
*/
|
||||
protected function formatThumbnail($bookmark)
|
||||
{
|
||||
return $bookmark->getThumbnail();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Tags
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return array formatted Tags
|
||||
*/
|
||||
protected function formatTagList($bookmark)
|
||||
{
|
||||
return $this->filterTagList($bookmark->getTags());
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Url Encoded Tags
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return array formatted Tags
|
||||
*/
|
||||
protected function formatTagListUrlEncoded($bookmark)
|
||||
{
|
||||
return array_map('urlencode', $this->filterTagList($bookmark->getTags()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Tags HTML: to be displayed in HTML content, it can contains HTML tags.
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return array formatted Tags
|
||||
*/
|
||||
protected function formatTagListHtml($bookmark)
|
||||
{
|
||||
return $this->formatTagList($bookmark);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format TagString
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return string formatted TagString
|
||||
*/
|
||||
protected function formatTagString($bookmark)
|
||||
{
|
||||
return implode($this->conf->get('general.tags_separator', ' '), $this->formatTagList($bookmark));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format TagString
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return string formatted TagString
|
||||
*/
|
||||
protected function formatTagStringUrlEncoded($bookmark)
|
||||
{
|
||||
return implode(' ', $this->formatTagListUrlEncoded($bookmark));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Class
|
||||
* Used to add specific CSS class for a link
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return string formatted Class
|
||||
*/
|
||||
protected function formatClass($bookmark)
|
||||
{
|
||||
return $bookmark->isPrivate() ? 'private' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Created
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return DateTimeInterface instance
|
||||
*/
|
||||
protected function formatCreated(Bookmark $bookmark)
|
||||
{
|
||||
return $bookmark->getCreated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Updated
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return DateTimeInterface instance
|
||||
*/
|
||||
protected function formatUpdated(Bookmark $bookmark)
|
||||
{
|
||||
return $bookmark->getUpdated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format CreatedTimestamp
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return int formatted CreatedTimestamp
|
||||
*/
|
||||
protected function formatCreatedTimestamp(Bookmark $bookmark)
|
||||
{
|
||||
if (! empty($bookmark->getCreated())) {
|
||||
return $bookmark->getCreated()->getTimestamp();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format UpdatedTimestamp
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return int formatted UpdatedTimestamp
|
||||
*/
|
||||
protected function formatUpdatedTimestamp(Bookmark $bookmark)
|
||||
{
|
||||
if (! empty($bookmark->getUpdated())) {
|
||||
return $bookmark->getUpdated()->getTimestamp();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bookmark's additional content
|
||||
*
|
||||
* @param Bookmark $bookmark instance
|
||||
*
|
||||
* @return mixed[]
|
||||
*/
|
||||
protected function formatAdditionalContent(Bookmark $bookmark): array
|
||||
{
|
||||
return $bookmark->getAdditionalContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tag list, e.g. remove private tags if the user is not logged in.
|
||||
* TODO: this method is called multiple time to format tags, the result should be cached.
|
||||
*
|
||||
* @param array $tags
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function filterTagList(array $tags): array
|
||||
{
|
||||
if ($this->isLoggedIn === true) {
|
||||
return $tags;
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($tags as $tag) {
|
||||
if (strpos($tag, '.') === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$out[] = $tag;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Formatter;
|
||||
|
||||
use Shaarli\Config\ConfigManager;
|
||||
use Shaarli\Formatter\Parsedown\ShaarliParsedownExtra;
|
||||
|
||||
/**
|
||||
* Class BookmarkMarkdownExtraFormatter
|
||||
*
|
||||
* Format bookmark description into MarkdownExtra format.
|
||||
*
|
||||
* @see https://michelf.ca/projects/php-markdown/extra/
|
||||
*
|
||||
* @package Shaarli\Formatter
|
||||
*/
|
||||
class BookmarkMarkdownExtraFormatter extends BookmarkMarkdownFormatter
|
||||
{
|
||||
public function __construct(ConfigManager $conf, bool $isLoggedIn)
|
||||
{
|
||||
parent::__construct($conf, $isLoggedIn);
|
||||
$this->parsedown = new ShaarliParsedownExtra();
|
||||
}
|
||||
}
|
|
@ -1,221 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Formatter;
|
||||
|
||||
use Shaarli\Config\ConfigManager;
|
||||
use Shaarli\Formatter\Parsedown\ShaarliParsedown;
|
||||
|
||||
/**
|
||||
* Class BookmarkMarkdownFormatter
|
||||
*
|
||||
* Format bookmark description into Markdown format.
|
||||
*
|
||||
* @package Shaarli\Formatter
|
||||
*/
|
||||
class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
|
||||
{
|
||||
/**
|
||||
* When this tag is present in a bookmark, its description should not be processed with Markdown
|
||||
*/
|
||||
public const NO_MD_TAG = 'nomarkdown';
|
||||
|
||||
/** @var \Parsedown instance */
|
||||
protected $parsedown;
|
||||
|
||||
/** @var bool used to escape HTML in Markdown or not.
|
||||
* It MUST be set to true for shared instance as HTML content can
|
||||
* introduce XSS vulnerabilities.
|
||||
*/
|
||||
protected $escape;
|
||||
|
||||
/**
|
||||
* @var array List of allowed protocols for links inside bookmark's description.
|
||||
*/
|
||||
protected $allowedProtocols;
|
||||
|
||||
/**
|
||||
* LinkMarkdownFormatter constructor.
|
||||
*
|
||||
* @param ConfigManager $conf instance
|
||||
* @param bool $isLoggedIn
|
||||
*/
|
||||
public function __construct(ConfigManager $conf, bool $isLoggedIn)
|
||||
{
|
||||
parent::__construct($conf, $isLoggedIn);
|
||||
|
||||
$this->parsedown = new ShaarliParsedown();
|
||||
$this->escape = $conf->get('security.markdown_escape', true);
|
||||
$this->allowedProtocols = $conf->get('security.allowed_protocols', []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
public function formatDescription($bookmark)
|
||||
{
|
||||
if (in_array(self::NO_MD_TAG, $bookmark->getTags())) {
|
||||
return parent::formatDescription($bookmark);
|
||||
}
|
||||
|
||||
$processedDescription = $this->tokenizeSearchHighlightField(
|
||||
$bookmark->getDescription() ?? '',
|
||||
$bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
|
||||
);
|
||||
$processedDescription = $this->filterProtocols($processedDescription);
|
||||
$processedDescription = $this->formatHashTags($processedDescription);
|
||||
$processedDescription = $this->reverseEscapedHtml($processedDescription);
|
||||
$processedDescription = $this->parsedown
|
||||
->setMarkupEscaped($this->escape)
|
||||
->setBreaksEnabled(true)
|
||||
->text($processedDescription);
|
||||
$processedDescription = $this->sanitizeHtml($processedDescription);
|
||||
$processedDescription = $this->replaceTokens($processedDescription);
|
||||
|
||||
if (!empty($processedDescription)) {
|
||||
$processedDescription = '<div class="markdown">' . $processedDescription . '</div>';
|
||||
}
|
||||
|
||||
return $processedDescription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the NO markdown tag if it is present
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
protected function formatTagList($bookmark)
|
||||
{
|
||||
$out = parent::formatTagList($bookmark);
|
||||
if ($this->isLoggedIn === false && ($pos = array_search(self::NO_MD_TAG, $out)) !== false) {
|
||||
unset($out[$pos]);
|
||||
return array_values($out);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace not whitelisted protocols with http:// in given description.
|
||||
* Also adds `index_url` to relative links if it's specified
|
||||
*
|
||||
* @param string $description input description text.
|
||||
*
|
||||
* @return string $description without malicious link.
|
||||
*/
|
||||
protected function filterProtocols($description)
|
||||
{
|
||||
$allowedProtocols = $this->allowedProtocols;
|
||||
$indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
|
||||
|
||||
return preg_replace_callback(
|
||||
'#]\((.*?)\)#is',
|
||||
function ($match) use ($allowedProtocols, $indexUrl) {
|
||||
$link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : '';
|
||||
$link .= whitelist_protocols($match[1], $allowedProtocols);
|
||||
return '](' . $link . ')';
|
||||
},
|
||||
$description
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace hashtag in Markdown links format
|
||||
* E.g. `#hashtag` becomes `[#hashtag](./add-tag/hashtag)`
|
||||
* It includes the index URL if specified.
|
||||
*
|
||||
* @param string $description
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function formatHashTags($description)
|
||||
{
|
||||
$indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
|
||||
$tokens = '(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN . ')' .
|
||||
'(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE . ')'
|
||||
;
|
||||
|
||||
/*
|
||||
* To support unicode: http://stackoverflow.com/a/35498078/1484919
|
||||
* \p{Pc} - to match underscore
|
||||
* \p{N} - numeric character in any script
|
||||
* \p{L} - letter from any language
|
||||
* \p{Mn} - any non marking space (accents, umlauts, etc)
|
||||
*/
|
||||
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}' . $tokens . ']+)/mui';
|
||||
$replacement = function (array $match) use ($indexUrl): string {
|
||||
$cleanMatch = str_replace(
|
||||
BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN,
|
||||
'',
|
||||
str_replace(BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE, '', $match[2])
|
||||
);
|
||||
return $match[1] . '[#' . $match[2] . '](' . $indexUrl . './add-tag/' . $cleanMatch . ')';
|
||||
};
|
||||
|
||||
$descriptionLines = explode(PHP_EOL, $description);
|
||||
$descriptionOut = '';
|
||||
$codeBlockOn = false;
|
||||
$lineCount = 0;
|
||||
|
||||
foreach ($descriptionLines as $descriptionLine) {
|
||||
// Detect line of code: starting with 4 spaces,
|
||||
// except lists which can start with +/*/- or `2.` after spaces.
|
||||
$codeLineOn = preg_match('/^ +(?=[^\+\*\-])(?=(?!\d\.).)/', $descriptionLine) > 0;
|
||||
// Detect and toggle block of code
|
||||
if (!$codeBlockOn) {
|
||||
$codeBlockOn = preg_match('/^```/', $descriptionLine) > 0;
|
||||
} elseif (preg_match('/^```/', $descriptionLine) > 0) {
|
||||
$codeBlockOn = false;
|
||||
}
|
||||
|
||||
if (!$codeBlockOn && !$codeLineOn) {
|
||||
$descriptionLine = preg_replace_callback($regex, $replacement, $descriptionLine);
|
||||
}
|
||||
|
||||
$descriptionOut .= $descriptionLine;
|
||||
if ($lineCount++ < count($descriptionLines) - 1) {
|
||||
$descriptionOut .= PHP_EOL;
|
||||
}
|
||||
}
|
||||
|
||||
return $descriptionOut;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove dangerous HTML tags (tags, iframe, etc.).
|
||||
* Doesn't affect <code> content (already escaped by Parsedown).
|
||||
*
|
||||
* @param string $description input description text.
|
||||
*
|
||||
* @return string given string escaped.
|
||||
*/
|
||||
protected function sanitizeHtml($description)
|
||||
{
|
||||
$escapeTags = [
|
||||
'script',
|
||||
'style',
|
||||
'link',
|
||||
'iframe',
|
||||
'frameset',
|
||||
'frame',
|
||||
];
|
||||
foreach ($escapeTags as $tag) {
|
||||
$description = preg_replace_callback(
|
||||
'#<\s*' . $tag . '[^>]*>(.*</\s*' . $tag . '[^>]*>)?#is',
|
||||
function ($match) {
|
||||
return escape($match[0]);
|
||||
},
|
||||
$description
|
||||
);
|
||||
}
|
||||
$description = preg_replace(
|
||||
'#(<[^>]+\s)on[a-z]*="?[^ "]*"?#is',
|
||||
'$1',
|
||||
$description
|
||||
);
|
||||
return $description;
|
||||
}
|
||||
|
||||
protected function reverseEscapedHtml($description)
|
||||
{
|
||||
return unescape($description);
|
||||
}
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Formatter;
|
||||
|
||||
/**
|
||||
* Class BookmarkRawFormatter
|
||||
*
|
||||
* Used to retrieve bookmarks as array with raw values.
|
||||
* Warning: Do NOT use this for HTML content as it can introduce XSS vulnerabilities.
|
||||
*
|
||||
* @package Shaarli\Formatter
|
||||
*/
|
||||
class BookmarkRawFormatter extends BookmarkFormatter
|
||||
{
|
||||
}
|
|
@ -1,51 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Formatter;
|
||||
|
||||
use Shaarli\Config\ConfigManager;
|
||||
|
||||
/**
|
||||
* Class FormatterFactory
|
||||
*
|
||||
* Helper class used to instantiate the proper BookmarkFormatter.
|
||||
*
|
||||
* @package Shaarli\Formatter
|
||||
*/
|
||||
class FormatterFactory
|
||||
{
|
||||
/** @var ConfigManager instance */
|
||||
protected $conf;
|
||||
|
||||
/** @var bool */
|
||||
protected $isLoggedIn;
|
||||
|
||||
/**
|
||||
* FormatterFactory constructor.
|
||||
*
|
||||
* @param ConfigManager $conf
|
||||
* @param bool $isLoggedIn
|
||||
*/
|
||||
public function __construct(ConfigManager $conf, bool $isLoggedIn)
|
||||
{
|
||||
$this->conf = $conf;
|
||||
$this->isLoggedIn = $isLoggedIn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instanciate a BookmarkFormatter depending on the configuration or provided formatter type.
|
||||
*
|
||||
* @param string|null $type force a specific type regardless of the configuration
|
||||
*
|
||||
* @return BookmarkFormatter instance.
|
||||
*/
|
||||
public function getFormatter(string $type = null): BookmarkFormatter
|
||||
{
|
||||
$type = $type ? $type : $this->conf->get('formatter', 'default');
|
||||
$className = '\\Shaarli\\Formatter\\Bookmark' . ucfirst($type) . 'Formatter';
|
||||
if (!class_exists($className)) {
|
||||
$className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter';
|
||||
}
|
||||
|
||||
return new $className($this->conf, $this->isLoggedIn);
|
||||
}
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shaarli\Formatter\Parsedown;
|
||||
|
||||
/**
|
||||
* Parsedown extension for Shaarli.
|
||||
*
|
||||
* Extension for both Parsedown and ParsedownExtra centralized in ShaarliParsedownTrait.
|
||||
*/
|
||||
class ShaarliParsedown extends \Parsedown
|
||||
{
|
||||
use ShaarliParsedownTrait;
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shaarli\Formatter\Parsedown;
|
||||
|
||||
/**
|
||||
* ParsedownExtra extension for Shaarli.
|
||||
*
|
||||
* Extension for both Parsedown and ParsedownExtra centralized in ShaarliParsedownTrait.
|
||||
*/
|
||||
class ShaarliParsedownExtra extends \ParsedownExtra
|
||||
{
|
||||
use ShaarliParsedownTrait;
|
||||
}
|
|
@ -1,81 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shaarli\Formatter\Parsedown;
|
||||
|
||||
use Shaarli\Formatter\BookmarkDefaultFormatter as Formatter;
|
||||
|
||||
/**
|
||||
* Trait used for Parsedown and ParsedownExtra extension.
|
||||
*
|
||||
* Extended:
|
||||
* - Format links properly in search context
|
||||
*/
|
||||
trait ShaarliParsedownTrait
|
||||
{
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function inlineLink($excerpt)
|
||||
{
|
||||
return $this->shaarliFormatLink(parent::inlineLink($excerpt), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function inlineUrl($excerpt)
|
||||
{
|
||||
return $this->shaarliFormatLink(parent::inlineUrl($excerpt), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Properly format markdown link:
|
||||
* - remove highlight tags from HREF attribute
|
||||
* - (optional) add highlight tags to link caption
|
||||
*
|
||||
* @param array|null $link Parsedown formatted link array.
|
||||
* It can be empty.
|
||||
* @param bool $fullWrap Add highlight tags the whole link caption
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
protected function shaarliFormatLink(?array $link, bool $fullWrap): ?array
|
||||
{
|
||||
// If open and clean search tokens are found in the link, process.
|
||||
if (
|
||||
is_array($link)
|
||||
&& strpos($link['element']['attributes']['href'] ?? '', Formatter::SEARCH_HIGHLIGHT_OPEN) !== false
|
||||
&& strpos($link['element']['attributes']['href'] ?? '', Formatter::SEARCH_HIGHLIGHT_CLOSE) !== false
|
||||
) {
|
||||
$link['element']['attributes']['href'] = $this->shaarliRemoveSearchTokens(
|
||||
$link['element']['attributes']['href']
|
||||
);
|
||||
|
||||
if ($fullWrap) {
|
||||
$link['element']['text'] = Formatter::SEARCH_HIGHLIGHT_OPEN .
|
||||
$link['element']['text'] .
|
||||
Formatter::SEARCH_HIGHLIGHT_CLOSE
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
return $link;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove open and close tags from provided string.
|
||||
*
|
||||
* @param string $entry input
|
||||
*
|
||||
* @return string Striped input
|
||||
*/
|
||||
protected function shaarliRemoveSearchTokens(string $entry): string
|
||||
{
|
||||
$entry = str_replace(Formatter::SEARCH_HIGHLIGHT_OPEN, '', $entry);
|
||||
$entry = str_replace(Formatter::SEARCH_HIGHLIGHT_CLOSE, '', $entry);
|
||||
|
||||
return $entry;
|
||||
}
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Front;
|
||||
|
||||
use Slim\Http\Request;
|
||||
use Slim\Http\Response;
|
||||
|
||||
/**
|
||||
* Middleware used for controller requiring to be authenticated.
|
||||
* It extends ShaarliMiddleware, and just make sure that the user is authenticated.
|
||||
* Otherwise, it redirects to the login page.
|
||||
*/
|
||||
class ShaarliAdminMiddleware extends ShaarliMiddleware
|
||||
{
|
||||
public function __invoke(Request $request, Response $response, callable $next): Response
|
||||
{
|
||||
$this->initBasePath($request);
|
||||
|
||||
if (true !== $this->container->loginManager->isLoggedIn()) {
|
||||
$returnUrl = urlencode($this->container->environment['REQUEST_URI']);
|
||||
|
||||
return $response->withRedirect($this->container->basePath . '/login?returnurl=' . $returnUrl);
|
||||
}
|
||||
|
||||
return parent::__invoke($request, $response, $next);
|
||||
}
|
||||
}
|
|
@ -1,116 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace Shaarli\Front;
|
||||
|
||||
use Shaarli\Container\ShaarliContainer;
|
||||
use Shaarli\Front\Exception\UnauthorizedException;
|
||||
use Slim\Http\Request;
|
||||
use Slim\Http\Response;
|
||||
|
||||
/**
|
||||
* Class ShaarliMiddleware
|
||||
*
|
||||
* This will be called before accessing any Shaarli controller.
|
||||
*/
|
||||
class ShaarliMiddleware
|
||||
{
|
||||
/** @var ShaarliContainer contains all Shaarli DI */
|
||||
protected $container;
|
||||
|
||||
public function __construct(ShaarliContainer $container)
|
||||
{
|
||||
$this->container = $container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware execution:
|
||||
* - run updates
|
||||
* - if not logged in open shaarli, redirect to login
|
||||
* - execute the controller
|
||||
* - return the response
|
||||
*
|
||||
* In case of error, the error template will be displayed with the exception message.
|
||||
*
|
||||
* @param Request $request Slim request
|
||||
* @param Response $response Slim response
|
||||
* @param callable $next Next action
|
||||
*
|
||||
* @return Response response.
|
||||
*/
|
||||
public function __invoke(Request $request, Response $response, callable $next): Response
|
||||
{
|
||||
$this->initBasePath($request);
|
||||
|
||||
try {
|
||||
if (
|
||||
!is_file($this->container->conf->getConfigFileExt())
|
||||
&& !in_array($next->getName(), ['displayInstall', 'saveInstall'], true)
|
||||
) {
|
||||
return $response->withRedirect($this->container->basePath . '/install');
|
||||
}
|
||||
|
||||
$this->runUpdates();
|
||||
$this->checkOpenShaarli($request, $response, $next);
|
||||
|
||||
return $next($request, $response);
|
||||
} catch (UnauthorizedException $e) {
|
||||
$returnUrl = urlencode($this->container->environment['REQUEST_URI']);
|
||||
|
||||
return $response->withRedirect($this->container->basePath . '/login?returnurl=' . $returnUrl);
|
||||
}
|
||||
// Other exceptions are handled by ErrorController
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the updater for every requests processed while logged in.
|
||||
*/
|
||||
protected function runUpdates(): void
|
||||
{
|
||||
if ($this->container->loginManager->isLoggedIn() !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->container->updater->setBasePath($this->container->basePath);
|
||||
$newUpdates = $this->container->updater->update();
|
||||
if (!empty($newUpdates)) {
|
||||
$this->container->updater->writeUpdates(
|
||||
$this->container->conf->get('resource.updates'),
|
||||
$this->container->updater->getDoneUpdates()
|
||||
);
|
||||
|
||||
$this->container->pageCacheManager->invalidateCaches();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Access is denied to most pages with `hide_public_links` + `force_login` settings.
|
||||
*/
|
||||
protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool
|
||||
{
|
||||
if (
|
||||
// if the user isn't logged in
|
||||
!$this->container->loginManager->isLoggedIn()
|
||||
// and Shaarli doesn't have public content...
|
||||
&& $this->container->conf->get('privacy.hide_public_links')
|
||||
// and is configured to enforce the login
|
||||
&& $this->container->conf->get('privacy.force_login')
|
||||
// and the current page isn't already the login page
|
||||
// and the user is not requesting a feed (which would lead to a different content-type as expected)
|
||||
&& !in_array($next->getName(), ['login', 'processLogin', 'atom', 'rss'], true)
|
||||
) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the URL base path if it hasn't been defined yet.
|
||||
*/
|
||||
protected function initBasePath(Request $request): void
|
||||
{
|
||||
if (null === $this->container->basePath) {
|
||||
$this->container->basePath = rtrim($request->getUri()->getBasePath(), '/');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,132 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shaarli\Front\Controller\Admin;
|
||||
|
||||
use Shaarli\Languages;
|
||||
use Shaarli\Render\TemplatePage;
|
||||
use Shaarli\Render\ThemeUtils;
|
||||
use Shaarli\Thumbnailer;
|
||||
use Slim\Http\Request;
|
||||
use Slim\Http\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Class ConfigureController
|
||||
*
|
||||
* Slim controller used to handle Shaarli configuration page (display + save new config).
|
||||
*/
|
||||
class ConfigureController extends ShaarliAdminController
|
||||
{
|
||||
/**
|
||||
* GET /admin/configure - Displays the configuration page
|
||||
*/
|
||||
public function index(Request $request, Response $response): Response
|
||||
{
|
||||
$this->assignView('title', $this->container->conf->get('general.title', 'Shaarli'));
|
||||
$this->assignView('theme', $this->container->conf->get('resource.theme'));
|
||||
$this->assignView(
|
||||
'theme_available',
|
||||
ThemeUtils::getThemes($this->container->conf->get('resource.raintpl_tpl'))
|
||||
);
|
||||
$this->assignView('formatter_available', ['default', 'markdown', 'markdownExtra']);
|
||||
list($continents, $cities) = generateTimeZoneData(
|
||||
timezone_identifiers_list(),
|
||||
$this->container->conf->get('general.timezone')
|
||||
);
|
||||
$this->assignView('continents', $continents);
|
||||
$this->assignView('cities', $cities);
|
||||
$this->assignView('retrieve_description', $this->container->conf->get('general.retrieve_description', false));
|
||||
$this->assignView('private_links_default', $this->container->conf->get('privacy.default_private_links', false));
|
||||
$this->assignView(
|
||||
'session_protection_disabled',
|
||||
$this->container->conf->get('security.session_protection_disabled', false)
|
||||
);
|
||||
$this->assignView('enable_rss_permalinks', $this->container->conf->get('feed.rss_permalinks', false));
|
||||
$this->assignView('enable_update_check', $this->container->conf->get('updates.check_updates', true));
|
||||
$this->assignView('hide_public_links', $this->container->conf->get('privacy.hide_public_links', false));
|
||||
$this->assignView('api_enabled', $this->container->conf->get('api.enabled', true));
|
||||
$this->assignView('api_secret', $this->container->conf->get('api.secret'));
|
||||
$this->assignView('languages', Languages::getAvailableLanguages());
|
||||
$this->assignView('gd_enabled', extension_loaded('gd'));
|
||||
$this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE));
|
||||
$this->assignView(
|
||||
'pagetitle',
|
||||
t('Configure') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
|
||||
);
|
||||
|
||||
return $response->write($this->render(TemplatePage::CONFIGURE));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/configure - Update Shaarli's configuration
|
||||
*/
|
||||
public function save(Request $request, Response $response): Response
|
||||
{
|
||||
$this->checkToken($request);
|
||||
|
||||
$continent = $request->getParam('continent');
|
||||
$city = $request->getParam('city');
|
||||
$tz = 'UTC';
|
||||
if (null !== $continent && null !== $city && isTimeZoneValid($continent, $city)) {
|
||||
$tz = $continent . '/' . $city;
|
||||
}
|
||||
|
||||
$this->container->conf->set('general.timezone', $tz);
|
||||
$this->container->conf->set('general.title', escape($request->getParam('title')));
|
||||
$this->container->conf->set('general.header_link', escape($request->getParam('titleLink')));
|
||||
$this->container->conf->set('general.retrieve_description', !empty($request->getParam('retrieveDescription')));
|
||||
$this->container->conf->set('resource.theme', escape($request->getParam('theme')));
|
||||
$this->container->conf->set(
|
||||
'security.session_protection_disabled',
|
||||
!empty($request->getParam('disablesessionprotection'))
|
||||
);
|
||||
$this->container->conf->set(
|
||||
'privacy.default_private_links',
|
||||
!empty($request->getParam('privateLinkByDefault'))
|
||||
);
|
||||
$this->container->conf->set('feed.rss_permalinks', !empty($request->getParam('enableRssPermalinks')));
|
||||
$this->container->conf->set('updates.check_updates', !empty($request->getParam('updateCheck')));
|
||||
$this->container->conf->set('privacy.hide_public_links', !empty($request->getParam('hidePublicLinks')));
|
||||
$this->container->conf->set('api.enabled', !empty($request->getParam('enableApi')));
|
||||
$this->container->conf->set('api.secret', escape($request->getParam('apiSecret')));
|
||||
$this->container->conf->set('formatter', escape($request->getParam('formatter')));
|
||||
|
||||
if (!empty($request->getParam('language'))) {
|
||||
$this->container->conf->set('translation.language', escape($request->getParam('language')));
|
||||
}
|
||||
|
||||
$thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE;
|
||||
if (
|
||||
$thumbnailsMode !== Thumbnailer::MODE_NONE
|
||||
&& $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)
|
||||
) {
|
||||
$this->saveWarningMessage(
|
||||
t('You have enabled or changed thumbnails mode.') .
|
||||
'<a href="' . $this->container->basePath . '/admin/thumbnails">' .
|
||||
t('Please synchronize them.') .
|
||||
'</a>'
|
||||
);
|
||||
}
|
||||
$this->container->conf->set('thumbnails.mode', $thumbnailsMode);
|
||||
|
||||
try {
|
||||
$this->container->conf->write($this->container->loginManager->isLoggedIn());
|
||||
$this->container->history->updateSettings();
|
||||
$this->container->pageCacheManager->invalidateCaches();
|
||||
} catch (Throwable $e) {
|
||||
$this->assignView('message', t('Error while writing config file after configuration update.'));
|
||||
|
||||
if ($this->container->conf->get('dev.debug', false)) {
|
||||
$this->assignView('stacktrace', $e->getMessage() . PHP_EOL . $e->getTraceAsString());
|
||||
}
|
||||
|
||||
return $response->write($this->render('error'));
|
||||
}
|
||||
|
||||
$this->saveSuccessMessage(t('Configuration was saved.'));
|
||||
|
||||
return $this->redirect($response, '/admin/configure');
|
||||
}
|
||||
}
|
|
@ -1,80 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shaarli\Front\Controller\Admin;
|
||||
|
||||
use DateTime;
|
||||
use Shaarli\Bookmark\Bookmark;
|
||||
use Shaarli\Render\TemplatePage;
|
||||
use Slim\Http\Request;
|
||||
use Slim\Http\Response;
|
||||
|
||||
/**
|
||||
* Class ExportController
|
||||
*
|
||||
* Slim controller used to display Shaarli data export page,
|
||||
* and process the bookmarks export as a Netscape Bookmarks file.
|
||||
*/
|
||||
class ExportController extends ShaarliAdminController
|
||||
{
|
||||
/**
|
||||
* GET /admin/export - Display export page
|
||||
*/
|
||||
public function index(Request $request, Response $response): Response
|
||||
{
|
||||
$this->assignView('pagetitle', t('Export') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
|
||||
|
||||
return $response->write($this->render(TemplatePage::EXPORT));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /admin/export - Process export, and serve download file named
|
||||
* bookmarks_(all|private|public)_datetime.html
|
||||
*/
|
||||
public function export(Request $request, Response $response): Response
|
||||
{
|
||||
$this->checkToken($request);
|
||||
|
||||
$selection = $request->getParam('selection');
|
||||
|
||||
if (empty($selection)) {
|
||||
$this->saveErrorMessage(t('Please select an export mode.'));
|
||||
|
||||
return $this->redirect($response, '/admin/export');
|
||||
}
|
||||
|
||||
$prependNoteUrl = filter_var($request->getParam('prepend_note_url') ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
try {
|
||||
$formatter = $this->container->formatterFactory->getFormatter('raw');
|
||||
|
||||
$this->assignView(
|
||||
'links',
|
||||
$this->container->netscapeBookmarkUtils->filterAndFormat(
|
||||
$formatter,
|
||||
$selection,
|
||||
$prependNoteUrl,
|
||||
index_url($this->container->environment)
|
||||
)
|
||||
);
|
||||
} catch (\Exception $exc) {
|
||||
$this->saveErrorMessage($exc->getMessage());
|
||||
|
||||
return $this->redirect($response, '/admin/export');
|
||||
}
|
||||
|
||||
$now = new DateTime();
|
||||
$response = $response->withHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
$response = $response->withHeader(
|
||||
'Content-disposition',
|
||||
'attachment; filename=bookmarks_' . $selection . '_' . $now->format(Bookmark::LINK_DATE_FORMAT) . '.html'
|
||||
);
|
||||
|
||||
$this->assignView('date', $now->format(DateTime::RFC822));
|
||||
$this->assignView('eol', PHP_EOL);
|
||||
$this->assignView('selection', $selection);
|
||||
|
||||
return $response->write($this->render(TemplatePage::NETSCAPE_EXPORT_BOOKMARKS));
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue