48 changes: 48 additions & 0 deletions .github/workflows/docker-latest.yml
@@ -0,0 +1,48 @@
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
if: github.repository == 'shaarli/Shaarli'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: github.repository == 'shaarli/Shaarli'
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: ${{ github.repository == 'shaarli/Shaarli' }}
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
if: github.repository == 'shaarli/Shaarli'
run: make test_trivy_docker TRIVY_EXIT_CODE=0 TRIVY_TARGET_DOCKER_IMAGE=ghcr.io/${{ secrets.DOCKER_IMAGE }}:latest
21 changes: 21 additions & 0 deletions .github/workflows/docker-pr.yml
@@ -0,0 +1,21 @@
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 }}
44 changes: 44 additions & 0 deletions .github/workflows/docker-tags.yml
@@ -0,0 +1,44 @@
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
if: github.repository == 'shaarli/Shaarli'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: github.repository == 'shaarli/Shaarli'
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: ${{ github.repository == 'shaarli/Shaarli' }}
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 }}
25 changes: 25 additions & 0 deletions .github/workflows/trivy-release.yml
@@ -0,0 +1,25 @@
name: trivy security scans (release)
on:
schedule:
- cron: '0 17 * * *'
workflow_dispatch:

jobs:
trivy-repo:
runs-on: ubuntu-latest
name: trivy scan (release composer/yarn dependencies)
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Run trivy scanner on repository
run: make test_trivy_repo TRIVY_TARGET_BRANCH=origin/release TRIVY_EXIT_CODE=1
trivy-docker:
runs-on: ubuntu-latest
name: trivy scan (release docker image)
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Run trivy scanner on release docker image
run: make test_trivy_docker TRIVY_TARGET_DOCKER_IMAGE=ghcr.io/shaarli/shaarli:release
60 changes: 58 additions & 2 deletions .gitignore
@@ -1,4 +1,4 @@
# Ignore data/, tmp/, cache/ and pagecache/
# Shaarli runtime resources
data
tmp
cache
Expand All @@ -7,4 +7,60 @@ pagecache
# Eclipse project files
.settings
.buildpath
.project
.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
sandbox
phpmd.html
phpdoc.xml
.phpunit.result.cache
trivy

# User plugin configuration
plugins/*
!addlink_toolbar
!archiveorg
!default_colors
!demo_plugin
!isso
!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

# 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

# Documented scripts
generate_templates.php
37 changes: 37 additions & 0 deletions .htaccess
@@ -0,0 +1,37 @@
# 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>
23 changes: 23 additions & 0 deletions .readthedocs.yml
@@ -0,0 +1,23 @@
# .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
111 changes: 111 additions & 0 deletions AUTHORS
@@ -0,0 +1,111 @@
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 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 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 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 Adrien Oliva <adrien.oliva@yapbreak.fr>
1 Adrien le Maire <adrien@alemaire.be>
1 Ajabep <ajabep@users.noreply.github.com>
1 Alexis J <alexis@effingo.be>
1 Alistair Young <avatar@arkane-systems.net>
1 Amadeous <amadeous@users.noreply.github.com>
1 Angristan <angristan@users.noreply.github.com>
1 Bish Erbas <42714627+bisherbas@users.noreply.github.com>
1 BoboTiG <bobotig@gmail.com>
1 Brendan M. Sleight <bms.git@barwap.com>
1 Bronco <bronco@warriordudimanche.net>
1 Buster One <37770318+buster-one@users.noreply.github.com>
1 D Low <daniellowtw@gmail.com>
1 Daniel Jakots <vigdis@chown.me>
1 David <dajare@gmail.com>
1 David Foucher <dev@tyjak.net>
1 Denis Renning <denis@devtty.de>
1 Dennis Verspuij <dennisverspuij@users.noreply.github.com>
1 Dimtion <zizou.xena@gmail.com>
1 Fanch <fanch-github@qth.fr>
1 Felix Kästner <github.com-fpunktk@fpunktk.de>
1 Florian Voigt <flvoigt@me.com>
1 Franck Kerbiriou <FranckKe@users.noreply.github.com>
1 Gary Marigliano <gmarigliano93@gmail.com>
1 Hazhar Galeh <78073762+hazhargaleh@users.noreply.github.com>
1 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>
1,593 changes: 1,593 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

78 changes: 78 additions & 0 deletions CONTRIBUTING.md
@@ -0,0 +1,78 @@
## Contributing to Shaarli (community repository)

### Bugs and feature requests
**Reporting bugs, feature requests: issues management**

You can look through existing bugs/requests and help reporting them [here](https://github.com/shaarli/Shaarli/issues).

Constructive input/experience reports/helping other users is welcome.

The general guideline of the fork is to keep Shaarli simple (project and code maintenance, and features-wise), while providing customization capabilities (plugin system, making more settings configurable).

Check the [milestones](https://github.com/shaarli/Shaarli/milestones) to see what issues have priority.

* The issues list should preferably contain **only tasks that can be actioned immediately**. Anyone should be able to open the issues list, pick one and start working on it immediately.
* If you have a clear idea of a **feature you expect, or have a specific bug/defect to report**, [search the issues list, both open and closed](https://github.com/shaarli/Shaarli/issues?q=is%3Aissue) to check if it has been discussed, and comment on the appropriate issue. If you can't find one, please open a [new issue](https://github.com/shaarli/Shaarli/issues/new)
* **General discussions** fit in #44 so that we don't follow a slope where users and contributors have to track 90 "maybe" items in the bug tracker. Separate issues about clear, separate steps can be opened after discussion.
* You can also join instant discussion at https://gitter.im/shaarli/Shaarli, or via IRC as described [here](https://github.com/shaarli/Shaarli/issues/44#issuecomment-77745105)

### Documentation

The [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.

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.

### 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)

### Beta testing
You can help testing Shaarli releases by immediately upgrading your installation after a [new version has been releases](https://github.com/shaarli/Shaarli/releases).

All current development happens in [Pull Requests](https://github.com/shaarli/Shaarli/pulls). You can test proposed patches by cloning the Shaarli repo, adding the Pull Request branch and `git checkout` to it. You can also merge multiple Pull Requests to a testing branch.

```bash
git clone https://github.com/shaarli/Shaarli
git remote add pull-request-25 owner/cool-new-feature
git remote add pull-request-26 anotherowner/bugfix
git remote update
git checkout -b testing
git merge cool-new-feature
git merge bugfix
```
Or see [Checkout Github Pull Requests locally](https://gist.github.com/piscisaureus/3342247)

Please report any problem you might find.


### Contributing code

#### Adding your own changes

* Pick or open an issue
* Fork the Shaarli repository on github
* `git clone` your fork
* 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)
* 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.

#### Contributing to an existing Pull Request

TODO

#### Useful links
If you are not familiar with Git or Github, here are a few links to set you on track:

* https://try.github.io/ - 10 minutes Github workflow interactive tutorial
* http://ndpsoftware.com/git-cheatsheet.html - A Git cheatsheet
* http://www.wei-wang.com/ExplainGitWithD3 - Helps you understand some basic Git concepts visually
* https://www.atlassian.com/git/tutorial - Git tutorials
* https://www.atlassian.com/git/workflows - Git workflows
* http://git-scm.com/book - The official Git book, multiple languages
* http://www.vogella.com/tutorials/Git/article.html - Git tutorials
* http://think-like-a-git.net/resources.html - Guide to Git
* http://gitready.com/ - medium to advanced Git docs/tips/blog/articles
* https://github.com/btford/participating-in-open-source - Participating in Open Source
779 changes: 775 additions & 4 deletions COPYING

Large diffs are not rendered by default.

74 changes: 74 additions & 0 deletions Dockerfile
@@ -0,0 +1,74 @@
# Stage 1:
# - Copy Shaarli sources
# - Build documentation
FROM docker.io/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 docker.io/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 docker.io/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 docker.io/alpine:3.18.6
LABEL maintainer="Shaarli Community"

RUN apk --update --no-cache add \
ca-certificates \
nginx \
php82 \
php82-ctype \
php82-curl \
php82-fpm \
php82-gd \
php82-gettext \
php82-iconv \
php82-intl \
php82-json \
php82-ldap \
php82-mbstring \
php82-openssl \
php82-session \
php82-xml \
php82-simplexml \
php82-zlib \
s6

COPY .docker/nginx.conf /etc/nginx/nginx.conf
COPY .docker/php-fpm.conf /etc/php82/php-fpm.conf
COPY .docker/services.d /etc/services.d

RUN rm -rf /etc/php82/php-fpm.d/www.conf \
&& sed -i 's/post_max_size.*/post_max_size = 10M/' /etc/php82/php.ini \
&& sed -i 's/upload_max_filesize.*/upload_max_filesize = 10M/' /etc/php82/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 []
221 changes: 221 additions & 0 deletions Makefile
@@ -0,0 +1,221 @@
# The personal, minimalist, super fast, database-free, bookmarking service.
# Makefile for PHP code analysis & testing, documentation and release generation

BIN = vendor/bin

all: 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.
##
docker_%:
rsync -az /shaarli/ ~/shaarli/
cd ~/shaarli && make $*

##
# PHP_CodeSniffer
# Detects PHP syntax errors
# Documentation (usage, output formatting):
# - http://pear.php.net/manual/en/package.php.php-codesniffer.usage.php
# - http://pear.php.net/manual/en/package.php.php-codesniffer.reporting.php
##
PHPCS := $(BIN)/phpcs

# Use GNU Tar where available
ifneq (, $(shell which gtar))
TAR := gtar
else
TAR := tar
endif

code_sniffer:
@$(PHPCS)

### - errors by Git author
code_sniffer_blame:
@$(PHPCS) --report-gitblame

### - all errors/warnings
code_sniffer_full:
@$(PHPCS) --report-full --report-width=200

### - errors grouped by kind
code_sniffer_source:
@$(PHPCS) --report-source || exit 0

##
# Checks source file & script permissions
##
check_permissions:
@echo "----------------------"
@echo "Check file permissions"
@echo "----------------------"
@for file in `git ls-files | grep -v docker`; do \
if [ -x $$file ]; then \
errors=true; \
echo "$${file} is executable"; \
fi \
done; [ -z $$errors ] || false

##
# PHPUnit
# Runs unitary and functional tests
# Generates an HTML coverage report if Xdebug is enabled
#
# See phpunit.xml for configuration
# https://phpunit.de/manual/current/en/appendixes.configuration.html
##
test: translate
@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

##
# Custom release archive generation
#
# For each tagged revision, GitHub provides tar and zip archives that correspond
# to the output of git-archive
#
# These targets produce similar archives, featuring 3rd-party dependencies
# to ease deployment on shared hosting.
##
ARCHIVE_VERSION := shaarli-$$(git describe)-full
ARCHIVE_PREFIX=Shaarli/

release_archive: release_tar release_zip

### download 3rd-party PHP libraries
composer_dependencies: clean
composer install --no-dev --prefer-dist
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
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/
gzip $(ARCHIVE_VERSION).tar

### generate a release zip and include 3rd-party dependencies and translations
release_zip: composer_dependencies htmldoc translate build_frontend
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/
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)

##
# Targets for repository and documentation maintenance
##

### remove all unversioned files
clean:
@git clean -df
@rm -rf sandbox trivy*

### generate the AUTHORS file from Git commit information
generate_authors:
@cp .github/mailmap .mailmap
@git shortlog -sne > AUTHORS
@rm .mailmap

### generate phpDocumentor documentation
phpdoc: clean
@docker run --rm -v $(PWD):/data -u `id -u`:`id -g` phpdoc/phpdoc

### 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 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"; \
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.49.1
# 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
# branch on which test_trivy_repo should be run. leave undefined for the current branch
#TRIVY_TARGET_BRANCH=origin/release

### 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
ifdef TRIVY_TARGET_BRANCH
git checkout $(TRIVY_TARGET_BRANCH) composer.lock
git checkout $(TRIVY_TARGET_BRANCH) yarn.lock
endif
./trivy --exit-code $(TRIVY_EXIT_CODE) fs composer.lock
./trivy --exit-code $(TRIVY_EXIT_CODE) fs yarn.lock
82 changes: 21 additions & 61 deletions README.md
@@ -1,71 +1,31 @@
![Shaarli logo](http://sebsauvage.net/wiki/lib/exe/fetch.php?media=php:php_shaarli:php_shaarli_logo_inkscape_w600_transp-nq8.png)
![Shaarli logo](doc/md/images/doc-logo.png)

Shaarli, the personal, minimalist, super-fast, no-database delicious clone.
The personal, minimalist, super fast, database-free, bookmarking service.

You want to share the links you discover ? Shaarli is a minimalist delicious clone you can install on your own website.
It is designed to be personal (single-user), fast and handy.
_Do you want to share the links you discover?_
_Shaarli is a minimalist link sharing service that you can install on your own server._
_It is designed to be personal (single-user), fast and handy._

[![](https://img.shields.io/badge/release-v0.13.0-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.13.0)
[![](https://img.shields.io/badge/master-v0.13.x-blue.svg)](https://github.com/shaarli/Shaarli)
[![](https://github.com/shaarli/Shaarli/actions/workflows/ci.yml/badge.svg)](https://github.com/shaarli/Shaarli/actions)
[![](https://github.com/shaarli/Shaarli/actions/workflows/trivy-release.yml/badge.svg)](https://github.com/shaarli/Shaarli/actions)
[![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)

Features:
## Quickstart

* Minimalist design (simple is beautiful)
* **FAST**
* Dead-simple installation: Drop the files, open the page. No database required.
* Easy to use: Single button in your browser to bookmark a page
* Save url, title, description (unlimited size). Classify links with tags (with autocomplete)
* Tag renaming, merging and deletion.
* Automatic thumbnails for various services (imgur, imageshack.us, flickr, youtube, vimeo, dailymotion…)
* Automatic conversion of URLs to clickable links in descriptions. Support for http/ftp/file/apt/magnet protocols.
* Save links as public or private
* 1-clic access to your private links/notes
* Browse links by page, filter by tag or use the full text search engine
* Permalinks (with QR-Code) for easy reference
* RSS and ATOM feeds (which can be filtered by tag or text search)
* Tag cloud
* Picture wall (which can be filtered by tag or text search)
* “Links of the day” Newspaper-like digest, browsable by day.
* “Daily” RSS feed: Get each day a digest of all new links.
* [PubSubHubbub](https://code.google.com/p/pubsubhubbub/) protocol support
* Easy backup (Data stored in a single file)
* Compact storage (1315 links stored in 150 kb)
* Mobile browsers support
* Also works with javascript disabled
* Can import/export Netscape bookmarks (for import/export from/to Firefox, Opera, Chrome, Delicious…)
* Brute force protected login form
* Protected against [XSRF](http://en.wikipedia.org/wiki/Cross-site_request_forgery), session cookie hijacking.
* Automatic removal of annoying FeedBurner/Google FeedProxy parameters in URL (?utm_source…)
* Shaarli is a bookmarking application, but you can use it for micro-blogging (like Twitter), a pastebin, an online notepad, a snippet repository, etc.
* You will be automatically notified by a discreet popup if a new version is available
* Pages are easy to customize (using CSS and simple RainTPL templates)
- [Documentation](https://shaarli.readthedocs.io)
- [Change log](CHANGELOG.md)
- [Bugs/Feature requests/Discussion](https://github.com/shaarli/Shaarli/issues/)

### Demo

Requires php 5.1
You can use this [public demo instance of Shaarli](https://demo.shaarli.org).
It runs the latest development version of Shaarli and is updated/reset daily.

More information on the project page:
http://sebsauvage.net/wiki/doku.php?id=php:shaarli
Login: `demo`; Password: `demo`

------------------------------------------------------------------------------
### License

Shaarli is distributed under the zlib/libpng License:

Copyright (c) 2011 Sébastien SAUVAGE (sebsauvage.net)

This software is provided 'as-is', without any express or implied warranty.
In no event will the authors be held liable for any damages arising from
the use of this software.

Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:

1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would
be appreciated but is not required.

2. Altered source versions must be plainly marked as such, and must
not be misrepresented as being the original software.

3. This notice may not be removed or altered from any source distribution.

------------------------------------------------------------------------------
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.
13 changes: 13 additions & 0 deletions application/.htaccess
@@ -0,0 +1,13 @@
<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>
223 changes: 223 additions & 0 deletions application/History.php
@@ -0,0 +1,223 @@
<?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;
}
}
193 changes: 193 additions & 0 deletions application/Languages.php
@@ -0,0 +1,193 @@
<?php

namespace Shaarli;

use Gettext\GettextTranslator;
use Gettext\Translations;
use Gettext\Translator;
use Gettext\TranslatorInterface;
use Shaarli\Config\ConfigManager;

/**
* Class Languages
*
* 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().
*
* Translation files PO/MO files follow gettext standard and must be placed under:
* <translation path>/<language>/LC_MESSAGES/<domain>.[po|mo]
*
* 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
*/
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)'),
];
}
}
131 changes: 131 additions & 0 deletions application/Thumbnailer.php
@@ -0,0 +1,131 @@
<?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');
}
}
92 changes: 92 additions & 0 deletions application/TimeZone.php
@@ -0,0 +1,92 @@
<?php

/**
* Generates a list of available timezone continents and cities.
*
* 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)
*
* Example:
* [
* [
* 'America',
* 'Europe',
* 'selected' => 'Europe',
* ],
* [
* ['continent' => 'America', 'city' => 'Toronto'],
* ['continent' => 'Europe', 'city' => 'Paris'],
* 'selected' => 'Paris',
* ],
* ];
*
* Notes:
* - 'UTC/UTC' is mapped to 'UTC' to form a valid option
* - a few timezone cities includes the country/state, such as Argentina/Buenos_Aires
* - these arrays are designed to build timezone selects in template files with any HTML structure
*
* @param array $installedTimeZones List of installed timezones as string
* @param string $preselectedTimezone preselected timezone (optional)
*
* @return array[] continents and cities
**/
function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
{
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);
}

$continents = [];
$cities = [];
foreach ($installedTimeZones as $tz) {
if ($tz == 'UTC') {
$tz = 'UTC/UTC';
}
$spos = strpos($tz, '/');

// Ignore invalid timezones
if ($spos === false) {
continue;
}

$continent = substr($tz, 0, $spos);
$city = substr($tz, $spos + 1);
$cities[] = ['continent' => $continent, 'city' => $city];
$continents[$continent] = true;
}

$continents = array_keys($continents);
$continents['selected'] = $pcontinent;
$cities['selected'] = $pcity;

return [$continents, $cities];
}

/**
* Tells if a continent/city pair form a valid timezone
*
* Note: 'UTC/UTC' is mapped to 'UTC'
*
* @param string $continent the timezone continent
* @param string $city the timezone city
*
* @return bool whether continent/city is a valid timezone
*/
function isTimeZoneValid($continent, $city)
{
return in_array(
$continent . '/' . $city,
timezone_identifiers_list()
);
}
525 changes: 525 additions & 0 deletions application/Utils.php

Large diffs are not rendered by default.

155 changes: 155 additions & 0 deletions application/api/ApiMiddleware.php
@@ -0,0 +1,155 @@
<?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;
}
}
174 changes: 174 additions & 0 deletions application/api/ApiUtils.php
@@ -0,0 +1,174 @@
<?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,
];
}
}
73 changes: 73 additions & 0 deletions application/api/controllers/ApiController.php
@@ -0,0 +1,73 @@
<?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;
}
}
68 changes: 68 additions & 0 deletions application/api/controllers/HistoryController.php
@@ -0,0 +1,68 @@
<?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);
}
}
44 changes: 44 additions & 0 deletions application/api/controllers/Info.php
@@ -0,0 +1,44 @@
<?php

namespace Shaarli\Api\Controllers;

use Shaarli\Bookmark\BookmarkFilter;
use Slim\Http\Request;
use Slim\Http\Response;

/**
* Class Info
*
* REST API Controller: /info
*
* @package Api\Controllers
* @see http://shaarli.github.io/api-documentation/#links-instance-information-get
*/
class Info extends ApiController
{
/**
* Service providing various information about Shaarli instance.
*
* @param Request $request Slim request.
* @param Response $response Slim response.
*
* @return Response response.
*/
public function getInfo($request, $response)
{
$info = [
'global_counter' => $this->bookmarkService->count(),
'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE),
'settings' => [
'title' => $this->conf->get('general.title', 'Shaarli'),
'header_link' => $this->conf->get('general.header_link', '?'),
'timezone' => $this->conf->get('general.timezone', 'UTC'),
'enabled_plugins' => $this->conf->get('general.enabled_plugins', []),
'default_private_links' => $this->conf->get('privacy.default_private_links', false),
'tags_separator' => $this->conf->get('general.tags_separator', ' '),
],
];

return $response->withJson($info, 200, $this->jsonStyle);
}
}
213 changes: 213 additions & 0 deletions application/api/controllers/Links.php
@@ -0,0 +1,213 @@
<?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);
}
}
174 changes: 174 additions & 0 deletions application/api/controllers/Tags.php
@@ -0,0 +1,174 @@
<?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);
}
}
34 changes: 34 additions & 0 deletions application/api/exceptions/ApiAuthorizationException.php
@@ -0,0 +1,34 @@
<?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;
}
}
19 changes: 19 additions & 0 deletions application/api/exceptions/ApiBadParametersException.php
@@ -0,0 +1,19 @@
<?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);
}
}
78 changes: 78 additions & 0 deletions application/api/exceptions/ApiException.php
@@ -0,0 +1,78 @@
<?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;
}
}
19 changes: 19 additions & 0 deletions application/api/exceptions/ApiInternalException.php
@@ -0,0 +1,19 @@
<?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);
}
}
29 changes: 29 additions & 0 deletions application/api/exceptions/ApiLinkNotFoundException.php
@@ -0,0 +1,29 @@
<?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);
}
}
29 changes: 29 additions & 0 deletions application/api/exceptions/ApiTagNotFoundException.php
@@ -0,0 +1,29 @@
<?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);
}
}