| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 [] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,71 +1,31 @@ | ||
|  | ||
|
|
||
| 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._ | ||
| _It is designed to be personal (single-user), fast and handy._ | ||
|
|
||
| [](https://github.com/shaarli/Shaarli/releases/tag/v0.13.0) | ||
| [](https://github.com/shaarli/Shaarli) | ||
| [](https://github.com/shaarli/Shaarli/actions) | ||
| [](https://github.com/shaarli/Shaarli/actions) | ||
| [](https://gitter.im/shaarli/Shaarli) | ||
| [](https://github.com/shaarli/Shaarli/pkgs/container/shaarli) | ||
|
|
||
| ## Quickstart | ||
|
|
||
| - [Documentation](https://shaarli.readthedocs.io) | ||
| - [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). | ||
| It runs the latest development version of Shaarli and is updated/reset daily. | ||
|
|
||
| Login: `demo`; Password: `demo` | ||
|
|
||
| ### 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)'), | ||
| ]; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ]; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } |