Compare commits

...

395 Commits

Author SHA1 Message Date
Knah Tsaeb be6a3e28ca Update soshot config 2023-07-13 17:22:58 +02:00
Knah Tsaeb 2c1f0981d9 Update for Shaarli 0.12.2 2023-05-25 11:13:43 +02:00
Knah Tsaeb 23a5fc1eef Merge latest 0.12.2 2023-05-24 11:35:15 +02:00
Knah Tsaeb 984073a980 Release v0.11.0
-----BEGIN PGP SIGNATURE-----
 
 iQIzBAABCgAdFiEEWe5LuNiFNDXAgI8BOzJIyqqwgW4FAl08H7AACgkQOzJIyqqw
 gW4dEw/9F55N9HMK1xTByxsnrMihjzBaKKc1lBBNJizAXrX2QchgnhE15ATRnQNy
 /7GUU8hCRukBsffMp7Ve1tbPkVvQwWgyQn2Hpp+ayGNWgQYrU1jNSaCQcbyxybyP
 6e+8DFAdDsleHiYCSZBPUHMpiJyQWsVBDV1wQPRrqvm+JYE3+9IwHzm+9/y4sk55
 7bp5Mj7fYyts5AJfLj9gxg2juGRnnhKXGWj2WI4Yk1mpwQLFSf43wC8lFf0ASY1J
 PfhjwOOFCRv/7LOL66nIPp74+pKcyO/S8p2m/pFNgrHL2bJXaAmFMPmYQjyoFmaA
 83iM5Jv3fBXMSf/iHnPvQlD0nmIvXUeu5ftBUIE/C4Uwu8LZTlOsPelW1dH5ygGa
 TVaA3/vlRhDWATe9mRNrHPHQT3VoxHg8U3qIv3p3cakj1uRFaFvkKhI7dEoqFSJY
 zsmISLbPMbmvJkMMNT4sI2q3ioyGDiU0OSayKocJziiu/H9+c2Pdty3YOSvJp/SX
 sjgqSX/hwtNmpQnS63dweDLoBGWjj01MYgedI9r64kmfW3QoSYsdVfykEMHIfofw
 /g8hRMBmuzK0VuDrla6DIBl7s58w0Uepr+e/lFMI4pzwHzxzUCZ5lc6wG0yCxuq2
 R+wTbpLqeXghKIaprmxq9i1TnAiCIl+lmw9zKj3M3fXwBGQ8e4I=
 =c7Xq
 -----END PGP SIGNATURE-----

Merge tag 'v0.11.0' into myShaarli_commu

Release v0.11.0
2019-08-12 14:16:22 +02:00
ArthurHoaro ed3365325d Bump Shaarli version to v0.11.0 2019-07-27 11:55:08 +02:00
ArthurHoaro d7dead5644
Merge pull request #1333 from ArthurHoaro/hotfix/sticky-update
Persist sticky status on bookmark update
2019-07-27 11:53:09 +02:00
ArthurHoaro 81cae5f5dd Persist sticky status on bookmark update
Fixes #1331
2019-07-27 11:46:05 +02:00
ArthurHoaro c49b999001
Merge pull request #1334 from ArthurHoaro/changelog-v11
Changelog and authors for v0.11 release
2019-07-27 11:43:17 +02:00
ArthurHoaro 525069ea7a Changelog and authors for v0.11 release
Signed-off-by: ArthurHoaro <arthur@hoa.ro>
2019-07-27 11:24:23 +02:00
ArthurHoaro 37686457f0
Merge pull request #1332 from rajathans/master_fix
Responsive issue with delete button fix
2019-07-27 10:38:44 +02:00
Rajat Hans 4c029779c8 Responsive issue with delete button fix 2019-07-24 21:59:56 +05:30
ArthurHoaro a9633359d1
Merge pull request #1317 from ArthurHoaro/feature/default-colors
Plugin to override default template colors
2019-07-20 09:36:39 +02:00
ArthurHoaro a8fb97a0c3 Default colors plugin - Documentation 2019-07-20 09:32:56 +02:00
ArthurHoaro e503d26f0b Default colors plugin - Translations 2019-07-20 09:32:56 +02:00
ArthurHoaro 15a61e5974 Add the new hook save_plugin_parameters to the demo plugin 2019-07-20 09:32:56 +02:00
ArthurHoaro b550735054 Default colors plugin - Add unit tests 2019-07-20 09:32:52 +02:00
ArthurHoaro 01ba8a0700
Merge pull request #1323 from llune/patch-5
fix a11y label
2019-07-13 15:09:47 +02:00
ArthurHoaro b15d065905
Merge pull request #1321 from llune/patch-3
a11y fix img without alt
2019-07-13 15:09:20 +02:00
ArthurHoaro c088ae99bf
Merge pull request #1324 from llune/patch-6
a11y fix add banner mobile version
2019-07-13 15:09:08 +02:00
ArthurHoaro 19ba060669
Merge pull request #1322 from llune/patch-4
fix a11y label
2019-07-13 15:08:50 +02:00
ArthurHoaro f2d00d95a0
Merge pull request #1320 from llune/master
a11y fix: label and tabindex
2019-07-13 15:08:42 +02:00
Luce Carević 84b8426c31
a11y fix add banner mobile version 2019-07-13 00:40:30 +02:00
Luce Carević cadf4d5bd6
fix a11y label 2019-07-13 00:09:54 +02:00
Luce Carević 6177da0c30
fix a11y label 2019-07-13 00:07:50 +02:00
Luce Carević d91719ab97
fix img without alt 2019-07-13 00:04:25 +02:00
Luce Carević b43c98fecb
fix blank 2019-07-12 23:56:43 +02:00
Luce Carević 852872930f
a11y fix: label and tabindex
Don't use tabindex values other than -1, 0. (see https://webaim.org/techniques/keyboard/tabindex).

Fix inputs without labels (the placeholder attribute is not a proper labelling method)
2019-07-12 23:54:42 +02:00
Knah Tsaeb 7fe2910525 Release v0.10.4
-----BEGIN PGP SIGNATURE-----
 
 iQFLBAABCAA1FiEEEv0k8DWUT53dSMUkR6bSrUEA328FAly1ANsXHHZpcnR1YWx0
 YW1AZmxpYmlkaS5uZXQACgkQR6bSrUEA32/RJQf/ZNv/QG1Gbno7DmoXrW8F1nvg
 gfNRLWkCJkbSVDy66huGaWUo8ysuyV1699+MqOxMEvGzkhpwZpSXDjjOjvaBy3ca
 UKlUQrpJSc8L0VjsryHgYeX83xamw2Fk8syAsvtNxLY4SDW8aSqwFbqXl9eoTSwA
 bGPMIy2wZk/Wh/9B5DB/8QM3vD4Bk5ZJFGbTTeJPhQ0AJ92i8E3lZUjG0C3oA1bG
 TYOrgEYoA2eUrNStRKaKj3i163emzOqTdf56ml+ADQGl45MeHkeuQM7+uZfC5+sG
 y/Zm/8aecNP/OXNO3+oSpxZlQKgINKVdoRQrqODs3LmsoMg/poc+krpzIUbebg==
 =rSrP
 -----END PGP SIGNATURE-----

Merge tag 'v0.10.4' into myShaarli_commu

Release v0.10.4
2019-07-11 11:44:51 +02:00
ArthurHoaro a5a0c0399b WIP - Plugin to override default template colors
* Adds a new core plugin to override default template colors
  * Adds a new hook when plugin settings are saved
(`save_plugin_parameters`)
  * Use CSS native variables for main colors instead of SASS variables
  * Disable SASS sort order rules due to a bug in the plugin

Fixes #1312
2019-07-08 23:20:56 +02:00
ArthurHoaro c03c90a13e
Merge pull request #1313 from ArthurHoaro/feature/desc-retrieval
Automatically retrieve description for new bookmarks
2019-07-06 12:34:02 +02:00
ArthurHoaro 6a4872520c Automatically retrieve description for new bookmarks
If the option is enabled, it will try to find a meta tag containing
the page description and keywords, just like we do for the page title.
It will either look for regular meta tag or OpenGraph ones.

The option is disabled by default.

Note that keywords meta tags is mostly not used.

In `configure` template, the variable associated with this setting
is `$retrieve_description`.

Fixes #1302
2019-07-06 12:21:52 +02:00
ArthurHoaro 5d8a958d5d
Merge pull request #1311 from Agurato/master
Building Docker image for armhf outputs error
2019-06-08 14:09:33 +02:00
ArthurHoaro bd231539e9
Merge pull request #1308 from ArthurHoaro/feature/daily-date
Daily - display the current day instead of the previous one
2019-06-08 14:09:07 +02:00
Agurato a47656a28e Rollback on removing php7-curl from step 4 2019-06-04 19:52:22 +02:00
Agurato 0b0694064c Fix armhf Dockerfile 2019-06-02 12:57:33 +02:00
Agurato e14d47cc55 Fix armhf Dockerfile 2019-06-02 12:52:07 +02:00
ArthurHoaro 86aa248654
Merge pull request #1309 from ArthurHoaro/feature/qrcode-link
Remove QRCode link to an external service
2019-05-30 10:54:02 +02:00
ArthurHoaro 5c003824a3 Remove QRCode link to an external service 2019-05-25 17:00:23 +02:00
ArthurHoaro 5321f704b5 Daily - display the current day instead of the previous one
Also mention if it's today or yesterday for clarity using `dayDesc`
variable

Fixes #1299
2019-05-25 16:40:45 +02:00
ArthurHoaro c3a04e328f
Merge pull request #1273 from ArthurHoaro/feature/ban-manager
Rewrite IP ban management
2019-05-25 16:13:56 +02:00
ArthurHoaro 8ed59f107e
Merge pull request #1301 from ArthurHoaro/template/print-css
Add print CSS rules to the default template
2019-05-25 15:38:49 +02:00
ArthurHoaro 5f8f6134bc
Merge pull request #1305 from ArthurHoaro/feature/forkawsome
Switch from FontAwesome v4.x to ForkAwesome
2019-05-25 15:38:15 +02:00
ArthurHoaro b2143ff480 Switch from FontAwesome v4.x to ForkAwesome
And use the Shaarli icon made by @xuv in the header and footer (default template).
2019-05-19 12:03:14 +02:00
ArthurHoaro 590c34dec1
Merge pull request #1304 from ArthurHoaro/hotfix/yarn-dep
Update node-sass to fix a vulnerability in node tar dependency
2019-05-19 11:38:06 +02:00
ArthurHoaro dbbea38c7a Update node-sass to fix a vulnerability in node tar dependency
See https://github.com/sass/node-sass/issues/2625
2019-05-19 11:32:28 +02:00
ArthurHoaro 374f89e721 Add print CSS rules to the default template
Fixes #1291

  * Display the header bar only on the first page
  * Hide search bars, pagination buttons, filters, and edit/delete buttons
2019-05-08 12:17:52 +02:00
ArthurHoaro 06783e8f1a
Merge pull request #1297 from ArthurHoaro/hotfix/mobile-select-all
Hide select all button on mobile view
2019-05-08 11:44:54 +02:00
ArthurHoaro c5e96f594b
Merge pull request #1295 from ArthurHoaro/feature/visited-link-color
Slightly lighten visited link color
2019-05-08 11:00:58 +02:00
ArthurHoaro 160d9a7741
Merge pull request #1296 from ArthurHoaro/feature/sticky-label
Display sticky label in linklist
2019-05-08 11:00:33 +02:00
ArthurHoaro 51c5de1105 Hide select all button on mobile view
Bulk actions are not available on mobile view yet
2019-04-22 12:39:15 +02:00
ArthurHoaro 786f35f270
Merge pull request #1276 from ArthurHoaro/feature/bulk-visibility
Bulk action: set visibility
2019-04-22 12:31:09 +02:00
ArthurHoaro d3defcac1c Display sticky label in linklist
Add sticky label, like private label, in linklist to make it more visible.
2019-04-22 11:26:37 +02:00
ArthurHoaro 8fc0a984f0 Slightly lighten visited link color
To make it more visible in the middle of raw text.
2019-04-22 10:21:33 +02:00
nodiscc e7ffbb7ed1
Merge pull request #1294 from virtualtam/changelog/v0.10.4
Update README, CHANGELOG and AUTHORS for v0.10.4
2019-04-16 00:24:11 +01:00
VirtualTam e92676ace2 Update README, CHANGELOG and AUTHORS for v0.10.4
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-04-16 00:22:15 +02:00
VirtualTam 1e77e0448b Release v0.10.4
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-04-16 00:04:56 +02:00
VirtualTam 1cc5eaf9de backport: Fix a warning if links sticky status isn't set
- initiate its status to false when the link is created
- if not defined, initiate its status to false (can happen if the updater hasn't run)

This is a backport of https://github.com/shaarli/Shaarli/pull/1270

Original author information:

commit b790f900c9
Author: ArthurHoaro <arthur@hoa.ro>
Date:   Sat Feb 9 14:04:16 2019 +0100

Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-04-15 23:57:08 +02:00
ArthurHoaro 0ed9396bfa Fix thumbnails disabling if PHP GD is not installed 2019-04-15 23:51:06 +02:00
ArthurHoaro 0d4c7a9fe3
Merge pull request #1293 from ArthurHoaro/hotfix/history-rename
Hotfix: History controller for the REST API has been renamed in a previous commit
2019-04-15 17:56:06 +02:00
ArthurHoaro 18d2d3ae15 Hotfix: History controller for the REST API has been renamed in a previous commit
but the class name hasn't been updated in index.php
2019-04-15 17:45:58 +02:00
nodiscc b7aad51e8a
Merge pull request #1288 from shaarli/fix-mkdocs
docs: add readthedocs configuration file
2019-03-31 12:05:51 +00:00
nodiscc 6e76474c4d docs: add readthedocs configuration file
workaround for mkdocs incompatibility with python 3.7
https://github.com/rtfd/readthedocs.org/issues/5250
2019-03-30 17:07:31 +01:00
ArthurHoaro d3c813fc15
Merge pull request #1283 from llune/patch-1
add "Select all" string and French translation
2019-03-25 23:35:13 +01:00
ArthurHoaro 008b0f82b6
Merge pull request #1281 from llune/master
accessibility fixes
2019-03-25 23:34:47 +01:00
Luce Carević 1004fd7d59
add "Select all" string and French translation 2019-03-02 14:39:33 +01:00
Luce Carević d3bbf9ee4d
Merge pull request #1 from llune/pr2
delete useless titles
2019-03-02 13:44:37 +01:00
Luce Carević da815e3f2e delete useless titles 2019-03-02 13:40:21 +01:00
Luce Carević de07aad18f fix empty links and hide <i> for screenreaders 2019-03-02 13:32:36 +01:00
Luce Carević c31dd67c5d footer and contentinfo 2019-03-02 12:56:08 +01:00
ArthurHoaro 90e048594a
Merge pull request #1272 from ArthurHoaro/feature/html-lang
Accessibility: specify the HTML lang attribute
2019-03-02 10:54:30 +01:00
ArthurHoaro cc69aad4a9
Merge pull request #1271 from ArthurHoaro/hotfix/thumb-note-retrieve
Do not try to retrieve thumbnails for internal link
2019-03-02 10:54:06 +01:00
Knah Tsaeb 272b07627b Release v0.10.3
-----BEGIN PGP SIGNATURE-----
 
 iQFLBAABCAA1FiEEEv0k8DWUT53dSMUkR6bSrUEA328FAlxxaB0XHHZpcnR1YWx0
 YW1AZmxpYmlkaS5uZXQACgkQR6bSrUEA328mfAf9GA0/rrA/5HMksQ2m9YKN7wJj
 ytCpeGdVksdvm+XRQj8dMp0oZjL+AIuEdd60W9fhMg+lVDlt9kO9GJKDc2kwkinx
 oNxXCl54BYfmlvaW98KF5GWLAkDAUFpaUDg91ZneD1kRXoU9y/NSNiKXZP+GV/L8
 8Niu2z8smypLv0UaRGblpDY+HkVfZkoV2yZJBGEcS9b7wHPy8nVv6rqUb93b+EJM
 IfooUj3DaCoa61dmTFa/a5oWnuu2Iu7F0SfMvL2rFFiMC22nXfSEGpfsKDeYihmG
 fhlSo0Fa665o94BfoetuXNiE2IU5Kez/aDk7sNNKoOoMsbxJPtzg9A0hyKS6eA==
 =xHH4
 -----END PGP SIGNATURE-----

Merge tag 'v0.10.3' into myShaarli_commu

Release v0.10.3
2019-02-28 14:29:52 +01:00
Aurélien Tamisier 5bb384cd27
Merge pull request #1279 from virtualtam/changelog/v0.10.3
Update badges, changelog and documentation for v0.10.3
2019-02-24 15:51:01 +01:00
VirtualTam 86dcb9048f Update badges, changelog and documentation for v0.10.3
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-02-24 15:44:12 +01:00
ArthurHoaro f87dd90f7b
Merge pull request #1275 from ArthurHoaro/doc/drop-php70
Documentation: drop PHP 7.0 compatibility in Shaarli 11.x
2019-02-24 12:27:35 +01:00
ArthurHoaro a8e7da0114 Do not try to retrieve thumbnails for internal link
Also adds a helper function to determine if a link is a note and apply it across multiple files.
2019-02-24 12:25:50 +01:00
ArthurHoaro c21dcc8199
Merge pull request #1270 from ArthurHoaro/hotfix/sticky-warning
Fix a warning if links sticky status isn't set
2019-02-24 11:30:35 +01:00
ArthurHoaro 015314f3c6
Merge pull request #1269 from ArthurHoaro/feature/remove-redirector
Remove the redirector setting
2019-02-24 11:29:56 +01:00
ArthurHoaro 0ee11e9390
Merge pull request #1274 from ArthurHoaro/hotfix/css-buttons-mobile
Fix button overlapping on mobile in linklist
2019-02-24 11:17:05 +01:00
ArthurHoaro c85b9758a6
Merge pull request #1268 from ArthurHoaro/hotfix/thumb-gd-disable
Fix thumbnails disabling if PHP GD is not installed
2019-02-24 11:16:02 +01:00
VirtualTam 1c03b65e2e Release v0.10.3
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-02-23 16:33:36 +01:00
VirtualTam 2c16e8e9a8 fix: ensure HTML tags are stripped from OpenGraph description
Fixes https://github.com/shaarli/Shaarli/issues/1250
Relates to https://github.com/shaarli/Shaarli/issues/1242

Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-02-23 16:28:01 +01:00
VirtualTam 43c77f658a Merge commit '1826e383ecf501302974132fd443cf1ca06e10f6' into v0.10 2019-02-23 16:27:33 +01:00
nodiscc 3dc80d69ab
Merge pull request #1264 from shaarli/doc-fix-mkdocs-warnings
doc: fix invalid links
2019-02-09 18:52:40 +00:00
ArthurHoaro 8d03f705eb Bulk action: set visibility
Added 2 buttons when link checkboxes are checked to set them either public or private.

Related to #572 #1160
2019-02-09 17:59:53 +01:00
ArthurHoaro 899d041137 Documentation: drop PHP 7.0 compatibility in Shaarli 11.x
related to #1249
2019-02-09 17:02:30 +01:00
ArthurHoaro 54ee240878 Fix button overlapping on mobile in linklist 2019-02-09 16:56:24 +01:00
ArthurHoaro b49a04f796 Rewrite IP ban management
This adds a dedicated manager class to handle all ban interactions, which is instantiated and handled by LoginManager.
IPs are now stored in the same format as the datastore, through FileUtils.

Fixes #1032 #587
2019-02-09 16:44:48 +01:00
ArthurHoaro cb974e4747 Accessibility: specify the HTML lang attribute
The lang is based on the user defined one. If the language is automatic, no language will be specified.

Fixes #1216
2019-02-09 14:29:35 +01:00
ArthurHoaro b790f900c9 Fix a warning if links sticky status isn't set
- initiate its status to false when the link is created
  - if not defined, initiate its status to false (can happen if the updater hasn't run)
2019-02-09 14:04:16 +01:00
ArthurHoaro 520d29578c Remove the redirector setting
Fixes #1239
2019-02-09 13:55:11 +01:00
ArthurHoaro 5bd62b5d53 Fix thumbnails disabling if PHP GD is not installed 2019-02-09 13:05:37 +01:00
ArthurHoaro 905f8675a7
Merge pull request #1182 from ArthurHoaro/feature/session-protection-stay-login
Do not check the IP address with session protection disabled
2019-02-09 12:36:31 +01:00
ArthurHoaro 7417e8ac4a
Merge pull request #1229 from ArthurHoaro/travis/php-7.3
Run Shaarli's tests against PHP 7.3 RC1 on Travis
2019-02-09 11:07:58 +01:00
ArthurHoaro 9f0c719c53 Run Shaarli's tests againt PHP 7.3 RC1 on Travis 2019-02-09 11:04:39 +01:00
nodiscc 8d1509e8a6
doc: fix invalid links
Fixes warnings from https://travis-ci.org/shaarli/Shaarli/jobs/486928133
2019-01-31 13:21:34 +00:00
nodiscc 7c13054038
Merge pull request #1261 from trailjeep/patch-1
Update Community-&-Related-software.md
2019-01-31 13:19:30 +00:00
nodiscc 0d41c8584c
Merge branch 'master' into patch-1 2019-01-31 13:13:34 +00:00
nodiscc 1173f8c87a
Merge pull request #1262 from trailjeep/patch-2
Update Community-&-Related-software.md
2019-01-31 13:12:22 +00:00
trailjeep 4d55e4f075
Update Community-&-Related-software.md 2019-01-26 15:51:40 -05:00
trailjeep 913c70d8e7
Update Community-&-Related-software.md 2019-01-26 14:06:54 -05:00
Aurélien Tamisier e664865e2e
Merge pull request #1258 from virtualtam/refactor/phpdoc
Replace Doxygen with phpDocumentor to generate reference documentation
2019-01-23 22:14:32 +01:00
Aurélien Tamisier 92423ce58a
Merge pull request #1257 from virtualtam/security/composer-advisories
composer: enforce PHP security advisories
2019-01-23 22:12:52 +01:00
Aurélien Tamisier 586a9e0065
Merge pull request #1259 from virtualtam/fix/render/strip-opengraph-description
fix: ensure HTML tags are stripped from OpenGraph description
2019-01-21 10:54:30 +01:00
VirtualTam 49106a5d8c fix: ensure HTML tags are stripped from OpenGraph description
Fixes https://github.com/shaarli/Shaarli/issues/1250
Relates to https://github.com/shaarli/Shaarli/issues/1242

Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-18 22:58:09 +01:00
VirtualTam 9eb6055abb doc: remove Doxygen configuration
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-18 22:44:21 +01:00
VirtualTam 7be2a2d5f4 doc: add Make target to run phpDocumentor with Docker
This target provides a convenient way for running phpDocumentor without
cluttering the system's configuration with PHP extensions, nor the
Composer dependencies.

See:
- https://hub.docker.com/r/phpdoc/phpdoc/dockerfile
- https://github.com/phpDocumentor/phpDocumentor2#via-docker

An alternative is to download the PHAR and run it locally:
- https://docs.phpdoc.org/getting-started/installing.html#phar

Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-18 22:40:15 +01:00
VirtualTam 1c53591a43 doc: add phpDocumentor configuration
See:
- https://phpdoc.org/
- https://docs.phpdoc.org/references/configuration.html
- https://github.com/phpDocumentor/phpDocumentor2

Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-18 22:24:58 +01:00
VirtualTam 8f4e9624e6 composer: enforce PHP security advisories
This adds roave/security-advisories to prevent installing PHP packages with
known vulnerabilities with Composer.

See:
- https://github.com/FriendsOfPHP/security-advisories
- https://github.com/Roave/SecurityAdvisories

Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-18 21:35:13 +01:00
Aurélien Tamisier ff3b5dc554
Merge pull request #1248 from virtualtam/refactor/namespacing
Ensure all PHP classes are properly namespaced
2019-01-18 21:26:03 +01:00
VirtualTam dea72c711f Optimize and cleanup imports
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-13 00:04:42 +01:00
VirtualTam a43e7842e4 API: update test regexes to comply with PCRE2
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-13 00:04:42 +01:00
VirtualTam 1a55fc8d63 composer: add and document optional PHP extensions
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-13 00:04:42 +01:00
VirtualTam 9585441734 namespacing: add plugin tests to \Shaarli\Plugin\[...]
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-12 23:11:19 +01:00
VirtualTam e185038834 namespacing: \Shaarli\Plugin\PluginManager
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-12 23:11:19 +01:00
VirtualTam 349b014401 namespacing: \Shaarli\Netscape\NetscapeBookmarkUtils
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-12 23:11:19 +01:00
VirtualTam a932f486f2 namespacing: \Shaarli\Router
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-12 23:11:19 +01:00
VirtualTam 9778a1551c namespacing: \Shaarli\ApplicationUtils
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-12 23:11:19 +01:00
VirtualTam bcf056c9d9 namespacing: \Shaarli\Updater
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-12 23:11:19 +01:00
VirtualTam 92c6439dbc namespacing: add curl-ext to suggested dependencies
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-12 22:47:48 +01:00
VirtualTam fe3713d2e5 namespacing: move LinkUtils along \Shaarli\Bookmark classes
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-12 22:47:48 +01:00
VirtualTam 6696729b88 namespacing: \Shaarli\Bookmark\LinkFilter
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-12 22:47:48 +01:00
VirtualTam f24896b237 namespacing: \Shaarli\Bookmark\LinkDB
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-12 22:47:48 +01:00
VirtualTam a0c4dbd91c namespacing: \Shaarli\FileUtils
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-12 22:47:48 +01:00
VirtualTam 8c0f19c797 namespacing: \Shaarli\Render\{PageBuilder,ThemeUtils}
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-12 22:47:48 +01:00
VirtualTam 51753e403f namespacing: move HTTP utilities along \Shaarli\Http\ classes
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-12 22:47:48 +01:00
VirtualTam fb1b182fbf namespacing: \Shaarli\Http\Url
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-12 22:47:48 +01:00
VirtualTam 00af48d9d2 namespacing: \Shaarli\Http\Base64Url
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-12 22:47:48 +01:00
VirtualTam dfc650aa23 namespacing: \Shaarli\Feed\{Cache,CachedPage,FeedBuilder}
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-12 22:47:48 +01:00
VirtualTam f3d2f25794 namespacing: \Shaarli\Exceptions\IOException
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-12 22:47:48 +01:00
VirtualTam bdc5152d48 namespacing: \Shaarli\History
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2019-01-12 22:47:48 +01:00
nodiscc 1826e383ec
Merge pull request #1233 from shaarli/doc-fix-homepage-icon
doc: fix homepage icon
2019-01-06 01:33:32 +00:00
nodiscc a7c98a07d1
Merge pull request #1251 from shaarli/doc-update-php-compat
doc: update PHP compatibility table
2019-01-06 01:32:28 +00:00
nodiscc 02c70f624e doc: fix homepage icon
The icon did not display properly on https://shaarli.readthedocs.io/en/master/
2019-01-06 02:10:04 +01:00
nodiscc 7062ef4ddd
doc: update PHP compatibility table
Ref https://github.com/shaarli/Shaarli/issues/1249
2018-12-09 14:40:04 +00:00
Aurélien Tamisier 1004742f09
Merge pull request #1234 from virtualtam/lint
Setup PHPCS and cleanup linter configuration
2018-12-02 22:47:41 +01:00
VirtualTam 9d9f6d75b9 lint: fix line-length warnings
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-12-02 22:39:16 +01:00
VirtualTam 067c2dd8f5 lint: apply phpcbf to tests/
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-12-02 22:39:16 +01:00
VirtualTam 93bf0918fa lint: apply phpcbf to index.php
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-12-02 22:39:16 +01:00
VirtualTam a0ab3c3f68 lint: apply phpcbf to plugins/
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-12-02 22:39:16 +01:00
VirtualTam f211e417bf lint: apply phpcbf to application/
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-12-02 22:39:16 +01:00
VirtualTam 04ec8fedd9 lint: setup PHPCS for PSR-1 and PSR-2
Relates to https://github.com/shaarli/Shaarli/issues/95

See:
- https://github.com/squizlabs/PHP_CodeSniffer
- https://github.com/squizlabs/PHP_CodeSniffer/blob/master/phpcs.xml.dist
- https://www.php-fig.org/psr/psr-1/
- https://www.php-fig.org/psr/psr-2/

Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-12-02 22:39:16 +01:00
VirtualTam 37c9c6b4e6 lint: remove unused tools
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-12-02 22:39:16 +01:00
Aurélien Tamisier 5e0a898bb1
Merge pull request #1247 from llune/patch-5
add label to form - accessibility issue
2018-12-02 13:56:16 +01:00
Aurélien Tamisier 027ff329a1
Merge pull request #1246 from llune/patch-4
fix translation string
2018-12-02 13:55:58 +01:00
Luce Carević 0c42c5e359
add label to form - accessibility issue
Don't use placeholder instead of label for form input.
2018-11-30 20:39:13 +01:00
Luce Carević db06c261f6
fix translation string 2018-11-30 20:36:10 +01:00
ArthurHoaro d53d9d01f7
Merge pull request #1236 from bisherbas/patch-1
Update session start condition
2018-11-15 20:10:47 +01:00
Bish Erbas f6380409ac
Update session start condition
Recommended method for PHP >= 5.4.0 as seen here https://stackoverflow.com/questions/6249707/check-if-php-session-has-already-started

Per https://shaarli.readthedocs.io/en/master/Server-configuration/ Shaarli supports PHP >= 5.6
2018-10-31 09:09:35 -04:00
Aurélien Tamisier a605982fa9
Merge pull request #1235 from ilesinge/patch-2
Dead link on dead link detector tool
2018-10-20 12:28:01 +02:00
Alexandre G.-Raymond 6fd287a0a2
Dead link on dead link detector tool
Author's repo moved to Framagit
2018-10-20 11:58:49 +02:00
Aurélien Tamisier d37348efe2
Merge pull request #1230 from virtualtam/composer/netscape-parser
Composer: bump netscape-bookmark-parser to 2.1
2018-10-12 23:07:38 +02:00
nodiscc d3734b0652
Merge pull request #1232 from shaarli/doc-rm-firefox-share
remove firefox share documentation
2018-10-11 10:03:48 +00:00
nodiscc afe4377e4d
Merge pull request #1221 from nodiscc/doc-refactor-index-features2
doc: refactor documentation homepage
2018-10-11 08:09:45 +00:00
nodiscc 37bbfb5f65 remove firefox share documentation
Firefox Share integration has been removed in https://github.com/shaarli/Shaarli/pull/1026
Firefox Share is not available anymore in any ESR/release versions of Firefox
2018-10-11 09:51:14 +02:00
nodiscc 1a9515ff6f
Merge pull request #1231 from shaarli/revert-1220-url-filter
Revert part of #1220
2018-10-11 07:46:15 +00:00
nodiscc 8b2afee16b Revert part of #1220
Fixes #1177
The `url` template filter is [only supported in Mkdocs 1.0+](https://github.com/mkdocs/mkdocs/blob/master/docs/about/release-notes.md#internal-refactor-of-pages-files-and-navigation)
Readthedocs.org uses Mkdocs `0.17.3 ` while `make htmldoc` fetches the [latest version from pypi](https://pypi.org/project/mkdocs/) which is `1.0.4`.
Following https://github.com/shaarli/Shaarli/pull/1220, building the docs fails with https://readthedocs.org/projects/shaarli/builds/7886340/
2018-10-09 19:35:56 +02:00
VirtualTam b41c5ab04c Composer: bump netscape-bookmark-parser to 2.1
Relates to https://github.com/shaarli/Shaarli/issues/1227

Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-10-06 17:30:16 +02:00
ArthurHoaro e95247d41d
Merge pull request #1205 from ArthurHoaro/feature/opengraph
Add OpenGraph meta tags on permalink page
2018-10-06 13:31:07 +02:00
ArthurHoaro a062416918
Merge pull request #1208 from ArthurHoaro/feature/select-all
Add a button to toggle all checkboxes of displayed links
2018-10-06 13:30:29 +02:00
ArthurHoaro 8cac122086
Merge pull request #1211 from ArthurHoaro/hotfix/img-request
Fix a bug making thumbnail to request the current page
2018-10-06 13:29:10 +02:00
ArthurHoaro fa8100c088
Merge pull request #1212 from ArthurHoaro/hotfix/hashtag-md-escape
Fix hashtags with markdown escape enabled
2018-10-06 13:28:51 +02:00
ArthurHoaro 86e1bc713f
Merge pull request #1144 from ArthurHoaro/feature/sticky
Add a button to set links as sticky
2018-10-06 13:22:59 +02:00
ArthurHoaro d9bf5b31ff Sticky feature - Add translation and display for logged out users 2018-10-06 13:13:57 +02:00
ArthurHoaro 4154c25b5f Add a button to set links as sticky
Meaning that they always appear on top of all links

Fixes #186
2018-10-06 12:55:05 +02:00
Knah Tsaeb 71071f144a Add filter for note 2018-10-05 15:32:27 +02:00
Knah Tsaeb ee610d4505 Fix picwall img url for self note 2018-10-05 14:06:27 +02:00
Knah Tsaeb 3801d999a0 Update to V0.10.3 2018-10-05 11:55:51 +02:00
nodiscc 10a7b5cee9
Merge pull request #1220 from nodiscc/doc-robots-noindex-nofollow
add "noindex, nofollow" HTML robots meta-tag to documentation pages
2018-10-03 19:29:20 +00:00
Knah Tsaeb 94716fb2ba Release v0.10.2
-----BEGIN PGP SIGNATURE-----
 
 iQIzBAABCAAdFiEEWe5LuNiFNDXAgI8BOzJIyqqwgW4FAltu2K0ACgkQOzJIyqqw
 gW4fpg/+MfXOj0d4sR3QMgafKHAVtiVmrOydVwqFOjVe+BOjpxHliDtOqo++cquF
 umZ3Ln9D8R3Wocw5cdLOn0/WbS+xMqyLmJWkGb1sn2NS8NWINXwCw6A8QuYF789p
 NmfmhYnXCW8OoX3TWLT1RR/0UL0V2ZJsMYTWfngxM4EVSPkaZc8C7Sjqs4hL/m4w
 uPcHgsCziZjxtGmdFUKLEEoFwxWKIvZTnYNTVegD6uHGb7jNZGXz1kizIpsXHC3p
 LffOpx1bamTbPoNhM0PyTTRAvNF3qBWsWY58Haldv9R60KsxJ7Fxc9PXgt02vUfw
 dGLMuMEd98iArAlovqQCy4/f+r1JhqJUsfj2IDJM5QSTiYWJL6zShHyHoWWifU07
 4eZCOZce3kskRd8kl/0TRqdFKBB1RxIDtEZRBbmIhnkUt8E2fZG+7XPvZiIeTZSc
 9/8y0KAxBnOuWtLny/NE6kS6yNUSlYooTU6kkDZ4lvsJFpHlQKwwuoFDcsD6oY0k
 yZ7lCAJht645pEQAF9b7WaB+qiE55suWFUcXM/uHqRdvl+DhEJE5C/BD7orW2mi9
 CVfjmqEz5UFkalG7cZpb/NB1Rtcm1YT1NlY0h1YMRtT6ZILkgUNZLWb6tuZ2e0CS
 sPvVzSNzyJmw5vRC6MtwAJzRRkqa1cFJ58vnQB1n8N65n/mAFNA=
 =+fbH
 -----END PGP SIGNATURE-----

Merge tag 'v0.10.2' into myShaarli_commu

Release v0.10.2
2018-10-01 15:51:33 +02:00
ArthurHoaro 4adeffd7f4
Merge pull request #1207 from ArthurHoaro/feature/cors
Add CORS headers to REST API responses
2018-09-20 23:34:59 +02:00
ArthurHoaro a4fbe88b6d
Merge pull request #1215 from ArthurHoaro/hotfix/tag-deletion
Fix a JS bug preventing AJAX tag deletion to work
2018-09-20 23:34:44 +02:00
ArthurHoaro bede8e1b63
Merge pull request #1213 from ArthurHoaro/plugins/isso-icon
Isso plugin: add an icon in linklist if enabled
2018-09-20 23:33:44 +02:00
nodiscc 6c44d604a1 doc: server config: basic usage of robots.txt/HTML robots meta-tag/crawler control mechanisms 2018-09-09 16:21:58 +02:00
nodiscc 2b4f391559 add "noindex, nofollow" HTML robots meta-tag to documentation pages
- Customize the "readthedocs" mkdocs theme: https://www.mkdocs.org/user-guide/styling-your-docs/#customizing-a-theme
 - Adds a '<meta name="robots" content="noindex, nofollow">' HTML tag on each page
 - Do not include robots directive on readthedocs.org, only in local builds
2018-09-09 16:21:03 +02:00
nodiscc b817fb0d95 documentation: refactor documentation homepage
- simplify/organize feature list and contributing section
- move bug reporting/contact information to Contributing section
- unclutter

Ref https://github.com/shaarli/Shaarli/issues/1148#issuecomment-397871451 and https://github.com/shaarli/Shaarli/issues/598
2018-08-30 21:09:02 +02:00
ArthurHoaro 4fa9a3c5d8 Fix a JS bug preventing AJAX tag deletion to work
Fixes #1214
2018-08-16 17:25:47 +02:00
ArthurHoaro 0e54e1059f Isso plugin: add an icon in linklist if enabled
Fixes #1075
2018-08-14 13:39:31 +02:00
ArthurHoaro cb7940e2de Fix hashtags with markdown escape enabled
They're now transformed to markdown syntax links before processing them through Parsedown.

Fixes #1210
2018-08-14 12:26:51 +02:00
ArthurHoaro 8c75c43e7e Fix a bug making thumbnail to request the current page 2018-08-14 11:43:54 +02:00
ArthurHoaro f28b73b21f
Merge pull request #1209 from ArthurHoaro/hotfix/history-delete
History: fix a bug on bulk deletion where only one deletion were regi…
2018-08-13 13:24:01 +02:00
ArthurHoaro b54faf4fd9 History: fix a bug on bulk deletion where only one deletion were registred 2018-08-13 13:18:31 +02:00
ArthurHoaro fc574e6454 Add a button to toggle all checkboxes of displayed links
Related to #1160
2018-08-13 13:13:26 +02:00
ArthurHoaro 83eab29ef8
Merge pull request #1206 from ArthurHoaro/hotfix/search-input-size
Fix input size for dropdown search form
2018-08-13 12:23:51 +02:00
ArthurHoaro 5d9bc40d7e Add CORS headers to REST API responses
Fixes #1174
2018-08-13 12:21:10 +02:00
ArthurHoaro a120fb2977 Add OpenGraph meta tags on permalink page
Includes:
  - og:title
  - og:type -> article
  - og:image -> if there is a thumbnail
  - og:url -> permalink
  - og:description -> first 300 chars of raw description
  - article:published_time
  - article:modified_time
  - article:tag -> one OG meta tag for each shaare tag

Fixes #258
2018-08-13 10:55:48 +02:00
ArthurHoaro d94e6e69dd Fix input size for dropdown search form 2018-08-13 10:55:13 +02:00
ArthurHoaro 14077272f4
Merge pull request #1193 from llune/patch-1
Update French translation
2018-08-13 10:48:36 +02:00
ArthurHoaro 5de61c2ca7 badge 2018-08-11 14:39:03 +02:00
llune d0e8ca9224
update translation 2018-08-02 21:11:13 +02:00
llune a71d6641f6
fix boutton 2018-08-02 20:49:23 +02:00
llune 9c91a17ba8
Maj French translation 2018-08-02 20:47:47 +02:00
Knah Tsaeb 48ab8cd53d Release v0.9.7
-----BEGIN PGP SIGNATURE-----
 
 iQFLBAABCAA1FiEEEv0k8DWUT53dSMUkR6bSrUEA328FAlsqZ3wXHHZpcnR1YWx0
 YW1AZmxpYmlkaS5uZXQACgkQR6bSrUEA32+0eQf+PsLsgP5xg9Tm06+qTptfvIjt
 RlupVU4BJTeTNcDdwqMduyvAlM+mpLPcuAnZYsPhv9O/zyT12TPStCPwOG+ETkgS
 QdrJ1X+vc2vb9tjT/gs5p9cfqa9FZQTEVn7jdztFO67fZ1BSB9fSEIXKKr/dG13B
 QV3lQE36mVyPm/AXf7iS+0enyCqw9M7gtYqCEMYPeAFoM7E/obRbN3sUamkuonjx
 ST2jtg7hmyzrq1/HM9UwbLiPZJX/XKCxhrDIAs7sxnWg/frwJeMAgoIy/c3FjdhK
 BMNA5qrEvDOFK6F+WjEhlvSNfKnE5vykObMpP+VL36ID//HEc+BIWBgZAPa+ng==
 =qqHr
 -----END PGP SIGNATURE-----

Merge tag 'v0.9.7' into myShaarli_commu

Release v0.9.7
2018-07-31 14:38:47 +02:00
ArthurHoaro d9ba1cdd44 Do not check the IP address with session protection disabled
This allows the user to stay logged in if his IP changes.

Fixes #1106
2018-07-17 14:13:37 +02:00
VirtualTam 658988f3ae Bump Shaarli version to v0.9.7
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-06-20 16:40:50 +02:00
VirtualTam 5420c87e22
Merge pull request #1157 from virtualtam/v0.9-dockerfile
v0.9 - Build the Docker images from the local sources
2018-06-20 16:29:27 +02:00
VirtualTam decae8c119 docker: build the images from the local sources
Relates to https://github.com/shaarli/Shaarli/issues/1153

Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-06-17 13:21:01 +02:00
VirtualTam 865d57b84a docker: remove current image build resources
Relates to https://github.com/shaarli/Shaarli/issues/1153

Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-06-16 23:29:47 +02:00
VirtualTam 47095cb333 docker: move testing resources to tests/docker
Relates to https://github.com/shaarli/Shaarli/issues/1153

Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-06-16 23:28:55 +02:00
Knah Tsaeb 1a129ca266 Merge branch 'latest' of https://github.com/shaarli/Shaarli into myShaarli_commu 2018-03-27 15:52:53 +02:00
VirtualTam 76004d331b Release v0.9.6
-----BEGIN PGP SIGNATURE-----
 
 iQFLBAABCAA1FiEEEv0k8DWUT53dSMUkR6bSrUEA328FAlq349YXHHZpcnR1YWx0
 YW1AZmxpYmlkaS5uZXQACgkQR6bSrUEA32+riAf/UmmxZHmoNnSBGqleKtIoTVLn
 71zPp9LuQiWxRNOd4oZMi6GWj4kxpwOzDhhkQ9Y7ywTX9K5/ilg2FD2LiJEd5FCt
 xzAeyp+jCThZwlxXOwnPPwD6WtmBf0nkf2j7mNIQq3wmZEQSRkyuE2n0pugaXzXF
 Xe2/plQ72YuARDsoJCkgQqmrK1DBSqE4YPmtpIHnG2k565NUPbZgtORrhcBrJPVc
 2X11DOvtHMoTJADSS+QoBr6r9PQhBonMBGRDhQJN+g3sg1TNv8mQtb4r2F0YU06w
 3cYWMQbBK/rL0KJeJ8ix8xpyCz0dmBLsTnjhIDkTNyy6AyyLBhOXU7DA2rhWdg==
 =xtBU
 -----END PGP SIGNATURE-----

Merge tag 'v0.9.6' into latest

Release v0.9.6
2018-03-25 20:04:42 +02:00
VirtualTam e36479d9ff Bump Shaarli version to v0.9.6
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-03-25 20:00:26 +02:00
VirtualTam d1e8f152f6 httpd: always forward the 'Authorization' header
On some Apache HTTPD setups where the CGI/FastCGI mode is used, the HTTP header
containing the JWT token is not forwarded, which results in the following error
when attempting to use the REST API:

  "401 Not authorized: JWT token not provided"

This patch allows forwarding the 'Authorization' header. An alternative would
be to use the `CGIPassAuth` directive to allow all authorization headers to be
forwarded.

See:
- https://secure.php.net/manual/en/features.http-auth.php#114877
- https://stackoverflow.com/questions/26475885/authorization-header-missing-in-php-post-request
- https://stackoverflow.com/questions/13387516/authorization-header-missing-in-django-rest-framework-is-apache-to-blame
- https://stackoverflow.com/questions/17018586/apache-2-4-php-fpm-and-authorization-headers
- https://httpd.apache.org/docs/2.4/en/mod/core.html#cgipassauth

Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-03-25 19:41:44 +02:00
VirtualTam 4c2f51256f htaccess: prevent accessing resources not managed by SCM
See:
- https://en.internetwache.org/dont-publicly-expose-git-or-how-we-downloaded-your-websites-sourcecode-an-analysis-of-alexas-1m-28-07-2015/
- https://stackoverflow.com/questions/2530372/how-do-i-disable-directory-browsing
- https://httpd.apache.org/docs/current/mod/mod_rewrite.html

Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-03-25 19:41:39 +02:00
Knah Tsaeb cdc426d560 Fix picwall 2018-03-12 16:57:33 +01:00
Knah Tsaeb c111704f8c Auto add link to contact page if contact.php exist 2018-02-13 14:46:06 +01:00
Knah Tsaeb 7a4ff2cd78 Add thumbshot key 2018-02-13 11:41:56 +01:00
Knah Tsaeb d923d1db2f Merge remote-tracking branch 'github/latest' into myShaarli_commu 2018-02-09 16:10:09 +01:00
Knah Tsaeb ba04c60849 Fix markdown editor with myShaarli plugin 2018-02-09 15:56:22 +01:00
ArthurHoaro 2c6e9ce465 Release v0.9.5
-----BEGIN PGP SIGNATURE-----
 
 iQIzBAABCAAdFiEEWe5LuNiFNDXAgI8BOzJIyqqwgW4FAlp0qF0ACgkQOzJIyqqw
 gW5u4A//TkhJ47pye6+O4cdsr6rU29Byz+hvSS+YEaTx1JSxsehR+pxJqye6QSpU
 DmFVJ7fkKKuIyDwEY6yI5mz/We4w+MBbASpzKHTxOar1TdZF+aJn+wIU7R971rJ3
 JbtSvd6inGO3v27g4ACy3GgvWffPMDfRMUp1j855PuJ8gP48c1oppZOiQxEuY9A7
 v5YDsrO3TuqZZl0HywH2/thgZap7LrTFVjPNRcT5CoY//t1gSw/aabUnA7Brw0Xn
 Sg6ejLKF2S273hBurZKyQcuPqPyGZP8SuLP0XgSKbh4JG3IX6K+7AIVfLMJZ1U2r
 MgC8NsKL3ZrDRZjCwz2jyOBLn7a/bbQ1isgvrBiLvsrQsf2OoXbraa5UkF+n20ri
 s4jPwRRIjSWzYmUlWLD+7OIb5HsVFPKqNi0uxnYPkXhEQKGWqsnmK7e99IjvkWhK
 QIaym5p/O6aoXIA0aE8tDq/XOM+SdRii9TlmuSHiT+sU7HtGOJ7OTlW7aKRnaoI0
 18ScTYiJfkjicBe0uZfbGoD4rXPXHg6xSV6IG/F9NzTgGmOm7im20oP9sOWSqVmL
 lX4mycWZRx9YfUjDRnZmqPYHKu7sdfPmNbDiXIr93pubIIF+OzY/kYjZunyDTMQz
 Mv8g9mRdZHuhyuP4lBn1T0EeaNWJj2gwekh1h6B8Fbqsf7gwsBU=
 =YmGW
 -----END PGP SIGNATURE-----

Merge tag 'v0.9.5' into latest

Release v0.9.5
2018-02-02 19:11:29 +01:00
ArthurHoaro 06ca7c102b Bump Shaarli version to v0.9.5
Signed-off-by: ArthurHoaro <arthur@hoa.ro>
2018-02-02 19:04:08 +01:00
ArthurHoaro 5a6161162d Bump Shaarli version to v0.9.4 2018-02-02 19:03:24 +01:00
VirtualTam 5bb7f37139 Bump Shaarli version to v0.9.3
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-02-02 19:03:24 +01:00
ArthurHoaro 033276a8cf Bump Shaarli version to v0.9.2
Signed-off-by: ArthurHoaro <arthur@hoa.ro>
2018-02-02 19:03:24 +01:00
VirtualTam 5c6a45ec94 Bump version to v0.9.1
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-02-02 19:03:24 +01:00
ArthurHoaro e6faed3477 Fix version file 2018-02-02 19:03:24 +01:00
ArthurHoaro 658573678b Bump version to v0.9.0
Signed-off-by: ArthurHoaro <arthur@hoa.ro>
2018-02-02 19:03:24 +01:00
ArthurHoaro a74184e1b0 Release v0.9.4
-----BEGIN PGP SIGNATURE-----
 
 iQIzBAABCAAdFiEEWe5LuNiFNDXAgI8BOzJIyqqwgW4FAlpws1AACgkQOzJIyqqw
 gW5FRw//YU1dW5CUwKjL9LxvQWWZmgm+iwuJP4sohCrySAG/2ZKxCRlJtdD1WGU3
 jF1HufmdDdx0fHiAAKSz5GK+9XVnI1MuGYzTWSTS+pZ1XO5v0nJMskSd+PSkHrs1
 5DaTzFnvwKflN7mKKbFOi9aBo7fIOYp8hmPHOHyDC458MJw7vraSiFjWXih10UW4
 3m3442UQ14Hfwe7uN6kOfxYrNmkyisa1VJshBYs5gs1qP0L4IGMoDIAuDzVCxbcA
 u/olrxfSaScrV9+yFUmUlcBHGq8ejQl20MsfK7QhErbZu6Y3FlcucySGWdzVV5Nr
 39sLFTjgoMhIk8oPt0N0szKH1uaqcNGbgOoo16unVFM/Kkd7kbLRoltTZIaNKyOs
 akqRczDkh8sd6RITsE7JwPEYloJPOLnNUPhTPqLTq9kFlCB8uGzy1VFnVUfSrqHU
 j6b/6xaoZUZ3hynBRLzwaN0wYQXH0jXWBHVbn2aZPSp0tTxhsnudCpPZ0STFu9As
 fv8NwGNejPr4I9hjoiys6ICu0NV+v88SdA347lUoXa2233Wg3EdIv8eAnZeANpkr
 ij0KfFhg7qiHQB8TftZjY9S9ehomw1jxShUkf2xwk7PQUngaKce/1xZAizn10jqj
 kLNTzPRUyVFUhEwYIeSCSOFJ22g7p8GvU+HxCIjystsxGDH3Q8s=
 =N7I1
 -----END PGP SIGNATURE-----

Merge tag 'v0.9.4' into latest

Release v0.9.4
2018-01-30 19:15:30 +01:00
ArthurHoaro 99a5549044 Bump Shaarli version to v0.9.4 2018-01-30 19:00:33 +01:00
VirtualTam 22a30186a5 Bump Shaarli version to v0.9.3
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-01-30 18:59:47 +01:00
ArthurHoaro 468b03a644 Bump Shaarli version to v0.9.2
Signed-off-by: ArthurHoaro <arthur@hoa.ro>
2018-01-30 18:58:13 +01:00
VirtualTam 91531e4604 Bump version to v0.9.1
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-01-30 18:58:13 +01:00
ArthurHoaro 1feafbe5b6 Fix version file 2018-01-30 18:58:13 +01:00
ArthurHoaro af0cd8ec3d Bump version to v0.9.0
Signed-off-by: ArthurHoaro <arthur@hoa.ro>
2018-01-30 18:58:13 +01:00
VirtualTam 57e4a974f7 Release v0.9.3
-----BEGIN PGP SIGNATURE-----
 
 iQFLBAABCAA1FiEEEv0k8DWUT53dSMUkR6bSrUEA328FAlpOYNkXHHZpcnR1YWx0
 YW1AZmxpYmlkaS5uZXQACgkQR6bSrUEA329Qggf/TCRMsuYsL3TtgxeEAwZh+fPG
 TmfsVUpc+3fnfZCYQAPZ4JXzGTvqrPKRewm3xuIj/s+46y5vxLoppLBN9ULhG97F
 rTllSWvl252+A+COZlSNQYRfUt4gmtm4hS7iUTrTzzTLZkuwhr8vkj05+b+gI9N6
 IT76HX/5onKUhZh+5L2ipFRF3KHBcwCaJbUOUT0YtEL/LqcT/F6oPnoagYLfgYDw
 I1E8ewcXyO8aMw98dghGg2xwIHytljRqqZXMUDs03n+50KFwPmP3CzZbohfW5uMV
 KsY79gB79B4pLoB9Slp3vypsoEL8wbfgZCLzMLlqr93xdztOp+bG9MQ9yvInjg==
 =2XAs
 -----END PGP SIGNATURE-----

Merge tag 'v0.9.3' into latest

Release v0.9.3
2018-01-04 18:35:22 +01:00
VirtualTam cb9b87eb1c Bump Shaarli version to v0.9.3
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-01-04 18:13:43 +01:00
VirtualTam 5ec90c7155 Fix XSS vulnerability
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2018-01-04 18:06:49 +01:00
Knah Tsaeb 95d55e9ea2 Fix bad field template 2018-01-04 15:10:22 +01:00
Knah Tsaeb a31f09001f Fix bad field template 2018-01-04 15:07:42 +01:00
Knah Tsaeb 6b0a76373c Update gitignore 2018-01-04 14:50:24 +01:00
Knah Tsaeb 0e94b7b7f8 Add origin plugin 2018-01-04 14:49:40 +01:00
Knah Tsaeb e37a7ab9ec Add myShaarli plugin 2018-01-04 14:49:09 +01:00
Knah Tsaeb aa228207a0 Update myShaarli theme 2018-01-04 14:47:54 +01:00
Knah Tsaeb 8ead0f9219 Make myShaarli theme for Shaarli 0.9.2 2017-12-15 12:16:50 +01:00
Knah Tsaeb 22ee4c71a3 Merge branch 'master' of https://github.com/shaarli/Shaarli into myShaarli_commu 2017-12-15 10:04:41 +01:00
Knah Tsaeb 8732a436eb Merge remote-tracking branch 'commu/latest' into myShaarli_commu 2017-11-03 16:24:12 +01:00
ArthurHoaro b14dfc23dd Release v0.9.2
-----BEGIN PGP SIGNATURE-----
 
 iQIzBAABCAAdFiEEWe5LuNiFNDXAgI8BOzJIyqqwgW4FAlnYq7YACgkQOzJIyqqw
 gW4eqQ/9Ez6vhQSy3PEBma0tLLTaC13BO5nfcxUx4kwQHcob/KiSeov1gNwWeg3M
 d0Op/KTDXQt2fLx/qksb9jJmAoVLtA5Ma4tPYWzpKeGDdmyKetiec4kT4XtzBUii
 6hHc9GZ/mVd5ok3aZ6ZHJ/v+9lqt01rlcyuHHqw0Tzk+r6VOEDauW9ItgS6eBEmA
 Jj2QLuErNa5EsS/FbCrAgQfDX9tI3tPGUGZRMfz+KNQ+CuP0i1Cr3j4XK1RuyoY2
 46GTaEfAqGydZI3KtKbC1agvtw0qjaMiKw8+UJdiVRwrQHJMOxLF6mFjf+wXoY9C
 BNRFRP1ITQpcW7BEyyewB2vcEabKjGeA++SHauBUITv/4wsVNDAP5mdFLaNPGaaF
 LWkSb0Lie6UFYPBcVb+wt6fhcfARGL4cYV4Go/YK8crEEtBJrPceYO0P0gjB6YYh
 2d80KnAJ05BXACFJqG500mgvu0z5Z65MjBzr1FGJ3KuPH+kYFkxsC/ACYlDVUsRz
 2YQSikid3gv4dvCE6u3Kg0DMEtRLSRpj35KwCyU/A4jFXQVdgWzTY5sclzVb3Ldb
 F7jX524Dt2hVxFMuzel25kfyoZ8XzXXybv3Db0RBFkRIABnpy1VvQgcp14GHVUlE
 e6NLSGrewR0UXCM58oa3OY8pMyroW7A42sqimVQdaEiSzc9RjMA=
 =EH6l
 -----END PGP SIGNATURE-----

Merge tag 'v0.9.2' into latest

Release v0.9.2
2017-10-08 15:05:50 +02:00
ArthurHoaro ecccb14e2a Bump Shaarli version to v0.9.2
Signed-off-by: ArthurHoaro <arthur@hoa.ro>
2017-10-07 12:23:44 +02:00
ArthurHoaro 80b15f5d2d Merge branch 'master' into v0.9 2017-10-07 12:22:54 +02:00
VirtualTam 2c049b673a Merge remote-tracking branch 'upstream/v0.9' into latest
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2017-08-23 01:26:03 +02:00
VirtualTam 1ea88ae7d1 Bump version to v0.9.1
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2017-08-23 01:15:46 +02:00
VirtualTam 9d7a02afce Merge branch 'master' into v0.9 2017-08-23 01:08:41 +02:00
ArthurHoaro bf82dcfeb3 Fix version file 2017-05-09 18:15:05 +02:00
ArthurHoaro c318096c7a Fix version file 2017-05-09 18:14:05 +02:00
ArthurHoaro fcf141926d Release v0.9.0
-----BEGIN PGP SIGNATURE-----
 
 iQEzBAABCAAdFiEEGqoBQZDmIumKOObJrZlaKwD4KWQFAlkPUlEACgkQrZlaKwD4
 KWR5+gf/akFTTxy5uyTfB1U36cGpgdvhf6bjosBKlaXY+Nvpu1NF/LX8xpbrSFDY
 phI8gECt2XPD8Nk4eMhpXi9KLMMYWMccVdO5NKuZP+NxtjpYnTTueAthosm7sWqy
 JXtqSJQCDRZJj2GNUhw1WvM/6t2WlmTFUcVRV/2Vq87Hzf0eYnOrTNUXVTsxfc3K
 8TY98qu4XgaMenzTjp35O5wza6kihEW27NXwM4KumWjg+VTgRkfePla5EGdK9BcG
 16wT94WCy29t/gTIEW9Q9Tf+hTO7Oaq0iyN/8Ha0QFXOOutMuHfrhFMezMDGPzzb
 esH47/AT2DzaxfCAIqSorgPVHVYzMA==
 =GN48
 -----END PGP SIGNATURE-----

Merge tag 'v0.9.0' into latest

Release v0.9.0
2017-05-07 19:23:32 +02:00
ArthurHoaro 54c8e8d299 Bump version to v0.9.0
Signed-off-by: ArthurHoaro <arthur@hoa.ro>
2017-05-07 18:48:39 +02:00
VirtualTam 8868f3ca46 UpdaterTest: ensure PHP 5.3 compatibility
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2017-03-04 09:52:48 +01:00
VirtualTam 6b7ddb4871 Bump version to 0.8.4
Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2017-03-04 09:42:26 +01:00
ArthurHoaro 9ff17ae20e Add markdown_escape setting
This setting allows to escape HTML in markdown rendering or not.
The goal behind it is to avoid XSS issue in shared instances.

More info:

  * the setting is set to true by default
  * it is set to false for anyone who already have the plugin enabled
  (avoid breaking existing entries)
  * improve the HTML sanitization when the setting is set to false - but don't consider it XSS proof
  * mention the setting in the plugin README
2017-03-04 09:38:12 +01:00
ArthurHoaro 63bddaad4b Bump version to v0.8.3
Signed-off-by: ArthurHoaro <arthur@hoa.ro>
2017-01-20 16:47:36 +01:00
ArthurHoaro faf8bdda50 Changelog v0.8.3 2017-01-20 16:44:52 +01:00
ArthurHoaro 848939b7ba Fixes can login function call in loginform.html
Fixes #711
2017-01-20 16:41:33 +01:00
Arthur 1022c59df8 Merge pull request #717 from ArthurHoaro/v0.8
Preparation of v0.8.2
2016-12-15 11:58:39 +01:00
ArthurHoaro 455f776a3d Bump version to v0.8.2 2016-12-15 11:52:31 +01:00
ArthurHoaro 5036cffade v0.8.2 Changelog 2016-12-15 11:49:41 +01:00
ArthurHoaro 00be9941f3 Fix a regression: permalinks change when old links are edited
fixes #713
2016-12-15 11:43:42 +01:00
Knah Tsaeb da3abc7591 Fix search tag style 2016-09-16 10:47:07 +02:00
Knah Tsaeb 645557480c Change tag style 2016-09-14 11:13:49 +02:00
Knah Tsaeb e68e261882 [upd] Parsedown to 1.6.0 2016-08-26 14:26:21 +02:00
Knah Tsaeb 0605188d4e Merge branch 'master' into myShaarli 2016-08-25 15:10:55 +02:00
Knah Tsaeb 12180ef604 [fix] checkversion url 2016-08-25 15:04:39 +02:00
Knah Tsaeb 9a3783ed20 [fix] bad merge 2016-08-25 14:27:57 +02:00
Knah Tsaeb 86894a7261 [add] support of Wallabag V2 + option in config manager 2016-08-25 14:11:43 +02:00
Knah Tsaeb 6658463e9e [fix] private icon not show 2016-06-16 09:31:31 +02:00
Knah Tsaeb e9cda12d81 [fix] private icon not show 2016-06-15 09:10:11 +02:00
Knah Tsaeb c29027dd1a Merge branch 'private_link' into myShaarli 2016-06-15 09:03:44 +02:00
Knah Tsaeb 9a49486707 Bump version 2016-06-14 12:01:23 +02:00
Knah Tsaeb 5ac350359c [fix] private icon not show 2016-06-14 11:59:10 +02:00
Knah Tsaeb 34b2678fd9 [fix] bad link for myShaarli repo 2016-06-14 11:41:43 +02:00
Knah Tsaeb 118f40d21e Better indent 2016-06-10 16:26:53 +02:00
Knah Tsaeb a076447c7c Fix bad page title 2016-06-10 16:00:08 +02:00
VirtualTam 35cc3582f0 Logging: improve formatting to enable fail2ban parsing
Fixes #436

Modifications:
- remove calls to strval() on safe data
- update the date format: 'Y/m/d_H:i:s' => 'Y/m/d H:i:s'

Signed-off-by: VirtualTam <virtualtam@flibidi.net>
2016-06-08 12:28:02 +02:00
Knah Tsaeb b7b0894720 [fix] bad merge 167066f4bb 2016-06-08 12:11:55 +02:00
Knah Tsaeb ad03ee9f5f Bump version 2016-06-08 11:59:42 +02:00
ArthurHoaro 20d859380a Fixes #399 - show single link title as page title 2016-06-08 11:56:24 +02:00
Nicolas Danelon 167066f4bb cleanup: remove json_encode() (built-in since PHP 5.2)
See http://php.net/manual/en/function.json-encode.php

Legacy since php 5.2.x . If php5.3 is required for the install script
2016-06-08 11:43:07 +02:00
ArthurHoaro a1c3e68e7a Fixes #382: Bookmarklet can not retrieve title when there is a quotation mark in it
bookmarklet fields weren't correctly escaped
2016-06-08 11:23:38 +02:00
Knah Tsaeb f981ab8a17 [add] implemented opensearch plugins 2016-06-08 11:01:13 +02:00
Knah Tsaeb 042095ae7a [upd] change url to new repo 2016-06-08 09:31:51 +02:00
Knah Tsaeb 72944a7234 [fix] encode of date 2016-02-18 17:00:14 +01:00
Knah Tsaeb 8b2d826eb1 Update index.php 2015-07-24 10:59:00 +02:00
Knah Tsaeb c6a6780a89 [fix] #222 FUD Full Path Disclosure 2015-07-24 10:18:29 +02:00
Knah Tsaeb cfc25f73e7 [fix] additional break line in RSS and Atom feed 2015-07-23 11:20:22 +02:00
Knah Tsaeb 7f51ca3b37 [fix] config not save 2015-07-23 10:53:42 +02:00
Knah Tsaeb 0db6fbd935 [upd] README and remove beta in version 2015-07-21 15:29:46 +02:00
Knah Tsaeb b7538c4a1b [fix] point at end of footer 2015-07-21 14:53:13 +02:00
Knah Tsaeb 4a4046e25c [upd] better install form 2015-07-21 14:50:34 +02:00
Knah Tsaeb 51f119e569 [fix] install doesn't work 2015-07-21 13:49:41 +02:00
Knah Tsaeb 927e67a6a9 [fix] picwall not load 2015-07-21 11:42:36 +02:00
Knah Tsaeb 1c0853cd04 [upd] readme 2015-07-20 17:31:11 +02:00
Knah Tsaeb 1edbcb4f38 [fix] path of image 2015-07-20 16:34:19 +02:00
Knah Tsaeb 00c968f830 [fix] path of image 2015-07-20 16:31:10 +02:00
Knah Tsaeb cef0816903 [add] option for define date format 2015-07-20 16:27:17 +02:00
ArthurHoaro d1be6766f3 #193 add UTF8 by default to autoLocale 2015-07-20 15:21:34 +02:00
ArthurHoaro 31fc9518a3 Fixes autoLocale function by trying several way to find a correct one.
Fix https://github.com/shaarli/Shaarli/issues/184
2015-07-20 15:21:22 +02:00
Knah Tsaeb 1f3a7f78a0 [chg] change some class for theme compatibility 2015-07-20 15:10:22 +02:00
Knah Tsaeb 17699d82dc [upd] use flex input and label form 2015-07-17 14:57:40 +02:00
Knah Tsaeb e89182bacf [add] new template system inspired by communauty fork 2015-07-17 13:49:55 +02:00
Knah Tsaeb b6d9d9b37a [chg] clean up html code and format 2015-07-17 11:28:43 +02:00
Knah Tsaeb d02bf19916 [add] new logo and news favicon 2015-07-16 17:12:59 +02:00
Knah Tsaeb 7bae9485fd [upd] update README and go to myShaarli 1.0.0 beta 2015-07-08 12:00:55 +02:00
nodiscc 4c6847df8b improve tag cloud font size scaling
* use logarithmic scales
 * remove bold style
2015-07-08 10:24:00 +02:00
Knah Tsaeb 132acc4e95 [fix] no version return 2015-07-03 15:02:40 +02:00
Knah Tsaeb 3c20b1071e [upd] clean up id and proper css in paging template 2015-07-03 14:32:04 +02:00
Knah Tsaeb f89abe02e8 [fix] bad position of generated qr-code 2015-07-03 14:06:18 +02:00
Knah Tsaeb 0b7c7fc069 [add] new theme and adapte linklist template 2015-07-03 13:48:53 +02:00
Knah Tsaeb 88f2ebadca [upd] refactor login form 2015-07-03 10:22:39 +02:00
Knah Tsaeb 62c55f9c8c [upd] replace js focus by html5 autofocus 2015-07-03 10:08:37 +02:00
Knah Tsaeb c5eeb78c3c [chg] cleanup html structure 2015-07-03 10:02:58 +02:00
Knah Tsaeb 8afd5016af [chg] remove language="JavaScript" 2015-07-03 09:50:53 +02:00
Knah Tsaeb b74a59fd49 [add] in note post add tag 'note' 2015-07-03 09:42:32 +02:00
Florian Eula e267bf2772 Prevents ?do=addlink from generating a 404 if the user is not logged in
Fixes https://github.com/shaarli/Shaarli/issue/47
2015-07-03 09:32:44 +02:00
Emilien Klein 194cd1cd16 Redirect to home page after deleting a link
Fixes issue 87
2015-07-03 09:30:28 +02:00
Knah Tsaeb 01342dd5a4 [upd] improve removing feedburner.... parameter 2015-07-02 17:17:05 +02:00
Knah Tsaeb 1d1bc6ebe3 [fix] error with autocomplet tag 2015-07-02 17:04:30 +02:00
Knah Tsaeb 799c92d786 [add] new configuration page
[fix] disable thumbnails keep left space in myShaarli theme
2015-07-01 12:20:41 +02:00
Knah Tsaeb d541bf3514 [chg] better html structure 2015-06-29 15:19:32 +02:00
Knah Tsaeb a044da320e [fix] forgot includes.html 2015-06-29 14:51:09 +02:00
Knah Tsaeb d15d267369 [fix] login background 2015-06-29 14:48:52 +02:00
Knah Tsaeb 7708afcc78 [chg] release user.css, merge old user.css and shaarli.css on myShaarli.css 2015-06-29 14:42:09 +02:00
Knah Tsaeb b741e823c7 [chg] reorganize css 2015-06-29 11:36:09 +02:00
Knah Tsaeb 7d0661086e [chg] optimise and clean css 2015-06-29 11:30:57 +02:00
Knah Tsaeb ff50f9c69e [chg] start proper fork of original Shaarli 2015-06-26 17:29:17 +02:00
Knah Tsaeb 9047fb2fd5 [chg] remove javascript autofocus prefer html5 2015-06-26 15:33:17 +02:00
Knah Tsaeb 1f28497fff [add] option for define contact link 2015-06-26 15:23:10 +02:00
Knah Tsaeb cd635a0857 [add] Firefox social API by Marsup d33c5d4c3b 2015-06-26 14:41:36 +02:00
Knah Tsaeb 1f0cf0c35e [chg] bad if syntax 2015-06-26 14:09:33 +02:00
ArthurHoaro 17c45348fe Page title if there is a single link
Fixes #232
2015-06-26 14:03:36 +02:00
Knah Tsaeb 5bc8d56ae8 [fix] small fix 2015-06-26 12:23:23 +02:00
Knah Tsaeb 75d92a11f6 [fix] duplicate id paging_current in paging 2015-05-20 12:30:54 +02:00
Knah Tsaeb 3a6dad3bc4 Merge branch 'myShaarli' of forge.leslibres.org:shaarli into myShaarli 2015-05-20 12:24:13 +02:00
Knah Tsaeb b69f64e3fa [add] option for post original article to wallabag (nodiscc plugin) 2015-05-20 12:23:02 +02:00
Knah Tsaeb 8a93529664 [add] option for post original article to wallabag (nodiscc plugin) 2015-05-20 12:19:47 +02:00
Knah Tsaeb 3737a64ff3 [chg] change rename/delete tag form 2015-05-20 10:40:51 +02:00
Knah Tsaeb 2e05b32a32 [add] markdown documentation
[upd] better css and semantic for edit/add form
2015-05-13 12:07:03 +02:00
Knah Tsaeb 33502774af [upd] better add form css 2015-05-12 16:26:37 +02:00
Knah Tsaeb 7f8cde80f7 [add] option for enable/disable markdown
[fix] enables automatic line breaks
2015-05-07 10:38:19 +02:00
Knah Tsaeb 09fb269e37 [add] insert selected description from bookmarklet as quote (markdown) 2015-05-05 16:36:39 +02:00
nodiscc ade1b1365b thumbnails: force HTTPS for youtube, imgur, vimeo
* other services also provide thumbs over HTTPS, but the rewrite expression is more complex, so left out for now
2015-05-05 16:04:08 +02:00
Qwerty 83a86d2d39 Add Archive.org integration
* adds an "archive" link next to permalinks, linking to the last version of the page on archive.org
2015-05-05 15:56:17 +02:00
ArthurHoaro 1687756741 shaarli/Shaarli#34: Make update check optional
* Add a check box at installation (checked by default)
  * Add a check box in configuration page
2015-05-05 15:36:46 +02:00
nodiscc 3e361b0394 Redirect to homepage after adding a link via "Add Link" dialog
* Fixes https://github.com/shaarli/Shaarli/issues/115
2015-05-05 15:19:29 +02:00
ArthurHoaro f2391a5793 Fixes shaarli/Shaarli#46: allow 'javascript:' links sharing 2015-05-05 15:17:25 +02:00
Knah Tsaeb 27c05d1885 [upd] fix all div width 2015-05-05 15:10:32 +02:00
Knah Tsaeb a90f15a5c2 [upd] css search form 2015-05-05 14:34:29 +02:00
nodiscc e76cb042fa tools dialog: add a 'Add Note' bookmarklet to immediatly open a note (text post) compose window
* Fixes https://github.com/shaarli/Shaarli/issues/142
 * Fixes https://github.com/sebsauvage/Shaarli/issues/59
2015-05-05 12:02:03 +02:00
Knah Tsaeb 6f4fd910a9 [add] markdown support 2015-05-05 11:41:43 +02:00
Knah Tsaeb 086adcd4a9 [fix] bad detection of favicon url 2015-01-30 10:47:07 +01:00
Knah Tsaeb f0bec991d0 Merge branch 'favicon' into myShaarli
Conflicts:
	index.php
2015-01-30 09:37:52 +01:00
Knah Tsaeb 268682859a [add] show favicon of site
[add] fetch and cache favicon
2015-01-29 16:59:59 +01:00
Knah Tsaeb f457180534 Merge branch 'master' into myShaarli 2014-04-04 14:18:43 +02:00
Knah Tsaeb f945bb9b05 Merge branch 'master' of git://github.com/sebsauvage/Shaarli 2014-04-04 14:17:19 +02:00
Knah Tsaeb bb2103a4a2 [add] visual image for private link 2014-04-04 14:16:51 +02:00
Knah Tsaeb 0396d42bba Merge branch 'master' into myShaarli 2014-02-12 10:51:35 +01:00
Knah Tsaeb 020df22d1e Merge branch 'master' of git://github.com/sebsauvage/Shaarli 2014-02-12 10:49:50 +01:00
Knah Tsaeb 921e7020c9 Merge via branch 2014-01-03 09:49:24 +01:00
Knah Tsaeb f1a8ca9cc8 [fix] warning in search form when empty via field 2014-01-03 09:39:02 +01:00
Knah Tsaeb 4123658eae [upd] change via message (@via to Origine ⇒)
[fix] via field for atom
[fix] warning when add new link
2013-12-19 09:53:12 +01:00
Knah Tsaeb 8e2b06fd78 [fix] warning when add new link 2013-12-19 09:37:57 +01:00
Knah Tsaeb b55c95e172 Merge branch 'master' into myShaarli 2013-12-19 09:30:59 +01:00
Knah Tsaeb 5f9bf1b96e Merge branch 'master' into via 2013-12-19 09:30:20 +01:00
Knah Tsaeb a9821c6fcd Merge branch 'master' of git://github.com/sebsauvage/Shaarli 2013-12-19 09:25:58 +01:00
Knah Tsaeb 7a8068a787 [upd] update input label via 2013-11-21 17:10:47 +01:00
Knah Tsaeb 06d803e78e [upd] change via message (@via to Origine =>)
[fix] via field for atom
2013-11-21 16:50:48 +01:00
Knah Tsaeb e8633c6bbe [fix] add url prefix for smallhash url for external thumbshot 2013-10-14 14:41:14 +02:00
Knah Tsaeb f80a51a9bf Merge branch 'master' of git://github.com/sebsauvage/Shaarli 2013-10-11 15:24:33 +02:00
Knah Tsaeb 3c49d5a29a Merge branch 'master' of git://github.com/sebsauvage/Shaarli into via 2013-10-11 15:23:24 +02:00
Knah Tsaeb 040eb18ec8 Add source of link (via imput) 2013-10-11 15:18:37 +02:00
Knah Tsaeb 55ade1a969 Fix bad merge 2013-09-27 10:02:20 +02:00
Knah Tsaeb 6cb22b63c5 Merge branch 'master' into myShaarli
Conflicts:
	tpl/page.header.html
	tpl/picwall.html
	tpl/tagcloud.html
2013-09-27 09:53:07 +02:00
Knah Tsaeb 6f5933d23f Sync with SebSauvage repo 2013-09-27 09:38:01 +02:00
Knah Tsaeb 1f9886dc51 Merge branch 'master' into myShaarli
Conflicts:
	index.php
2013-09-24 13:55:49 +02:00
Knah Tsaeb bd5d37d0ba Merge branch 'master' of git://github.com/sebsauvage/Shaarli 2013-09-24 13:49:24 +02:00
Bronco b607a4c503 Added the possibility to put a description in the bookmarklet's URL
Conflicts:
	index.php
2013-09-16 12:02:34 +02:00
lehollandaisvolant fb57aab74d Ajout d’un UA lors de la récupération d’une page externe (certains site veulent un UA) 2013-09-16 11:47:42 +02:00
Knah Tsaeb 7e929771eb [upd] link to homepage 2013-09-09 10:44:42 +02:00
Knah Tsaeb ba36c44c5c [add] link to contact page 2013-09-09 10:42:27 +02:00
Knah Tsaeb 64f4f387a0 [fix] PHP notice error 2013-08-20 15:01:45 +02:00
Knah Tsaeb 588c4e4be4 Merge branch 'master' into myShaarli 2013-08-07 10:11:37 +02:00
Knah Tsaeb 256545b392 Merge branch 'master' of git://github.com/sebsauvage/Shaarli 2013-08-07 10:09:53 +02:00
Knah Tsaeb 12e74779c4 [fix] small bug (bad empty test) 2013-05-03 10:44:24 +02:00
Knah Tsaeb c26d0303ee [fix] background repeat in login page 2013-04-30 16:24:43 +02:00
Knah Tsaeb c2d24b7827 [add] via input 2013-04-30 16:20:54 +02:00
Knah Tsaeb 5b82e59b33 Add default background color for thumbshot. 2013-04-02 16:17:11 +02:00
Christophe HENRY 1db7867707 typo 2013-03-29 17:04:15 +01:00
Christophe HENRY 6888cc6f90 Adds a configuration variable "titleLink" which allows to customize the
link on the title.

Conflicts:
	tpl/page.header.html
2013-03-29 16:56:24 +01:00
Knah Tsaeb ed5a80e732 [fix] css background linear 2013-03-29 15:59:19 +01:00
Knah Tsaeb 01f59ddf63 Change the tagcloud generation for better variaous size. 2013-03-29 15:51:56 +01:00
Knah Tsaeb 4c02d06d57 Merge remote-tracking branch 'master/master' into myShaarli 2013-03-29 15:48:58 +01:00
Knah Tsaeb 9550bfe181 Move inline CSS style to shaarli.css 2013-03-29 15:37:44 +01:00
Knah Tsaeb dc420191df Move inline CSS style to shaarli.css 2013-03-29 15:21:32 +01:00
Knah Tsaeb b28f3129ef just change order of few element 2013-03-21 12:24:51 +01:00
Knah Tsaeb e4501035c3 Merge remote-tracking branch 'origin/master' into myShaarli 2013-03-21 10:57:51 +01:00
Knah Tsaeb c98a5f2205 Create a personal themes for Shaarli. 2013-03-20 12:31:27 +01:00
Knah Tsaeb 8f2c12ce6a [add] option for use external service for thumbshot 2013-03-19 17:22:50 +01:00
503 changed files with 51349 additions and 14608 deletions

View File

@ -2,9 +2,11 @@ options:
max-warnings: 0
rules:
property-sort-order:
- 1
-
order: 'concentric'
- 0
# Sort order rule does not work with CSS variables: https://github.com/sasstools/sass-lint/issues/1161
# - 1
# -
# order: 'concentric'
no-important:
- 0
no-vendor-prefixes:

13
.gitignore vendored
View File

@ -22,20 +22,26 @@ inc/languages/*/LC_MESSAGES/shaarli.mo
# Development and test resources
coverage
doxygen
sandbox
phpmd.html
phpdoc.xml
# User plugin configuration
plugins/*/config.php
plugins/default_colors/default_colors.css
# HTML documentation
doc/html/
doc/phpdoc/
# 3rd party themes
tpl/*
!tpl/default
!tpl/vintage
!tpl/myShaarli
contact.php
formStyle.css
# Front end
node_modules
@ -46,3 +52,8 @@ tpl/default/img
tpl/vintage/js
tpl/vintage/css
tpl/vintage/img
.composer.lock
# Documented scripts
generate_templates.php

View File

@ -7,31 +7,20 @@ RewriteEngine On
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
# REST API
# Slim URL Redirection
# Ionos Hosting needs RewriteBase /
# RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]
<Limit GET POST PUT DELETE OPTIONS>
<IfModule version_module>
<IfVersion >= 2.4>
Require all granted
</IfVersion>
<IfVersion < 2.4>
Allow from all
Deny from none
</IfVersion>
</IfModule>
<IfModule !version_module>
Require all granted
</IfModule>
</Limit>
<LimitExcept GET POST PUT DELETE OPTIONS>
<LimitExcept GET POST PUT DELETE PATCH OPTIONS>
<IfModule version_module>
<IfVersion >= 2.4>
Require all denied

15
.readthedocs.yml Normal file
View File

@ -0,0 +1,15 @@
# .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 with MkDocs
mkdocs:
configuration: mkdocs.yml
# Optionally set the version of Python and requirements required to build your docs
# https://github.com/rtfd/readthedocs.org/issues/5250
python:
version: 3.5

View File

@ -3,6 +3,8 @@ dist: trusty
matrix:
include:
- language: php
php: 7.3
- language: php
php: 7.2
- language: php

72
AUTHORS
View File

@ -1,42 +1,73 @@
687 ArthurHoaro <arthur@hoa.ro>
355 VirtualTam <virtualtam@flibidi.net>
195 nodiscc <nodiscc@gmail.com>
1206 ArthurHoaro <arthur@hoa.ro>
405 VirtualTam <virtualtam@flibidi.net>
384 nodiscc <nodiscc@gmail.com>
56 Sébastien Sauvage <sebsauvage@sebsauvage.net>
23 dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
19 Keith Carangelo <mail@kcaran.com>
16 Luce Carević <lcarevic@access42.net>
15 Florian Eula <eula.florian@gmail.com>
13 Emilien Klein <emilien@klein.st>
14 Emilien Klein <emilien@klein.st>
12 Nicolas Danelon <hi@nicolasmd.com.ar>
9 Lucas Cimon <lucas.cimon@gmail.com>
9 Willi Eggeling <thewilli@gmail.com>
8 Christophe HENRY <christophe.henry@sbgodin.fr>
6 Immánuel Fodor <immanuelfactor+github@gmail.com>
6 YFdyh000 <yfdyh000@gmail.com>
6 kalvn <kalvnthereal@gmail.com>
6 B. van Berkum <dev@dotmpe.com>
5 Lucas Cimon <lucas.cimon@gmail.com>
6 llune <llune@users.noreply.github.com>
5 Mark Schmitz <kramred@gmail.com>
5 kalvn <kalvnthereal@gmail.com>
5 Sébastien NOBILI <code@pipoprods.org>
4 Alexandre Alapetite <alexandre@alapetite.fr>
4 yude <yudesleepy@gmail.com>
4 David Sferruzza <david.sferruzza@gmail.com>
4 Immánuel Fodor <immanuelfactor+github@gmail.com>
3 Teromene <teromene@teromene.fr>
3 llune <llune@users.noreply.github.com>
2 Chris Kuethe <chris.kuethe@gmail.com>
3 yudete <yu@yude.moe>
3 Agurato <mail.vmonot@gmail.com>
3 Olivier <bourreauolivier@gmail.com>
3 Christoph Stoettner <christoph.stoettner@stoeps.de>
2 Felix Bartels <felix@host-consultants.de>
2 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 Alexandre G.-Raymond <alex@ndre.gr>
2 Qwerty <champlywood@free.fr>
2 Guillaume Virlet <github@virlet.org>
2 Sebastien Wains <sebw@users.noreply.github.com>
2 Stephen Muth <smuth4@gmail.com>
2 Timo Van Neerden <fire@lehollandaisvolant.net>
2 Alexander Railean <alexandr.railean@arculus.de>
2 Doug Breaux <25640850+dougbreaux@users.noreply.github.com>
2 flow.gunso <flow.gunso@gmail.com>
2 Chris Kuethe <chris.kuethe@gmail.com>
2 Ganesh Kandu <kanduganesh@gmail.com>
2 julienCXX <software@chmodplusx.eu>
2 Knah Tsaeb <Knah-Tsaeb@knah-tsaeb.org>
2 philipp-r <philipp-r@users.noreply.github.com>
2 pips <pips@e5150.fr>
1 Adrien Oliva <adrien.oliva@yapbreak.fr>
2 prog-it <pash.vld@gmail.com>
2 trailjeep <trailjeep@gmail.com>
1 leyrer <gitlab@leyrer.priv.at>
1 locness3 <37651007+locness3@users.noreply.github.com>
1 owen bell <66233223+xfnw@users.noreply.github.com>
1 philipp <philipp@philipp.PC.Ubuntu>
1 rfolo9li <50079896+rfolo9li@users.noreply.github.com>
1 sprak3000 <sprak3000+github@gmail.com>
1 yudejp <i@yude.jp>
1 Rajat Hans <rajathans9@gmail.com>
1 Adrien le Maire <adrien@alemaire.be>
1 Alexandre G.-Raymond <alex@ndre.gr>
1 Ajabep <ajabep@users.noreply.github.com>
1 Alexis J <alexis@effingo.be>
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 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>
@ -44,18 +75,31 @@
1 Florian Voigt <flvoigt@me.com>
1 Franck Kerbiriou <FranckKe@users.noreply.github.com>
1 Gary Marigliano <gmarigliano93@gmail.com>
1 Guillaume Virlet <github@virlet.org>
1 Gregory <gregory@nosheep.fr>
1 Hazhar Galeh <78073762+hazhargaleh@users.noreply.github.com>
1 Hg <dev@indigo.re>
1 Jens Kubieziel <github@kubieziel.de>
1 Jonathan Amiez <jonathan.amiez@gmail.com>
1 Jonathan 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 Neros <contact@neros.fr>
1 Nicolas Friedli <nicolas@theologique.ch>
1 Paul van den Burg <github@paulvandenburg.nl>
1 Adrien Oliva <adrien.oliva@yapbreak.fr>
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 <gummibando@gmx.net>
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>

View File

@ -4,6 +4,314 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [v0.12.2](https://github.com/shaarli/Shaarli/releases/tag/v0.12.2) - 2023-03-18
> Docker: use `ghcr.io/shaarli/shaarli` as Docker image instead of `shaarli/shaarli`.
> The `:master` Docker image has been removed, please use `:latest` instead.
> The `:stable` Docker image has been removed, please use `:release` instead.
## Added
- Bulk action: add or delete tag to multiple bookmarks
- New Core Plugin: ReadItLater
- Plugin system: allow plugins to provide custom routes
- Support search highlights when matching URL content
- Support for OR (~) and optional AND (+) operators for tag search
- Russian translation
- Chinese translation
- Export:
- Export: set a bookmark's LAST_MODIFIED attribute to its update timestamp
- Export: set a bookmark's PRIVATE attribute using an integer value
- Add an additional free disk space check before saving the datastore
- curl: support HTTP/2 response code header
- CI:
- Build and push Docker images through Github Actions
- push container images to github registry in addition to dockerhub
- Documentation:
- Add '206 not acceptable' to the Troubleshooting section
- Add mention to Shaarli Archiver
- doc: add note to adjust proxy timeouts or PHP max execution time
- doc: shaarli configuration: mention file:/// URIs
- add "formatter" key to example config.json.php
## Changed
- docker latest: replace dev in shaarli_version.php with the latest commit
- Daily RSS Cache: invalidate cache base on the date
- Update Japanese translations
- Update German translations
- Templates: Inject current template name
- format_date: include timezone in IntlDateFormatter object
- Handle pagination through BookmarkService
- autocapitalize off for username input
- More intuitive label for plugin checkboxes
- Simple and uniform localized website title
- Use rewrited version of Netscape Bookmark Parser
- tests/makefile: rewrite translate target to be compatible with busybox
- PubSubHub Plugin: make 1 external call per request
- Docker:
- newer alpine (for newer PHP) and apk upgrade
- Dockerfile.armhf: upgrade python2 -> python3
- Dockerfile: add php8-gettext package
- update s6 service definition to use php-fpm8
- install php8-ldap in Docker images
- CI:
- use Github Action instead of Travis CI
- use the yarnpkg command instead of yarn
- tools: github actions: fix PHP 8.0 tests
- github actions: add tests for PHP 8.2
- Documentation:
- apache: explicitely ste index.php as DirectoryIndex
- bookmarklet is now working on github.com
- LDAP login support, update php requirements list
- installation/tests: clarify build tools installation procedure
- doc: PHP extensions are also required for development
- doc: move OCI images hosting to ghcr.io
## Fixed
- Error handling if the datastore mutex is not working
- Synchronous metadata retrieval is failing in strict mode
- Improve metadata extraction
- Typo: 'Authentication' ->
- default_colors plugin: update CSS file on color change
- API: POST/PUT Link - properly parse tags string
- Error when using bulk shaare with a single URL
- Bulk Shaare:
- use unique HTML ID
- error with a single URL
- redirection with ending slash
- Bug when trying to access ATOM feed without bookmarks
- Documentation build
- pubsubhubbub hub link in RSS / Atom.
- Monthly views previous/next month links during month
- Resolve PHP 8.1 deprecation warnings
- Fix PHP 8 incompatibility with debug mode enabled
- Fixed Roboto-Regular and Roboto-Bold font declarations
- template/vintage: fix typo in visibility selection link
- Do not display deprecated warnings by default
- Fix a bug when using '/' as a tag separator
- Fix Logger exception: gracefully handle permission issue
- Documentation:
- plugins.md: fix link casing
## Removed
- Daily RSS: Remove relative description (today, yesterday)
- Documentation:
- remove the markdown plugin from the plugins list
- remove duplicate "general" key in example config.php.json
## [v0.12.1](https://github.com/shaarli/Shaarli/releases/tag/v0.12.1) - 2020-11-12
> nginx ([#1628](https://github.com/shaarli/Shaarli/pull/1628)) and Apache ([#1630](https://github.com/shaarli/Shaarli/pull/1630)) configurations have been reviewed. It is recommended that you
> update yours using [the documentation](https://shaarli.readthedocs.io/en/master/Server-configuration/).
> Users using official Docker image will receive updated configuration automatically.
### Added
- Bulk creation of bookmarks
- Server administration tool page (and install page requirements)
- Support any tag separator, not just whitespaces
- Share a private bookmark using a URL with a token
- Add a setting to retrieve bookmark metadata asynchronously (enabled by default)
- Highlight fulltext search results
- Weekly and monthly view/RSS feed for daily page
- MarkdownExtra formatter
- Default formatter: add a setting to disable auto-linkification
- Add mutex on datastore I/O operations to prevent data loss
- PHP 8.0 support
- REST API: allow override of creation and update dates
- Add strict types for bookmarks management
### Changed
- Improve regex and performances to extract HTML metadata (title, description, etc.)
- Support using Shaarli without URL rewriting (prefix URL with `/index.php/`)
- Improve the "Manage tags" tools page
- Use PSR-3 logger for login attempts
- Move utils classes to Shaarli\Helper namespace and folder
- Include php-simplexml in Docker image
- Raise 404 error instead of 500 if permalink access is denied
- Display error details even with dev.debug set to false
- Reviewed nginx configuration
- Reviewed Apache configuration
- Replace vimeo link in demo bookmarks due to IP ban on the demo instance
- Apply PSR-12 on code base, and add CI check using PHPCS
### Fixed
- Compatiliby issue on login with PHP 7.1
- Japanese translations update
- Redirect to referrer after bookmark deletion
- Inject ROOT_PATH in plugin instead of regenerating it everywhere
- Wallabag plugin: minor improvements
- REST API postLink: change relative path to absolute path
- Webpack: fix vintage theme images include
- Docker-compose: fix SSL certificate + add parameter for Docker tag
### Removed
- `config.json.php` new lines in prefix/suffix to prevent issues with Windows PHP
## [v0.12.0](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0) - 2020-10-13
**Save you `data/` folder before updating!**
### Added
- Thumbnailer: add soundcloud.com to list of common media domains
- Markdown rendering is now integrated into Shaarli core
- Add autofocus on tag cloud filter input
- Japanese translations
- Japanese translation: add language to admin configuration page
- Support for PHP 8.0
- Support for local anchor URL (starting with `#`)
- LDAP authentication
- Encapsulated PageCacheManager
- Docs:
- add screenshots of all pages
- section about mkdocs
- Ulauncher extension
- CI: run against PHP 7.4
- Added $links_per_page variable to template and display on default
- Inject BookmarkServiceInterface in plugins data
- Add manual configuration for root URL
- Added PATCH to the allowed Apache request methods.
- REST API: compatibility with ionos Apache's headers
### Changed
- Introduce Bookmark object and Service layer
- Save bookmark as objects in the datastore
- Handle bookmark as objects across the whole codebase (except templates and plugins)
- Process all Shaarli page through Slim controller, with proper URL rewriting (see #1516)
- Docs: the entire documentation has been reviewed, updated and improved, thanks to @nodiscc!
- ATOM feed: use instance name as author name instead of URL
- Updated French translation
- Default colors plugin: generate CSS file during initialization
- Improve default bookmarks after install
- Upgrade all front end dependencies and webpack build
- Default theme: Make tag cloud/list views buttons more obvious
### Fixed
- Undefined index: thumbnail in daily page
- Undefined index: thumbnail on OpenGraph headers
- Undefined index: updated on linklist
- Make sure that bookmark sort is consistent, even with equal timestamps
- Code PHP version check as requirement bumped to PHP 7.1
- Thumbnail images lazy loading
- Markdown plugin: fix RSS feed direct link reverse
- Fix RSS permalink included in Markdown bloc
- Demo plugin: multiple typos
- Makefile target for releases
- Makefile target for html documentation
- Session cookie setting being set while session is active
- Deprecated use of implode
- Division by zero in tag cloud
- CI: deprecated linux distribution and sudo directive
- Docker build: gcc is no longer included in python alpine image
- Default template: display pin button in mobile view
- Pinned bookmarks are not longer displayed first in ATOM/RSS feeds
- Docs:
- Outdated Docker documentation for stable branch
- Outdated links
- Plugin description in meta files
- docker-compose.yml: pin traefik image to 1.7-alpine
### Removed
- Markdown plugin
- Docs:
- emojione & twemoji removed
- Makefile: remove static_analysis_summary from all: target
- doc/Makefile: remove references to composer update
## [v0.11.1](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1) - 2019-08-03
Release to fix broken Docker build on the latest version.
### Fixed
- Fixed Docker build
- Fixed a few documentation broken links
- Fixed broken label in configuration page
### Added
- More accessibility improvements
## [v0.11.0](https://github.com/shaarli/Shaarli/releases/tag/v0.11.0) - 2019-07-27
**Shaarli no longer officially support PHP 5.6 and PHP 7.0 as they've reached end of life.**
**Shaarli classes now use namespace, third party plugins need to update.**
### Added
- Add optional PHP extension to composer suggestions.
- composer: enforce PHP security advisories
- phpDocumentor configuration and make target
- Run unit tests against PHP 7.3
- Bunch of accessibility improvements to the default template, thanks to @llune
- Bulk actions: set visibility
- Display sticky label in linklist
- Add print CSS rules to the default template
- New setting to automatically retrieve description for new bookmarks
- Plugin to override default template colors
### Changed
- Shaarli now uses namespaces for its classes.
- Rewrite IP ban management
- Default template: slightly lighten visited link color
- Hide select all button on mobile view
- Switch from FontAwesome v4.x to ForkAwesome
- Daily - display the current day instead of the previous one
### Fixed
- Do not check the IP address with session protection disabled
- API: update test regexes to comply with PCRE2
- Optimize and cleanup imports
- ensure HTML tags are stripped from OpenGraph description
- Documentation invalid links
- Thumbnails disabling if PHP GD is not installed
- Warning if links sticky status isn't set
- Fix button overlapping on mobile in linklist
- Do not try to retrieve thumbnails for internal link
- Update node-sass to fix a vulnerability in node tar dependency
- armhf Dockerfile
- Default template: Responsive issue with delete button fix
- Persist sticky status on bookmark update
### Removed
- Doxygen configuration
- redirector setting
- QRCode link to an external service
## [v0.10.4](https://github.com/shaarli/Shaarli/releases/tag/v0.10.4) - 2019-04-16
### Fixed
- Fix thumbnails disabling if PHP GD is not installed
- Fix a warning if links sticky status isn't set
## [v0.10.3](https://github.com/shaarli/Shaarli/releases/tag/v0.10.3) - 2019-02-23
### Added
- Add OpenGraph metadata tags on permalink page
- Add CORS headers to REST API reponses
- Add a button to toggle checkboxes of displayed links
- Add an icon to the link list when the Isso plugin is enabled
- Add noindex, nofollow to documentation pages
- Document usage of robots.txt
- Add a button to set links as sticky
### Changed
- Update French translation
- Refactor the documentation homepage
- Bump netscape-bookmark-parser
- Update session_start condition
- Improve accessibility
- Cleanup and refactor lint tooling
### Fixed
- Fix input size for dropdown search form
- Fix history for bulk link deletion
- Fix thumbnail requests
- Fix hashtag rendering when markdown escaping is enabled
- Fix AJAX tag deletion
- Fix lint errors and improve PSR-1 and PSR-2 compliance
### Removed
- Remove Firefox Share documentation
## [v0.10.2](https://github.com/shaarli/Shaarli/releases/tag/v0.10.2) - 2018-08-11
### Fixed
@ -12,7 +320,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## [v0.10.1](https://github.com/shaarli/Shaarli/releases/tag/v0.10.1) - 2018-08-11
### Changed
### Changed
- Accessibility:
- Remove alt text on the logo
@ -46,7 +354,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Use Travis matrix and stages to run Javascript tests in a dedicated environment
- Add tag endpoint in the REST API
- Build the documentation in Travis builds
- Provide a Docker Compose example
- Provide a Docker Compose example
### Changed
- Use web-thumbnailer to retrieve thumbnails (see #687)
@ -247,7 +555,7 @@ configuration to enable URL rewriting, see:
- `/api/v1/info`: get general information on the Shaarli instance
- `/api/v1/links`: get a list of shaared links
- `/api/v1/history`: get a list of latest actions
Theming:
- Theming:
- Introduce a new theme
- Allow selecting themes/templates from the configuration page
- New/Edit link form can be submitted using CTRL+Enter in the textarea

View File

@ -12,7 +12,7 @@ RUN apk --update --no-cache add py2-pip \
# - Resolve PHP dependencies with Composer
FROM arm32v6/alpine:3.8 as composer
COPY --from=docs /usr/src/app/shaarli /app/shaarli
RUN apk --update --no-cache add php7-mbstring composer \
RUN apk --update --no-cache add php7-curl php7-mbstring composer \
&& cd /app/shaarli \
&& composer --prefer-dist --no-dev install
@ -30,9 +30,11 @@ RUN apk --update --no-cache add yarn nodejs-current python2 build-base \
# - Shaarli image
FROM arm32v6/alpine:3.8
LABEL maintainer="Shaarli Community"
MAINTAINER Shaarli Community
RUN apk --update --no-cache add \
ca-certificates \
curl \
nginx \
php7 \
php7-ctype \
@ -44,6 +46,7 @@ RUN apk --update --no-cache add \
php7-json \
php7-mbstring \
php7-openssl \
php7-phar \
php7-session \
php7-xml \
php7-zlib \
@ -53,19 +56,22 @@ COPY .docker/nginx.conf /etc/nginx/nginx.conf
COPY .docker/php-fpm.conf /etc/php7/php-fpm.conf
COPY .docker/services.d /etc/services.d
RUN rm -rf /etc/php7/php-fpm.d/www.conf \
RUN curl -sS https://getcomposer.org/installer | php7 -- --install-dir=/usr/local/bin --filename=composer \
&& rm -rf /etc/php7/php-fpm.d/www.conf \
&& sed -i 's/post_max_size.*/post_max_size = 10M/' /etc/php7/php.ini \
&& sed -i 's/upload_max_filesize.*/upload_max_filesize = 10M/' /etc/php7/php.ini
WORKDIR /var/www
COPY --from=node /shaarli /var/www/shaarli
RUN chown -R nginx:nginx . \
RUN curl -L https://github.com/shaarli/Shaarli/archive/latest.tar.gz | tar xzf - \
&& mv Shaarli-latest shaarli \
&& cd shaarli \
&& composer --prefer-dist --no-dev install \
&& rm -rf ~/.composer \
&& 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

2377
Doxyfile

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,6 @@
# Makefile for PHP code analysis & testing, documentation and release generation
BIN = vendor/bin
PHP_SOURCE = index.php application tests plugins
PHP_COMMA_SOURCE = index.php,application,tests,plugins
all: static_analysis_summary check_permissions test
@ -17,14 +15,6 @@ docker_%:
rsync -az /shaarli/ ~/shaarli/
cd ~/shaarli && make $*
##
# Concise status of the project
# These targets are non-blocking: || exit 0
##
static_analysis_summary: code_sniffer_source copy_paste mess_detector_summary
@echo
##
# PHP_CodeSniffer
# Detects PHP syntax errors
@ -32,70 +22,26 @@ static_analysis_summary: code_sniffer_source copy_paste mess_detector_summary
# - 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
code_sniffer: code_sniffer_full
code_sniffer:
@$(PHPCS)
### - errors filtered by coding standard: PEAR, PSR1, PSR2, Zend...
PHPCS_%:
@$(BIN)/phpcs $(PHP_SOURCE) --report-full --report-width=200 --standard=$*
@$(PHPCS) --report-full --report-width=200 --standard=$*
### - errors by Git author
code_sniffer_blame:
@$(BIN)/phpcs $(PHP_SOURCE) --report-gitblame
@$(PHPCS) --report-gitblame
### - all errors/warnings
code_sniffer_full:
@$(BIN)/phpcs $(PHP_SOURCE) --report-full --report-width=200
@$(PHPCS) --report-full --report-width=200
### - errors grouped by kind
code_sniffer_source:
@$(BIN)/phpcs $(PHP_SOURCE) --report-source || exit 0
##
# PHP Copy/Paste Detector
# Detects code redundancy
# Documentation: https://github.com/sebastianbergmann/phpcpd
##
copy_paste:
@echo "-----------------------"
@echo "PHP COPY/PASTE DETECTOR"
@echo "-----------------------"
@$(BIN)/phpcpd $(PHP_SOURCE) || exit 0
@echo
##
# PHP Mess Detector
# Detects PHP syntax errors, sorted by category
# Rules documentation: http://phpmd.org/rules/index.html
##
MESS_DETECTOR_RULES = cleancode,codesize,controversial,design,naming,unusedcode
mess_title:
@echo "-----------------"
@echo "PHP MESS DETECTOR"
@echo "-----------------"
### - all warnings
mess_detector: mess_title
@$(BIN)/phpmd $(PHP_COMMA_SOURCE) text $(MESS_DETECTOR_RULES) | sed 's_.*\/__'
### - all warnings + HTML output contains links to PHPMD's documentation
mess_detector_html:
@$(BIN)/phpmd $(PHP_COMMA_SOURCE) html $(MESS_DETECTOR_RULES) \
--reportfile phpmd.html || exit 0
### - warnings grouped by message, sorted by descending frequency order
mess_detector_grouped: mess_title
@$(BIN)/phpmd $(PHP_SOURCE) text $(MESS_DETECTOR_RULES) \
| cut -f 2 | sort | uniq -c | sort -nr
### - summary: number of warnings by rule set
mess_detector_summary: mess_title
@for rule in $$(echo $(MESS_DETECTOR_RULES) | tr ',' ' '); do \
warnings=$$($(BIN)/phpmd $(PHP_COMMA_SOURCE) text $$rule | wc -l); \
printf "$$warnings\t$$rule\n"; \
done;
@$(PHPCS) --report-source || exit 0
##
# Checks source file & script permissions
@ -200,10 +146,9 @@ authors:
@git shortlog -sne > AUTHORS
@rm .mailmap
### generate Doxygen documentation
doxygen: clean
@rm -rf doxygen
@doxygen Doxyfile
### 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 MkDocs
htmldoc:

View File

@ -6,18 +6,13 @@ _Do you want to share the links you discover?_
_Shaarli is a minimalist link sharing service that you can install on your own server._
_It is designed to be personal (single-user), fast and handy._
[![](https://img.shields.io/badge/stable-v0.9.7-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.9.7)
[![](https://img.shields.io/travis/shaarli/Shaarli/stable.svg?label=stable)](https://travis-ci.org/shaarli/Shaarli)
&bull;
[![](https://img.shields.io/badge/latest-v0.10.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.10.1)
[![](https://img.shields.io/travis/shaarli/Shaarli/latest.svg?label=latest)](https://travis-ci.org/shaarli/Shaarli)
&bull;
[![](https://img.shields.io/badge/master-v0.10.x-blue.svg)](https://github.com/shaarli/Shaarli)
[![](https://img.shields.io/travis/shaarli/Shaarli.svg?label=master)](https://travis-ci.org/shaarli/Shaarli)
[![](https://img.shields.io/badge/stable-v0.11.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1)
[![](https://img.shields.io/badge/latest-v0.12.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.12.1)
[![](https://img.shields.io/badge/master-v0.12.x-blue.svg)](https://github.com/shaarli/Shaarli)
[![](https://github.com/shaarli/Shaarli/actions/workflows/ci.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)
[![Bountysource](https://www.bountysource.com/badge/team?team_id=19583&style=bounties_received)](https://www.bountysource.com/teams/shaarli/issues)
[![Docker repository](https://img.shields.io/docker/pulls/shaarli/shaarli.svg)](https://hub.docker.com/r/shaarli/shaarli/)
[![Docker repository](https://img.shields.io/docker/pulls/shaarli/shaarli.svg)](https://github.com/shaarli/Shaarli/pkgs/container/shaarli)
## Quickstart

View File

@ -1,4 +1,9 @@
<?php
namespace Shaarli;
use Exception;
use Shaarli\Config\ConfigManager;
/**
* Shaarli (application) utilities
*/
@ -24,7 +29,7 @@ class ApplicationUtils
*
* @return mixed the version code from the repository if available, else 'false'
*/
public static function getLatestGitVersionCode($url, $timeout=2)
public static function getLatestGitVersionCode($url, $timeout = 2)
{
list($headers, $data) = get_http_response($url, $timeout);
@ -51,7 +56,7 @@ class ApplicationUtils
return false;
}
} else {
if (! is_file($remote)) {
if (!is_file($remote)) {
return false;
}
$data = file_get_contents($remote);
@ -86,17 +91,18 @@ class ApplicationUtils
*
* @return mixed the new version code if available and greater, else 'false'
*/
public static function checkUpdate($currentVersion,
$updateFile,
$checkInterval,
$enableCheck,
$isLoggedIn,
$branch='stable')
{
public static function checkUpdate(
$currentVersion,
$updateFile,
$checkInterval,
$enableCheck,
$isLoggedIn,
$branch = 'stable'
) {
// Do not check versions for visitors
// Do not check if the user doesn't want to
// Do not check with dev version
if (! $isLoggedIn || empty($enableCheck) || $currentVersion === 'dev') {
if (!$isLoggedIn || empty($enableCheck) || $currentVersion === 'dev') {
return false;
}
@ -110,7 +116,7 @@ class ApplicationUtils
return false;
}
if (! in_array($branch, self::$GIT_BRANCHES)) {
if (!in_array($branch, self::$GIT_BRANCHES)) {
throw new Exception(
'Invalid branch selected for updates: "' . $branch . '"'
);
@ -122,7 +128,7 @@ class ApplicationUtils
self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE
);
if (! $latestVersion) {
if (!$latestVersion) {
// Only update the file's modification date
file_put_contents($updateFile, $currentVersion);
return false;
@ -151,9 +157,9 @@ class ApplicationUtils
if (version_compare($curVersion, $minVersion) < 0) {
$msg = t(
'Your PHP version is obsolete!'
. ' Shaarli requires at least PHP %s, and thus cannot run.'
. ' Your PHP version has known security vulnerabilities and should be'
. ' updated as soon as possible.'
. ' Shaarli requires at least PHP %s, and thus cannot run.'
. ' Your PHP version has known security vulnerabilities and should be'
. ' updated as soon as possible.'
);
throw new Exception(sprintf($msg, $minVersion));
}
@ -173,50 +179,50 @@ class ApplicationUtils
// Check script and template directories are readable
foreach (array(
'application',
'inc',
'plugins',
$rainTplDir,
$rainTplDir.'/'.$conf->get('resource.theme'),
) as $path) {
if (! is_readable(realpath($path))) {
$errors[] = '"'.$path.'" '. t('directory is not readable');
'application',
'inc',
'plugins',
$rainTplDir,
$rainTplDir . '/' . $conf->get('resource.theme'),
) as $path) {
if (!is_readable(realpath($path))) {
$errors[] = '"' . $path . '" ' . t('directory is not readable');
}
}
// Check cache and data directories are readable and writable
foreach (array(
$conf->get('resource.thumbnails_cache'),
$conf->get('resource.data_dir'),
$conf->get('resource.page_cache'),
$conf->get('resource.raintpl_tmp'),
) as $path) {
if (! is_readable(realpath($path))) {
$errors[] = '"'.$path.'" '. t('directory is not readable');
$conf->get('resource.thumbnails_cache'),
$conf->get('resource.data_dir'),
$conf->get('resource.page_cache'),
$conf->get('resource.raintpl_tmp'),
) as $path) {
if (!is_readable(realpath($path))) {
$errors[] = '"' . $path . '" ' . t('directory is not readable');
}
if (! is_writable(realpath($path))) {
$errors[] = '"'.$path.'" '. t('directory is not writable');
if (!is_writable(realpath($path))) {
$errors[] = '"' . $path . '" ' . t('directory is not writable');
}
}
// Check configuration files are readable and writable
foreach (array(
$conf->getConfigFileExt(),
$conf->get('resource.datastore'),
$conf->get('resource.ban_file'),
$conf->get('resource.log'),
$conf->get('resource.update_check'),
) as $path) {
if (! is_file(realpath($path))) {
$conf->getConfigFileExt(),
$conf->get('resource.datastore'),
$conf->get('resource.ban_file'),
$conf->get('resource.log'),
$conf->get('resource.update_check'),
) as $path) {
if (!is_file(realpath($path))) {
# the file may not exist yet
continue;
}
if (! is_readable(realpath($path))) {
$errors[] = '"'.$path.'" '. t('file is not readable');
if (!is_readable(realpath($path))) {
$errors[] = '"' . $path . '" ' . t('file is not readable');
}
if (! is_writable(realpath($path))) {
$errors[] = '"'.$path.'" '. t('file is not writable');
if (!is_writable(realpath($path))) {
$errors[] = '"' . $path . '" ' . t('file is not writable');
}
}

View File

@ -1,59 +0,0 @@
<?php
/**
* Simple cache system, mainly for the RSS/ATOM feeds
*/
class CachedPage
{
// Directory containing page caches
private $cacheDir;
// Should this URL be cached (boolean)?
private $shouldBeCached;
// Name of the cache file for this URL
private $filename;
/**
* Creates a new CachedPage
*
* @param string $cacheDir page cache directory
* @param string $url page URL
* @param bool $shouldBeCached whether this page needs to be cached
*/
public function __construct($cacheDir, $url, $shouldBeCached)
{
// TODO: check write access to the cache directory
$this->cacheDir = $cacheDir;
$this->filename = $this->cacheDir.'/'.sha1($url).'.cache';
$this->shouldBeCached = $shouldBeCached;
}
/**
* Returns the cached version of a page, if it exists and should be cached
*
* @return string a cached version of the page if it exists, null otherwise
*/
public function cachedVersion()
{
if (!$this->shouldBeCached) {
return null;
}
if (is_file($this->filename)) {
return file_get_contents($this->filename);
}
return null;
}
/**
* Puts a page in the cache
*
* @param string $pageContent XML content to cache
*/
public function cache($pageContent)
{
if (!$this->shouldBeCached) {
return;
}
file_put_contents($this->filename, $pageContent);
}
}

View File

@ -1,296 +0,0 @@
<?php
/**
* FeedBuilder class.
*
* Used to build ATOM and RSS feeds data.
*/
class FeedBuilder
{
/**
* @var string Constant: RSS feed type.
*/
public static $FEED_RSS = 'rss';
/**
* @var string Constant: ATOM feed type.
*/
public static $FEED_ATOM = 'atom';
/**
* @var string Default language if the locale isn't set.
*/
public static $DEFAULT_LANGUAGE = 'en-en';
/**
* @var int Number of links to display in a feed by default.
*/
public static $DEFAULT_NB_LINKS = 50;
/**
* @var LinkDB instance.
*/
protected $linkDB;
/**
* @var string RSS or ATOM feed.
*/
protected $feedType;
/**
* @var array $_SERVER.
*/
protected $serverInfo;
/**
* @var array $_GET.
*/
protected $userInput;
/**
* @var boolean True if the user is currently logged in, false otherwise.
*/
protected $isLoggedIn;
/**
* @var boolean Use permalinks instead of direct links if true.
*/
protected $usePermalinks;
/**
* @var boolean true to hide dates in feeds.
*/
protected $hideDates;
/**
* @var string server locale.
*/
protected $locale;
/**
* @var DateTime Latest item date.
*/
protected $latestDate;
/**
* Feed constructor.
*
* @param LinkDB $linkDB LinkDB instance.
* @param string $feedType Type of feed.
* @param array $serverInfo $_SERVER.
* @param array $userInput $_GET.
* @param boolean $isLoggedIn True if the user is currently logged in, false otherwise.
*/
public function __construct($linkDB, $feedType, $serverInfo, $userInput, $isLoggedIn)
{
$this->linkDB = $linkDB;
$this->feedType = $feedType;
$this->serverInfo = $serverInfo;
$this->userInput = $userInput;
$this->isLoggedIn = $isLoggedIn;
}
/**
* Build data for feed templates.
*
* @return array Formatted data for feeds templates.
*/
public function buildData()
{
// Search for untagged links
if (isset($this->userInput['searchtags']) && empty($this->userInput['searchtags'])) {
$this->userInput['searchtags'] = false;
}
// Optionally filter the results:
$linksToDisplay = $this->linkDB->filterSearch($this->userInput);
$nblinksToDisplay = $this->getNbLinks(count($linksToDisplay));
// Can't use array_keys() because $link is a LinkDB instance and not a real array.
$keys = array();
foreach ($linksToDisplay as $key => $value) {
$keys[] = $key;
}
$pageaddr = escape(index_url($this->serverInfo));
$linkDisplayed = array();
for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) {
$linkDisplayed[$keys[$i]] = $this->buildItem($linksToDisplay[$keys[$i]], $pageaddr);
}
$data['language'] = $this->getTypeLanguage();
$data['last_update'] = $this->getLatestDateFormatted();
$data['show_dates'] = !$this->hideDates || $this->isLoggedIn;
// Remove leading slash from REQUEST_URI.
$data['self_link'] = escape(server_url($this->serverInfo))
. escape($this->serverInfo['REQUEST_URI']);
$data['index_url'] = $pageaddr;
$data['usepermalinks'] = $this->usePermalinks === true;
$data['links'] = $linkDisplayed;
return $data;
}
/**
* Build a feed item (one per shaare).
*
* @param array $link Single link array extracted from LinkDB.
* @param string $pageaddr Index URL.
*
* @return array Link array with feed attributes.
*/
protected function buildItem($link, $pageaddr)
{
$link['guid'] = $pageaddr .'?'. $link['shorturl'];
// Check for both signs of a note: starting with ? and 7 chars long.
if ($link['url'][0] === '?' && strlen($link['url']) === 7) {
$link['url'] = $pageaddr . $link['url'];
}
if ($this->usePermalinks === true) {
$permalink = '<a href="'. $link['url'] .'" title="'. t('Direct link') .'">'. t('Direct link') .'</a>';
} else {
$permalink = '<a href="'. $link['guid'] .'" title="'. t('Permalink') .'">'. t('Permalink') .'</a>';
}
$link['description'] = format_description($link['description'], '', false, $pageaddr);
$link['description'] .= PHP_EOL .'<br>&#8212; '. $permalink;
$pubDate = $link['created'];
$link['pub_iso_date'] = $this->getIsoDate($pubDate);
// atom:entry elements MUST contain exactly one atom:updated element.
if (!empty($link['updated'])) {
$upDate = $link['updated'];
$link['up_iso_date'] = $this->getIsoDate($upDate, DateTime::ATOM);
} else {
$link['up_iso_date'] = $this->getIsoDate($pubDate, DateTime::ATOM);;
}
// Save the more recent item.
if (empty($this->latestDate) || $this->latestDate < $pubDate) {
$this->latestDate = $pubDate;
}
if (!empty($upDate) && $this->latestDate < $upDate) {
$this->latestDate = $upDate;
}
$taglist = array_filter(explode(' ', $link['tags']), 'strlen');
uasort($taglist, 'strcasecmp');
$link['taglist'] = $taglist;
return $link;
}
/**
* Set this to true to use permalinks instead of direct links.
*
* @param boolean $usePermalinks true to force permalinks.
*/
public function setUsePermalinks($usePermalinks)
{
$this->usePermalinks = $usePermalinks;
}
/**
* Set this to true to hide timestamps in feeds.
*
* @param boolean $hideDates true to enable.
*/
public function setHideDates($hideDates)
{
$this->hideDates = $hideDates;
}
/**
* Set the locale. Used to show feed language.
*
* @param string $locale The locale (eg. 'fr_FR.UTF8').
*/
public function setLocale($locale)
{
$this->locale = strtolower($locale);
}
/**
* Get the language according to the feed type, based on the locale:
*
* - RSS format: en-us (default: 'en-en').
* - ATOM format: fr (default: 'en').
*
* @return string The language.
*/
public function getTypeLanguage()
{
// Use the locale do define the language, if available.
if (! empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) {
$length = ($this->feedType == self::$FEED_RSS) ? 5 : 2;
return str_replace('_', '-', substr($this->locale, 0, $length));
}
return ($this->feedType == self::$FEED_RSS) ? 'en-en' : 'en';
}
/**
* Format the latest item date found according to the feed type.
*
* Return an empty string if invalid DateTime is passed.
*
* @return string Formatted date.
*/
protected function getLatestDateFormatted()
{
if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) {
return '';
}
$type = ($this->feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM;
return $this->latestDate->format($type);
}
/**
* Get ISO date from DateTime according to feed type.
*
* @param DateTime $date Date to format.
* @param string|bool $format Force format.
*
* @return string Formatted date.
*/
protected function getIsoDate(DateTime $date, $format = false)
{
if ($format !== false) {
return $date->format($format);
}
if ($this->feedType == self::$FEED_RSS) {
return $date->format(DateTime::RSS);
}
return $date->format(DateTime::ATOM);
}
/**
* Returns the number of link to display according to 'nb' user input parameter.
*
* If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS.
* If 'nb' is set to 'all', display all filtered links (max parameter).
*
* @param int $max maximum number of links to display.
*
* @return int number of links to display.
*/
public function getNbLinks($max)
{
if (empty($this->userInput['nb'])) {
return self::$DEFAULT_NB_LINKS;
}
if ($this->userInput['nb'] == 'all') {
return $max;
}
$intNb = intval($this->userInput['nb']);
if (! is_int($intNb) || $intNb == 0) {
return self::$DEFAULT_NB_LINKS;
}
return $intNb;
}
}

View File

@ -1,6 +1,8 @@
<?php
require_once 'exceptions/IOException.php';
namespace Shaarli;
use Shaarli\Exceptions\IOException;
/**
* Class FileUtils
@ -44,7 +46,7 @@ class FileUtils
return file_put_contents(
$file,
self::$phpPrefix.base64_encode(gzdeflate(serialize($content))).self::$phpSuffix
self::$phpPrefix . base64_encode(gzdeflate(serialize($content))) . self::$phpSuffix
);
}
@ -62,7 +64,7 @@ class FileUtils
{
// Note that gzinflate is faster than gzuncompress.
// See: http://www.php.net/manual/en/function.gzdeflate.php#96439
if (! is_readable($file)) {
if (!is_readable($file)) {
return $default;
}

View File

@ -1,5 +1,12 @@
<?php
namespace Shaarli;
use DateTime;
use Exception;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Helper\FileUtils;
/**
* Class History
*
@ -16,7 +23,7 @@
* - UPDATED: link updated
* - DELETED: link deleted
* - SETTINGS: the settings have been updated through the UI.
* - IMPORT: bulk links import
* - IMPORT: bulk bookmarks import
*
* Note: new events are put at the beginning of the file and history array.
*/
@ -25,27 +32,27 @@ class History
/**
* @var string Action key: a new link has been created.
*/
const CREATED = 'CREATED';
public const CREATED = 'CREATED';
/**
* @var string Action key: a link has been updated.
*/
const UPDATED = 'UPDATED';
public const UPDATED = 'UPDATED';
/**
* @var string Action key: a link has been deleted.
*/
const DELETED = 'DELETED';
public const DELETED = 'DELETED';
/**
* @var string Action key: settings have been updated.
*/
const SETTINGS = 'SETTINGS';
public const SETTINGS = 'SETTINGS';
/**
* @var string Action key: a bulk import has been processed.
*/
const IMPORT = 'IMPORT';
public const IMPORT = 'IMPORT';
/**
* @var string History file path.
@ -66,7 +73,7 @@ class History
* History constructor.
*
* @param string $historyFilePath History file path.
* @param int $retentionTime History content rentention time in seconds.
* @param int $retentionTime History content retention time in seconds.
*
* @throws Exception if something goes wrong.
*/
@ -92,31 +99,31 @@ class History
/**
* Add Event: new link.
*
* @param array $link Link data.
* @param Bookmark $link Link data.
*/
public function addLink($link)
{
$this->addEvent(self::CREATED, $link['id']);
$this->addEvent(self::CREATED, $link->getId());
}
/**
* Add Event: update existing link.
*
* @param array $link Link data.
* @param Bookmark $link Link data.
*/
public function updateLink($link)
{
$this->addEvent(self::UPDATED, $link['id']);
$this->addEvent(self::UPDATED, $link->getId());
}
/**
* Add Event: delete existing link.
*
* @param array $link Link data.
* @param Bookmark $link Link data.
*/
public function deleteLink($link)
{
$this->addEvent(self::DELETED, $link['id']);
$this->addEvent(self::DELETED, $link->getId());
}
/**
@ -130,7 +137,7 @@ class History
/**
* Add Event: bulk import.
*
* Note: we don't store links add/update one by one since it can have a huge impact on performances.
* Note: we don't store bookmarks add/update one by one since it can have a huge impact on performances.
*/
public function importLinks()
{
@ -166,11 +173,11 @@ class History
*/
protected function check()
{
if (! is_file($this->historyFilePath)) {
if (!is_file($this->historyFilePath)) {
FileUtils::writeFlatDB($this->historyFilePath, []);
}
if (! is_writable($this->historyFilePath)) {
if (!is_writable($this->historyFilePath)) {
throw new Exception(t('History file isn\'t readable or writable'));
}
}
@ -191,7 +198,7 @@ class History
*/
protected function write()
{
$comparaison = new DateTime('-'. $this->retentionTime . ' seconds');
$comparaison = new DateTime('-' . $this->retentionTime . ' seconds');
foreach ($this->history as $key => $value) {
if ($value['datetime'] < $comparaison) {
unset($this->history[$key]);

View File

@ -3,7 +3,6 @@
namespace Shaarli;
use Gettext\GettextTranslator;
use Gettext\Merge;
use Gettext\Translations;
use Gettext\Translator;
use Gettext\TranslatorInterface;
@ -42,7 +41,7 @@ class Languages
/**
* Core translations domain
*/
const DEFAULT_DOMAIN = 'shaarli';
public const DEFAULT_DOMAIN = 'shaarli';
/**
* @var TranslatorInterface
@ -77,7 +76,8 @@ class Languages
$this->language = $confLanguage;
}
if (! extension_loaded('gettext')
if (
! extension_loaded('gettext')
|| in_array($this->conf->get('translation.mode', 'auto'), ['auto', 'php'])
) {
$this->initPhpTranslator();
@ -92,14 +92,14 @@ class Languages
/**
* Initialize the translator using php gettext extension (gettext dependency act as a wrapper).
*/
protected function initGettextTranslator ()
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';
$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);
}
@ -122,22 +122,26 @@ class Languages
$translations = new Translations();
// Core translations
try {
$translations = $translations->addFromPoFile('inc/languages/'. $this->language .'/LC_MESSAGES/shaarli.po');
$translations = $translations->addFromPoFile(
'inc/languages/' . $this->language . '/LC_MESSAGES/shaarli.po'
);
$translations->setDomain('shaarli');
$this->translator->loadTranslations($translations);
} catch (\InvalidArgumentException $e) {}
} catch (\InvalidArgumentException $e) {
}
// Default extension translation from the current theme
$theme = $this->conf->get('theme');
$themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') .'/'. $theme .'/language';
$themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') . '/' . $theme . '/language';
if (is_dir($themeTransFolder)) {
try {
$translations = Translations::fromPoFile(
$themeTransFolder .'/'. $this->language .'/LC_MESSAGES/'. $theme .'.po'
$themeTransFolder . '/' . $this->language . '/LC_MESSAGES/' . $theme . '.po'
);
$translations->setDomain($theme);
$this->translator->loadTranslations($translations);
} catch (\InvalidArgumentException $e) {}
} catch (\InvalidArgumentException $e) {
}
}
// Extension translations (plugins, themes, etc.).
@ -147,10 +151,13 @@ class Languages
}
try {
$extension = Translations::fromPoFile($translationPath . $this->language .'/LC_MESSAGES/'. $domain .'.po');
$extension = Translations::fromPoFile(
$translationPath . $this->language . '/LC_MESSAGES/' . $domain . '.po'
);
$extension->setDomain($domain);
$this->translator->loadTranslations($extension);
} catch (\InvalidArgumentException $e) {}
} catch (\InvalidArgumentException $e) {
}
}
}
@ -175,9 +182,12 @@ class Languages
{
return [
'auto' => t('Automatic'),
'de' => t('German'),
'en' => t('English'),
'fr' => t('French'),
'de' => t('German'),
'jp' => t('Japanese'),
'ru' => t('Russian'),
'zh_CN' => t('Chinese (Simplified)'),
];
}
}

View File

@ -1,219 +0,0 @@
<?php
/**
* Get cURL callback function for CURLOPT_WRITEFUNCTION
*
* @param string $charset to extract from the downloaded page (reference)
* @param string $title to extract from the downloaded page (reference)
* @param string $curlGetInfo Optionnaly overrides curl_getinfo function
*
* @return Closure
*/
function get_curl_download_callback(&$charset, &$title, $curlGetInfo = 'curl_getinfo')
{
$isRedirected = false;
/**
* cURL callback function for CURLOPT_WRITEFUNCTION (called during the download).
*
* While downloading the remote page, we check that the HTTP code is 200 and content type is 'html/text'
* Then we extract the title and the charset and stop the download when it's done.
*
* @param resource $ch cURL resource
* @param string $data chunk of data being downloaded
*
* @return int|bool length of $data or false if we need to stop the download
*/
return function(&$ch, $data) use ($curlGetInfo, &$charset, &$title, &$isRedirected) {
$responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
$isRedirected = true;
return strlen($data);
}
if (!empty($responseCode) && $responseCode !== 200) {
return false;
}
// After a redirection, the content type will keep the previous request value
// until it finds the next content-type header.
if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
$contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
}
if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
return false;
}
if (!empty($contentType) && empty($charset)) {
$charset = header_extract_charset($contentType);
}
if (empty($charset)) {
$charset = html_extract_charset($data);
}
if (empty($title)) {
$title = html_extract_title($data);
}
// We got everything we want, stop the download.
if (!empty($responseCode) && !empty($contentType) && !empty($charset) && !empty($title)) {
return false;
}
return strlen($data);
};
}
/**
* Extract title from an HTML document.
*
* @param string $html HTML content where to look for a title.
*
* @return bool|string Extracted title if found, false otherwise.
*/
function html_extract_title($html)
{
if (preg_match('!<title.*?>(.*?)</title>!is', $html, $matches)) {
return trim(str_replace("\n", '', $matches[1]));
}
return false;
}
/**
* Extract charset from HTTP header if it's defined.
*
* @param string $header HTTP header Content-Type line.
*
* @return bool|string Charset string if found (lowercase), false otherwise.
*/
function header_extract_charset($header)
{
preg_match('/charset="?([^; ]+)/i', $header, $match);
if (! empty($match[1])) {
return strtolower(trim($match[1]));
}
return false;
}
/**
* Extract charset HTML content (tag <meta charset>).
*
* @param string $html HTML content where to look for charset.
*
* @return bool|string Charset string if found, false otherwise.
*/
function html_extract_charset($html)
{
// Get encoding specified in HTML header.
preg_match('#<meta .*charset=["\']?([^";\'>/]+)["\']? */?>#Usi', $html, $enc);
if (!empty($enc[1])) {
return strtolower($enc[1]);
}
return false;
}
/**
* Count private links in given linklist.
*
* @param array|Countable $links Linklist.
*
* @return int Number of private links.
*/
function count_private($links)
{
$cpt = 0;
foreach ($links as $link) {
if ($link['private']) {
$cpt += 1;
}
}
return $cpt;
}
/**
* In a string, converts URLs to clickable links.
*
* @param string $text input string.
* @param string $redirector if a redirector is set, use it to gerenate links.
* @param bool $urlEncode Use `urlencode()` on the URL after the redirector or not.
*
* @return string returns $text with all links converted to HTML links.
*
* @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
*/
function text2clickable($text, $redirector = '', $urlEncode = true)
{
$regex = '!(((?:https?|ftp|file)://|apt:|magnet:)\S+[a-z0-9\(\)]/?)!si';
if (empty($redirector)) {
return preg_replace($regex, '<a href="$1">$1</a>', $text);
}
// Redirector is set, urlencode the final URL.
return preg_replace_callback(
$regex,
function ($matches) use ($redirector, $urlEncode) {
$url = $urlEncode ? urlencode($matches[1]) : $matches[1];
return '<a href="' . $redirector . $url .'">'. $matches[1] .'</a>';
},
$text
);
}
/**
* Auto-link hashtags.
*
* @param string $description Given description.
* @param string $indexUrl Root URL.
*
* @return string Description with auto-linked hashtags.
*/
function hashtag_autolink($description, $indexUrl = '')
{
/*
* To support unicode: http://stackoverflow.com/a/35498078/1484919
* \p{Pc} - to match underscore
* \p{N} - numeric character in any script
* \p{L} - letter from any language
* \p{Mn} - any non marking space (accents, umlauts, etc)
*/
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
$replacement = '$1<a href="'. $indexUrl .'?addtag=$2" title="Hashtag $2">#$2</a>';
return preg_replace($regex, $replacement, $description);
}
/**
* This function inserts &nbsp; where relevant so that multiple spaces are properly displayed in HTML
* even in the absence of <pre> (This is used in description to keep text formatting).
*
* @param string $text input text.
*
* @return string formatted text.
*/
function space2nbsp($text)
{
return preg_replace('/(^| ) /m', '$1&nbsp;', $text);
}
/**
* Format Shaarli's description
*
* @param string $description shaare's description.
* @param string $redirector if a redirector is set, use it to gerenate links.
* @param bool $urlEncode Use `urlencode()` on the URL after the redirector or not.
* @param string $indexUrl URL to Shaarli's index.
* @return string formatted description.
*/
function format_description($description, $redirector = '', $urlEncode = true, $indexUrl = '') {
return nl2br(space2nbsp(hashtag_autolink(text2clickable($description, $redirector, $urlEncode), $indexUrl)));
}
/**
* Generate a small hash for a link.
*
* @param DateTime $date Link creation date.
* @param int $id Link ID.
*
* @return string the small hash generated from link data.
*/
function link_small_hash($date, $id)
{
return smallHash($date->format(LinkDB::LINK_DATE_FORMAT) . $id);
}

View File

@ -1,216 +0,0 @@
<?php
use Psr\Log\LogLevel;
use Shaarli\Config\ConfigManager;
use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser;
use Katzgrau\KLogger\Logger;
/**
* Utilities to import and export bookmarks using the Netscape format
* TODO: Not static, use a container.
*/
class NetscapeBookmarkUtils
{
/**
* Filters links and adds Netscape-formatted fields
*
* Added fields:
* - timestamp link addition date, using the Unix epoch format
* - taglist comma-separated tag list
*
* @param LinkDB $linkDb Link datastore
* @param string $selection Which links to export: (all|private|public)
* @param bool $prependNoteUrl Prepend note permalinks with the server's URL
* @param string $indexUrl Absolute URL of the Shaarli index page
*
* @throws Exception Invalid export selection
*
* @return array The links to be exported, with additional fields
*/
public static function filterAndFormat($linkDb, $selection, $prependNoteUrl, $indexUrl)
{
// see tpl/export.html for possible values
if (! in_array($selection, array('all', 'public', 'private'))) {
throw new Exception(t('Invalid export selection:') .' "'.$selection.'"');
}
$bookmarkLinks = array();
foreach ($linkDb as $link) {
if ($link['private'] != 0 && $selection == 'public') {
continue;
}
if ($link['private'] == 0 && $selection == 'private') {
continue;
}
$date = $link['created'];
$link['timestamp'] = $date->getTimestamp();
$link['taglist'] = str_replace(' ', ',', $link['tags']);
if (startsWith($link['url'], '?') && $prependNoteUrl) {
$link['url'] = $indexUrl . $link['url'];
}
$bookmarkLinks[] = $link;
}
return $bookmarkLinks;
}
/**
* Generates an import status summary
*
* @param string $filename name of the file to import
* @param int $filesize size of the file to import
* @param int $importCount how many links were imported
* @param int $overwriteCount how many links were overwritten
* @param int $skipCount how many links were skipped
* @param int $duration how many seconds did the import take
*
* @return string Summary of the bookmark import status
*/
private static function importStatus(
$filename,
$filesize,
$importCount=0,
$overwriteCount=0,
$skipCount=0,
$duration=0
)
{
$status = sprintf(t('File %s (%d bytes) '), $filename, $filesize);
if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) {
$status .= t('has an unknown file format. Nothing was imported.');
} else {
$status .= vsprintf(
t('was successfully processed in %d seconds: %d links imported, %d links overwritten, %d links skipped.'),
[$duration, $importCount, $overwriteCount, $skipCount]
);
}
return $status;
}
/**
* Imports Web bookmarks from an uploaded Netscape bookmark dump
*
* @param array $post Server $_POST parameters
* @param array $files Server $_FILES parameters
* @param LinkDB $linkDb Loaded LinkDB instance
* @param ConfigManager $conf instance
* @param History $history History instance
*
* @return string Summary of the bookmark import status
*/
public static function import($post, $files, $linkDb, $conf, $history)
{
$start = time();
$filename = $files['filetoupload']['name'];
$filesize = $files['filetoupload']['size'];
$data = file_get_contents($files['filetoupload']['tmp_name']);
if (preg_match('/<!DOCTYPE NETSCAPE-Bookmark-file-1>/i', $data) === 0) {
return self::importStatus($filename, $filesize);
}
// Overwrite existing links?
$overwrite = ! empty($post['overwrite']);
// Add tags to all imported links?
if (empty($post['default_tags'])) {
$defaultTags = array();
} else {
$defaultTags = preg_split(
'/[\s,]+/',
escape($post['default_tags'])
);
}
// links are imported as public by default
$defaultPrivacy = 0;
$parser = new NetscapeBookmarkParser(
true, // nested tag support
$defaultTags, // additional user-specified tags
strval(1 - $defaultPrivacy), // defaultPub = 1 - defaultPrivacy
$conf->get('resource.data_dir') // log path, will be overridden
);
$logger = new Logger(
$conf->get('resource.data_dir'),
! $conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG,
[
'prefix' => 'import.',
'extension' => 'log',
]
);
$parser->setLogger($logger);
$bookmarks = $parser->parseString($data);
$importCount = 0;
$overwriteCount = 0;
$skipCount = 0;
foreach ($bookmarks as $bkm) {
$private = $defaultPrivacy;
if (empty($post['privacy']) || $post['privacy'] == 'default') {
// use value from the imported file
$private = $bkm['pub'] == '1' ? 0 : 1;
} elseif ($post['privacy'] == 'private') {
// all imported links are private
$private = 1;
} elseif ($post['privacy'] == 'public') {
// all imported links are public
$private = 0;
}
$newLink = array(
'title' => $bkm['title'],
'url' => $bkm['uri'],
'description' => $bkm['note'],
'private' => $private,
'tags' => $bkm['tags']
);
$existingLink = $linkDb->getLinkFromUrl($bkm['uri']);
if ($existingLink !== false) {
if ($overwrite === false) {
// Do not overwrite an existing link
$skipCount++;
continue;
}
// Overwrite an existing link, keep its date
$newLink['id'] = $existingLink['id'];
$newLink['created'] = $existingLink['created'];
$newLink['updated'] = new DateTime();
$newLink['shorturl'] = $existingLink['shorturl'];
$linkDb[$existingLink['id']] = $newLink;
$importCount++;
$overwriteCount++;
continue;
}
// Add a new link - @ used for UNIX timestamps
$newLinkDate = new DateTime('@'.strval($bkm['time']));
$newLinkDate->setTimezone(new DateTimeZone(date_default_timezone_get()));
$newLink['created'] = $newLinkDate;
$newLink['id'] = $linkDb->getNextId();
$newLink['shorturl'] = link_small_hash($newLink['created'], $newLink['id']);
$linkDb[$newLink['id']] = $newLink;
$importCount++;
}
$linkDb->save($conf->get('resource.page_cache'));
$history->importLinks();
$duration = time() - $start;
return self::importStatus(
$filename,
$filesize,
$importCount,
$overwriteCount,
$skipCount,
$duration
);
}
}

View File

@ -1,245 +0,0 @@
<?php
/**
* Class PluginManager
*
* Use to manage, load and execute plugins.
*/
class PluginManager
{
/**
* List of authorized plugins from configuration file.
* @var array $authorizedPlugins
*/
private $authorizedPlugins;
/**
* List of loaded plugins.
* @var array $loadedPlugins
*/
private $loadedPlugins = array();
/**
* @var ConfigManager Configuration Manager instance.
*/
protected $conf;
/**
* @var array List of plugin errors.
*/
protected $errors;
/**
* Plugins subdirectory.
* @var string $PLUGINS_PATH
*/
public static $PLUGINS_PATH = 'plugins';
/**
* Plugins meta files extension.
* @var string $META_EXT
*/
public static $META_EXT = 'meta';
/**
* Constructor.
*
* @param ConfigManager $conf Configuration Manager instance.
*/
public function __construct(&$conf)
{
$this->conf = $conf;
$this->errors = array();
}
/**
* Load plugins listed in $authorizedPlugins.
*
* @param array $authorizedPlugins Names of plugin authorized to be loaded.
*
* @return void
*/
public function load($authorizedPlugins)
{
$this->authorizedPlugins = $authorizedPlugins;
$dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR);
$dirnames = array_map('basename', $dirs);
foreach ($this->authorizedPlugins as $plugin) {
$index = array_search($plugin, $dirnames);
// plugin authorized, but its folder isn't listed
if ($index === false) {
continue;
}
try {
$this->loadPlugin($dirs[$index], $plugin);
}
catch (PluginFileNotFoundException $e) {
error_log($e->getMessage());
}
}
}
/**
* Execute all plugins registered hook.
*
* @param string $hook name of the hook to trigger.
* @param array $data list of data to manipulate passed by reference.
* @param array $params additional parameters such as page target.
*
* @return void
*/
public function executeHooks($hook, &$data, $params = array())
{
if (!empty($params['target'])) {
$data['_PAGE_'] = $params['target'];
}
if (isset($params['loggedin'])) {
$data['_LOGGEDIN_'] = $params['loggedin'];
}
foreach ($this->loadedPlugins as $plugin) {
$hookFunction = $this->buildHookName($hook, $plugin);
if (function_exists($hookFunction)) {
$data = call_user_func($hookFunction, $data, $this->conf);
}
}
}
/**
* Load a single plugin from its files.
* Call the init function if it exists, and collect errors.
* Add them in $loadedPlugins if successful.
*
* @param string $dir plugin's directory.
* @param string $pluginName plugin's name.
*
* @return void
* @throws PluginFileNotFoundException - plugin files not found.
*/
private function loadPlugin($dir, $pluginName)
{
if (!is_dir($dir)) {
throw new PluginFileNotFoundException($pluginName);
}
$pluginFilePath = $dir . '/' . $pluginName . '.php';
if (!is_file($pluginFilePath)) {
throw new PluginFileNotFoundException($pluginName);
}
$conf = $this->conf;
include_once $pluginFilePath;
$initFunction = $pluginName . '_init';
if (function_exists($initFunction)) {
$errors = call_user_func($initFunction, $this->conf);
if (!empty($errors)) {
$this->errors = array_merge($this->errors, $errors);
}
}
$this->loadedPlugins[] = $pluginName;
}
/**
* Construct normalize hook name for a specific plugin.
*
* Format:
* hook_<plugin_name>_<hook_name>
*
* @param string $hook hook name.
* @param string $pluginName plugin name.
*
* @return string - plugin's hook name.
*/
public function buildHookName($hook, $pluginName)
{
return 'hook_' . $pluginName . '_' . $hook;
}
/**
* Retrieve plugins metadata from *.meta (INI) files into an array.
* Metadata contains:
* - plugin description [description]
* - parameters split with ';' [parameters]
*
* Respects plugins order from settings.
*
* @return array plugins metadata.
*/
public function getPluginsMeta()
{
$metaData = array();
$dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK);
// Browse all plugin directories.
foreach ($dirs as $pluginDir) {
$plugin = basename($pluginDir);
$metaFile = $pluginDir . $plugin . '.' . self::$META_EXT;
if (!is_file($metaFile) || !is_readable($metaFile)) {
continue;
}
$metaData[$plugin] = parse_ini_file($metaFile);
$metaData[$plugin]['order'] = array_search($plugin, $this->authorizedPlugins);
if (isset($metaData[$plugin]['description'])) {
$metaData[$plugin]['description'] = t($metaData[$plugin]['description']);
}
// Read parameters and format them into an array.
if (isset($metaData[$plugin]['parameters'])) {
$params = explode(';', $metaData[$plugin]['parameters']);
} else {
$params = array();
}
$metaData[$plugin]['parameters'] = array();
foreach ($params as $param) {
if (empty($param)) {
continue;
}
$metaData[$plugin]['parameters'][$param]['value'] = '';
// Optional parameter description in parameter.PARAM_NAME=
if (isset($metaData[$plugin]['parameter.'. $param])) {
$metaData[$plugin]['parameters'][$param]['desc'] = t($metaData[$plugin]['parameter.'. $param]);
}
}
}
return $metaData;
}
/**
* Return the list of encountered errors.
*
* @return array List of errors (empty array if none exists).
*/
public function getErrors()
{
return $this->errors;
}
}
/**
* Class PluginFileNotFoundException
*
* Raise when plugin files can't be found.
*/
class PluginFileNotFoundException extends Exception
{
/**
* Construct exception with plugin name.
* Generate message.
*
* @param string $pluginName name of the plugin not found
*/
public function __construct($pluginName)
{
$this->message = sprintf(t('Plugin "%s" files not found.'), $pluginName);
}
}

View File

@ -1,4 +1,5 @@
<?php
namespace Shaarli;
/**
* Class Router
@ -37,6 +38,10 @@ class Router
public static $PAGE_DELETELINK = 'delete_link';
public static $PAGE_CHANGE_VISIBILITY = 'change_visibility';
public static $PAGE_PINLINK = 'pin';
public static $PAGE_EXPORT = 'export';
public static $PAGE_IMPORT = 'import';
@ -73,43 +78,43 @@ class Router
return self::$PAGE_LINKLIST;
}
if (startsWith($query, 'do='. self::$PAGE_LOGIN) && $loggedIn === false) {
if (startsWith($query, 'do=' . self::$PAGE_LOGIN) && $loggedIn === false) {
return self::$PAGE_LOGIN;
}
if (startsWith($query, 'do='. self::$PAGE_PICWALL)) {
if (startsWith($query, 'do=' . self::$PAGE_PICWALL)) {
return self::$PAGE_PICWALL;
}
if (startsWith($query, 'do='. self::$PAGE_TAGCLOUD)) {
if (startsWith($query, 'do=' . self::$PAGE_TAGCLOUD)) {
return self::$PAGE_TAGCLOUD;
}
if (startsWith($query, 'do='. self::$PAGE_TAGLIST)) {
if (startsWith($query, 'do=' . self::$PAGE_TAGLIST)) {
return self::$PAGE_TAGLIST;
}
if (startsWith($query, 'do='. self::$PAGE_OPENSEARCH)) {
if (startsWith($query, 'do=' . self::$PAGE_OPENSEARCH)) {
return self::$PAGE_OPENSEARCH;
}
if (startsWith($query, 'do='. self::$PAGE_DAILY)) {
if (startsWith($query, 'do=' . self::$PAGE_DAILY)) {
return self::$PAGE_DAILY;
}
if (startsWith($query, 'do='. self::$PAGE_FEED_ATOM)) {
if (startsWith($query, 'do=' . self::$PAGE_FEED_ATOM)) {
return self::$PAGE_FEED_ATOM;
}
if (startsWith($query, 'do='. self::$PAGE_FEED_RSS)) {
if (startsWith($query, 'do=' . self::$PAGE_FEED_RSS)) {
return self::$PAGE_FEED_RSS;
}
if (startsWith($query, 'do='. self::$PAGE_THUMBS_UPDATE)) {
if (startsWith($query, 'do=' . self::$PAGE_THUMBS_UPDATE)) {
return self::$PAGE_THUMBS_UPDATE;
}
if (startsWith($query, 'do='. self::$AJAX_THUMB_UPDATE)) {
if (startsWith($query, 'do=' . self::$AJAX_THUMB_UPDATE)) {
return self::$AJAX_THUMB_UPDATE;
}
@ -118,23 +123,23 @@ class Router
return self::$PAGE_LINKLIST;
}
if (startsWith($query, 'do='. self::$PAGE_TOOLS)) {
if (startsWith($query, 'do=' . self::$PAGE_TOOLS)) {
return self::$PAGE_TOOLS;
}
if (startsWith($query, 'do='. self::$PAGE_CHANGEPASSWORD)) {
if (startsWith($query, 'do=' . self::$PAGE_CHANGEPASSWORD)) {
return self::$PAGE_CHANGEPASSWORD;
}
if (startsWith($query, 'do='. self::$PAGE_CONFIGURE)) {
if (startsWith($query, 'do=' . self::$PAGE_CONFIGURE)) {
return self::$PAGE_CONFIGURE;
}
if (startsWith($query, 'do='. self::$PAGE_CHANGETAG)) {
if (startsWith($query, 'do=' . self::$PAGE_CHANGETAG)) {
return self::$PAGE_CHANGETAG;
}
if (startsWith($query, 'do='. self::$PAGE_ADDLINK)) {
if (startsWith($query, 'do=' . self::$PAGE_ADDLINK)) {
return self::$PAGE_ADDLINK;
}
@ -146,23 +151,31 @@ class Router
return self::$PAGE_DELETELINK;
}
if (startsWith($query, 'do='. self::$PAGE_EXPORT)) {
if (isset($get[self::$PAGE_CHANGE_VISIBILITY])) {
return self::$PAGE_CHANGE_VISIBILITY;
}
if (startsWith($query, 'do=' . self::$PAGE_PINLINK)) {
return self::$PAGE_PINLINK;
}
if (startsWith($query, 'do=' . self::$PAGE_EXPORT)) {
return self::$PAGE_EXPORT;
}
if (startsWith($query, 'do='. self::$PAGE_IMPORT)) {
if (startsWith($query, 'do=' . self::$PAGE_IMPORT)) {
return self::$PAGE_IMPORT;
}
if (startsWith($query, 'do='. self::$PAGE_PLUGINSADMIN)) {
if (startsWith($query, 'do=' . self::$PAGE_PLUGINSADMIN)) {
return self::$PAGE_PLUGINSADMIN;
}
if (startsWith($query, 'do='. self::$PAGE_SAVE_PLUGINSADMIN)) {
if (startsWith($query, 'do=' . self::$PAGE_SAVE_PLUGINSADMIN)) {
return self::$PAGE_SAVE_PLUGINSADMIN;
}
if (startsWith($query, 'do='. self::$GET_TOKEN)) {
if (startsWith($query, 'do=' . self::$GET_TOKEN)) {
return self::$GET_TOKEN;
}

View File

@ -3,9 +3,8 @@
namespace Shaarli;
use Shaarli\Config\ConfigManager;
use WebThumbnailer\Exception\WebThumbnailerException;
use WebThumbnailer\WebThumbnailer;
use WebThumbnailer\Application\ConfigManager as WTConfigManager;
use WebThumbnailer\WebThumbnailer;
/**
* Class Thumbnailer
@ -14,7 +13,7 @@ use WebThumbnailer\Application\ConfigManager as WTConfigManager;
*/
class Thumbnailer
{
const COMMON_MEDIA_DOMAINS = [
protected const COMMON_MEDIA_DOMAINS = [
'imgur.com',
'flickr.com',
'youtube.com',
@ -27,13 +26,14 @@ class Thumbnailer
'instagram.com',
'pinterest.com',
'pinterest.fr',
'soundcloud.com',
'tumblr.com',
'deviantart.com',
];
const MODE_ALL = 'all';
const MODE_COMMON = 'common';
const MODE_NONE = 'none';
public const MODE_ALL = 'all';
public const MODE_COMMON = 'common';
public const MODE_NONE = 'none';
/**
* @var WebThumbnailer instance.
@ -55,10 +55,13 @@ class Thumbnailer
$this->conf = $conf;
if (! $this->checkRequirements()) {
$this->conf->set('thumbnails.enabled', false);
$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.'));
die(t(
'php-gd extension must be loaded to use thumbnails. '
. 'Thumbnails are now disabled. Please reload the page.'
));
}
$this->wt = new WebThumbnailer();
@ -78,7 +81,8 @@ class Thumbnailer
*/
public function get($url)
{
if ($this->conf->get('thumbnails.mode') === self::MODE_COMMON
if (
$this->conf->get('thumbnails.mode') === self::MODE_COMMON
&& ! $this->isCommonMediaOrImage($url)
) {
return false;
@ -86,7 +90,7 @@ class Thumbnailer
try {
return $this->wt->thumbnail($url);
} catch (WebThumbnailerException $e) {
} catch (\Throwable $e) {
// Exceptions are only thrown in debug mode.
error_log(get_class($e) . ': ' . $e->getMessage());
}

View File

@ -1,4 +1,5 @@
<?php
/**
* Generates a list of available timezone continents and cities.
*
@ -43,7 +44,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
// Try to split the provided timezone
$spos = strpos($preselectedTimezone, '/');
$pcontinent = substr($preselectedTimezone, 0, $spos);
$pcity = substr($preselectedTimezone, $spos+1);
$pcity = substr($preselectedTimezone, $spos + 1);
}
$continents = [];
@ -60,7 +61,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
}
$continent = substr($tz, 0, $spos);
$city = substr($tz, $spos+1);
$city = substr($tz, $spos + 1);
$cities[] = ['continent' => $continent, 'city' => $city];
$continents[$continent] = true;
}
@ -85,7 +86,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
function isTimeZoneValid($continent, $city)
{
return in_array(
$continent.'/'.$city,
$continent . '/' . $city,
timezone_identifiers_list()
);
}

View File

@ -1,24 +1,27 @@
<?php
/**
* Shaarli utilities
*/
/**
* Logs a message to a text file
* Format log using provided data.
*
* The log format is compatible with fail2ban.
* @param string $message the message to log
* @param string|null $clientIp the client's remote IPv4/IPv6 address
*
* @param string $logFile where to write the logs
* @param string $clientIp the client's remote IPv4/IPv6 address
* @param string $message the message to log
* @return string Formatted message to log
*/
function logm($logFile, $clientIp, $message)
function format_log(string $message, string $clientIp = null): string
{
file_put_contents(
$logFile,
date('Y/m/d H:i:s').' - '.$clientIp.' - '.strval($message).PHP_EOL,
FILE_APPEND
);
$out = $message;
if (!empty($clientIp)) {
// Note: we keep the first dash to avoid breaking fail2ban configs
$out = '- ' . $clientIp . ' - ' . $out;
}
return $out;
}
/**
@ -58,6 +61,7 @@ function smallHash($text)
*/
function startsWith($haystack, $needle, $case = true)
{
$needle = $needle ?? '';
if ($case) {
return (strcmp(substr($haystack, 0, strlen($needle)), $needle) === 0);
}
@ -87,18 +91,22 @@ function endsWith($haystack, $needle, $case = true)
*
* @param mixed $input Data to escape: a single string or an array of strings.
*
* @return string escaped.
* @return string|array escaped.
*/
function escape($input)
{
if (is_bool($input)) {
if (null === $input) {
return null;
}
if (is_bool($input) || is_int($input) || is_float($input) || $input instanceof DateTimeInterface) {
return $input;
}
if (is_array($input)) {
$out = array();
foreach($input as $key => $value) {
$out[$key] = escape($value);
$out = [];
foreach ($input as $key => $value) {
$out[escape($key)] = escape($value);
}
return $out;
}
@ -157,12 +165,12 @@ function checkDateFormat($format, $string)
*
* @return string $referer - final referer.
*/
function generateLocation($referer, $host, $loopTerms = array())
function generateLocation($referer, $host, $loopTerms = [])
{
$finalReferer = '?';
$finalReferer = './?';
// No referer if it contains any value in $loopCriteria.
foreach ($loopTerms as $value) {
foreach (array_filter($loopTerms) as $value) {
if (strpos($referer, $value) !== false) {
return $finalReferer;
}
@ -173,7 +181,7 @@ function generateLocation($referer, $host, $loopTerms = array())
$host = substr($host, 0, $pos);
}
$refererHost = parse_url($referer, PHP_URL_HOST);
$refererHost = parse_url($referer, PHP_URL_HOST) ?? '';
if (!empty($referer) && (strpos($refererHost, $host) !== false || startsWith('?', $refererHost))) {
$finalReferer = $referer;
}
@ -190,7 +198,7 @@ function generateLocation($referer, $host, $loopTerms = array())
function autoLocale($headerLocale)
{
// Default if browser does not send HTTP_ACCEPT_LANGUAGE
$locales = array('en_US', 'en_US.utf8', 'en_US.UTF-8');
$locales = ['en_US.UTF-8', 'en_US.utf8', 'en_US'];
if (! empty($headerLocale)) {
if (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) {
$attempts = [];
@ -285,7 +293,7 @@ function generate_api_secret($username, $salt)
*/
function normalize_spaces($string)
{
return preg_replace('/\s{2,}/', ' ', trim($string));
return preg_replace('/\s{2,}/', ' ', trim($string ?? ''));
}
/**
@ -294,32 +302,52 @@ function normalize_spaces($string)
* Requires php-intl to display international datetimes,
* otherwise default format '%c' will be returned.
*
* @param DateTime $date to format.
* @param bool $time Displays time if true.
* @param bool $intl Use international format if true.
* @param DateTimeInterface $date to format.
* @param bool $time Displays time if true.
* @param bool $intl Use international format if true.
*
* @return bool|string Formatted date, or false if the input is invalid.
*/
function format_date($date, $time = true, $intl = true)
{
if (! $date instanceof DateTime) {
if (! $date instanceof DateTimeInterface) {
return false;
}
if (! $intl || ! class_exists('IntlDateFormatter')) {
$format = $time ? '%c' : '%x';
return strftime($format, $date->getTimestamp());
$format = 'F j, Y';
if ($time) {
$format .= ' h:i:s A \G\M\TP';
}
return $date->format($format);
}
$formatter = new IntlDateFormatter(
setlocale(LC_TIME, 0),
IntlDateFormatter::LONG,
$time ? IntlDateFormatter::LONG : IntlDateFormatter::NONE
);
$formatter->setTimeZone($date->getTimezone());
return $formatter->format($date);
}
/**
* Format the date month according to the locale.
*
* @param DateTimeInterface $date to format.
*
* @return bool|string Formatted date, or false if the input is invalid.
*/
function format_month(DateTimeInterface $date)
{
if (! $date instanceof DateTimeInterface) {
return false;
}
return strftime('%B', $date->getTimestamp());
}
/**
* Check if the input is an integer, no matter its real type.
*
@ -353,12 +381,17 @@ function return_bytes($val)
return $val;
}
$val = trim($val);
$last = strtolower($val[strlen($val)-1]);
$last = strtolower($val[strlen($val) - 1]);
$val = intval(substr($val, 0, -1));
switch($last) {
case 'g': $val *= 1024;
case 'm': $val *= 1024;
case 'k': $val *= 1024;
switch ($last) {
case 'g':
$val *= 1024;
// do no break in order 1024^2 for each unit
case 'm':
$val *= 1024;
// do no break in order 1024^2 for each unit
case 'k':
$val *= 1024;
}
return $val;
}
@ -445,13 +478,28 @@ function alphabetical_sort(&$data, $reverse = false, $byKeys = false)
* Wrapper function for translation which match the API
* of gettext()/_() and ngettext().
*
* @param string $text Text to translate.
* @param string $nText The plural message ID.
* @param int $nb The number of items for plural forms.
* @param string $domain The domain where the translation is stored (default: shaarli).
* @param string $text Text to translate.
* @param string $nText The plural message ID.
* @param int $nb The number of items for plural forms.
* @param string $domain The domain where the translation is stored (default: shaarli).
* @param array $variables Associative array of variables to replace in translated text.
* @param bool $fixCase Apply `ucfirst` on the translated string, might be useful for strings with variables.
*
* @return string Text translated.
*/
function t($text, $nText = '', $nb = 1, $domain = 'shaarli') {
return dn__($domain, $text, $nText, $nb);
function t($text, $nText = '', $nb = 1, $domain = 'shaarli', $variables = [], $fixCase = false)
{
$postFunction = $fixCase ? 'ucfirst' : function ($input) {
return $input;
};
return $postFunction(dn__($domain, $text, $nText, $nb, $variables));
}
/**
* Converts an exception into a printable stack trace string.
*/
function exception2text(Throwable $e): string
{
return $e->getMessage() . PHP_EOL . $e->getFile() . $e->getLine() . PHP_EOL . $e->getTraceAsString();
}

View File

@ -1,9 +1,11 @@
<?php
namespace Shaarli\Api;
use Shaarli\Api\Exceptions\ApiException;
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;
@ -65,13 +67,20 @@ class ApiMiddleware
try {
$this->checkRequest($request);
$response = $next($request, $response);
} catch(ApiException $e) {
} catch (ApiException $e) {
$e->setResponse($response);
$e->setDebug($this->conf->get('dev.debug', false));
$response = $e->getApiResponse();
}
return $response;
return $response
->withHeader('Access-Control-Allow-Origin', '*')
->withHeader(
'Access-Control-Allow-Headers',
'X-Requested-With, Content-Type, Accept, Origin, Authorization'
)
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
;
}
/**
@ -98,8 +107,12 @@ class ApiMiddleware
*
* @throws ApiAuthorizationException The token couldn't be validated.
*/
protected function checkToken($request) {
if (! $request->hasHeader('Authorization')) {
protected function checkToken($request)
{
if (
!$request->hasHeader('Authorization')
&& !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])
) {
throw new ApiAuthorizationException('JWT token not provided');
}
@ -107,7 +120,11 @@ class ApiMiddleware
throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration');
}
$authorization = $request->getHeaderLine('Authorization');
if (isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])) {
$authorization = $this->container->environment['REDIRECT_HTTP_AUTHORIZATION'];
} else {
$authorization = $request->getHeaderLine('Authorization');
}
if (! preg_match('/^Bearer (.*)/i', $authorization, $matches)) {
throw new ApiAuthorizationException('Invalid JWT header');
@ -117,7 +134,7 @@ class ApiMiddleware
}
/**
* Instantiate a new LinkDB including private links,
* Instantiate a new LinkDB including private bookmarks,
* and load in the Slim container.
*
* FIXME! LinkDB could use a refactoring to avoid this trick.
@ -126,12 +143,12 @@ class ApiMiddleware
*/
protected function setLinkDb($conf)
{
$linkDb = new \LinkDB(
$conf->get('resource.datastore'),
true,
$conf->get('privacy.hide_public_links'),
$conf->get('redirector.url'),
$conf->get('redirector.encode_url')
$linkDb = new BookmarkFileService(
$conf,
$this->container->get('pluginManager'),
$this->container->get('history'),
new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2),
true
);
$this->container['db'] = $linkDb;
}

View File

@ -1,8 +1,10 @@
<?php
namespace Shaarli\Api;
use Shaarli\Base64Url;
use Shaarli\Api\Exceptions\ApiAuthorizationException;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Http\Base64Url;
/**
* REST API utilities
@ -12,9 +14,11 @@ class ApiUtils
/**
* Validates a JWT token authenticity.
*
* @param string $token JWT token extracted from the headers.
* @param string $token JWT token extracted from the headers.
* @param string $secret API secret set in the settings.
*
* @return bool true on success
*
* @throws ApiAuthorizationException the token is not valid.
*/
public static function validateJwtToken($token, $secret)
@ -24,7 +28,7 @@ class ApiUtils
throw new ApiAuthorizationException('Malformed JWT token');
}
$genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] .'.'. $parts[1], $secret, true));
$genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] . '.' . $parts[1], $secret, true));
if ($parts[2] != $genSign) {
throw new ApiAuthorizationException('Invalid JWT signature');
}
@ -39,39 +43,42 @@ class ApiUtils
throw new ApiAuthorizationException('Invalid JWT payload');
}
if (empty($payload->iat)
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 array $link Link data read from the datastore.
* @param string $indexUrl Shaarli's index URL (used for relative URL).
* @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($link, $indexUrl)
public static function formatLink($bookmark, $indexUrl)
{
$out['id'] = $link['id'];
$out['id'] = $bookmark->getId();
// Not an internal link
if ($link['url'][0] != '?') {
$out['url'] = $link['url'];
if (! $bookmark->isNote()) {
$out['url'] = $bookmark->getUrl();
} else {
$out['url'] = $indexUrl . $link['url'];
$out['url'] = rtrim($indexUrl, '/') . '/' . ltrim($bookmark->getUrl(), '/');
}
$out['shorturl'] = $link['shorturl'];
$out['title'] = $link['title'];
$out['description'] = $link['description'];
$out['tags'] = preg_split('/\s+/', $link['tags'], -1, PREG_SPLIT_NO_EMPTY);
$out['private'] = $link['private'] == true;
$out['created'] = $link['created']->format(\DateTime::ATOM);
if (! empty($link['updated'])) {
$out['updated'] = $link['updated']->format(\DateTime::ATOM);
$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'] = '';
}
@ -79,58 +86,72 @@ class ApiUtils
}
/**
* Convert a link given through a request, to a valid link for LinkDB.
* Convert a link given through a request, to a valid Bookmark for the datastore.
*
* If no URL is provided, it will generate a local note URL.
* If no title is provided, it will use the URL as title.
*
* @param array $input Request Link.
* @param bool $defaultPrivate Request Link.
* @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 array Formatted link.
* @return Bookmark instance.
*/
public static function buildLinkFromRequest($input, $defaultPrivate)
{
$input['url'] = ! empty($input['url']) ? cleanup_url($input['url']) : '';
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;
}
$link = [
'title' => ! empty($input['title']) ? $input['title'] : $input['url'],
'url' => $input['url'],
'description' => ! empty($input['description']) ? $input['description'] : '',
'tags' => ! empty($input['tags']) ? implode(' ', $input['tags']) : '',
'private' => $private,
'created' => new \DateTime(),
];
return $link;
$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 array $oldLink data
* @param array $newLink data
* @param Bookmark $oldLink data
* @param Bookmark $newLink data
*
* @return array $oldLink updated with $newLink values
* @return Bookmark $oldLink updated with $newLink values
*/
public static function updateLink($oldLink, $newLink)
{
foreach (['title', 'url', 'description', 'tags', 'private'] as $field) {
$oldLink[$field] = $newLink[$field];
}
$oldLink['updated'] = new \DateTime();
if (empty($oldLink['url'])) {
$oldLink['url'] = '?' . $oldLink['shorturl'];
}
if (empty($oldLink['title'])) {
$oldLink['title'] = $oldLink['url'];
}
$oldLink->setTitle($newLink->getTitle());
$oldLink->setUrl($newLink->getUrl());
$oldLink->setDescription($newLink->getDescription());
$oldLink->setTags($newLink->getTags());
$oldLink->setPrivate($newLink->isPrivate());
return $oldLink;
}
@ -139,7 +160,7 @@ class ApiUtils
* Format a Tag for the REST API.
*
* @param string $tag Tag name
* @param int $occurrences Number of links using this tag
* @param int $occurrences Number of bookmarks using this tag
*
* @return array Link data formatted for the REST API.
*/

View File

@ -2,8 +2,10 @@
namespace Shaarli\Api\Controllers;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
use \Slim\Container;
use Shaarli\History;
use Slim\Container;
/**
* Abstract Class ApiController
@ -25,12 +27,12 @@ abstract class ApiController
protected $conf;
/**
* @var \LinkDB
* @var BookmarkServiceInterface
*/
protected $linkDb;
protected $bookmarkService;
/**
* @var \History
* @var History
*/
protected $history;
@ -41,7 +43,7 @@ abstract class ApiController
/**
* ApiController constructor.
*
*
* Note: enabling debug mode displays JSON with readable formatting.
*
* @param Container $ci Slim container.
@ -50,7 +52,7 @@ abstract class ApiController
{
$this->ci = $ci;
$this->conf = $ci->get('conf');
$this->linkDb = $ci->get('db');
$this->bookmarkService = $ci->get('db');
$this->history = $ci->get('history');
if ($this->conf->get('dev.debug', false)) {
$this->jsonStyle = JSON_PRETTY_PRINT;

View File

@ -1,6 +1,5 @@
<?php
namespace Shaarli\Api\Controllers;
use Shaarli\Api\Exceptions\ApiBadParametersException;
@ -14,7 +13,7 @@ use Slim\Http\Response;
*
* @package Shaarli\Api\Controllers
*/
class History extends ApiController
class HistoryController extends ApiController
{
/**
* Service providing operation regarding Shaarli datastore and settings.
@ -31,18 +30,17 @@ class History extends ApiController
$history = $this->history->getHistory();
// Return history operations from the {offset}th, starting from {since}.
$since = \DateTime::createFromFormat(\DateTime::ATOM, $request->getParam('since'));
$since = \DateTime::createFromFormat(\DateTime::ATOM, $request->getParam('since', ''));
$offset = $request->getParam('offset');
if (empty($offset)) {
$offset = 0;
}
elseif (ctype_digit($offset)) {
} elseif (ctype_digit($offset)) {
$offset = (int) $offset;
} else {
throw new ApiBadParametersException('Invalid offset');
}
// limit parameter is either a number of links or 'all' for everything.
// limit parameter is either a number of bookmarks or 'all' for everything.
$limit = $request->getParam('limit');
if (empty($limit)) {
$limit = count($history);

View File

@ -2,12 +2,13 @@
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
@ -17,7 +18,7 @@ class Info extends ApiController
{
/**
* Service providing various information about Shaarli instance.
*
*
* @param Request $request Slim request.
* @param Response $response Slim response.
*
@ -26,15 +27,15 @@ class Info extends ApiController
public function getInfo($request, $response)
{
$info = [
'global_counter' => count($this->linkDb),
'private_counter' => count_private($this->linkDb),
'settings' => array(
'global_counter' => $this->bookmarkService->count(),
'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE),
'settings' => [
'title' => $this->conf->get('general.title', 'Shaarli'),
'header_link' => $this->conf->get('general.header_link', '?'),
'timezone' => $this->conf->get('general.timezone', 'UTC'),
'enabled_plugins' => $this->conf->get('general.enabled_plugins', []),
'default_private_links' => $this->conf->get('privacy.default_private_links', false),
),
],
];
return $response->withJson($info, 200, $this->jsonStyle);

View File

@ -11,7 +11,7 @@ use Slim\Http\Response;
/**
* Class Links
*
* REST API Controller: all services related to links collection.
* REST API Controller: all services related to bookmarks collection.
*
* @package Api\Controllers
* @see http://shaarli.github.io/api-documentation/#links-links-collection
@ -19,12 +19,12 @@ use Slim\Http\Response;
class Links extends ApiController
{
/**
* @var int Number of links returned if no limit is provided.
* @var int Number of bookmarks returned if no limit is provided.
*/
public static $DEFAULT_LIMIT = 20;
/**
* Retrieve a list of links, allowing different filters.
* Retrieve a list of bookmarks, allowing different filters.
*
* @param Request $request Slim request.
* @param Response $response Slim response.
@ -36,49 +36,48 @@ class Links extends ApiController
public function getLinks($request, $response)
{
$private = $request->getParam('visibility');
$links = $this->linkDb->filterSearch(
[
'searchtags' => $request->getParam('searchtags', ''),
'searchterm' => $request->getParam('searchterm', ''),
],
false,
$private
);
// Return links from the {offset}th link, starting from 0.
// Return bookmarks from the {offset}th link, starting from 0.
$offset = $request->getParam('offset');
if (! empty($offset) && ! ctype_digit($offset)) {
throw new ApiBadParametersException('Invalid offset');
}
$offset = ! empty($offset) ? intval($offset) : 0;
if ($offset > count($links)) {
return $response->withJson([], 200, $this->jsonStyle);
}
// limit parameter is either a number of links or 'all' for everything.
// limit parameter is either a number of bookmarks or 'all' for everything.
$limit = $request->getParam('limit');
if (empty($limit)) {
$limit = self::$DEFAULT_LIMIT;
} elseif (ctype_digit($limit)) {
$limit = intval($limit);
} elseif ($limit === 'all') {
$limit = count($links);
$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 = [];
$index = 0;
foreach ($links as $link) {
if (count($out) >= $limit) {
break;
}
if ($index++ >= $offset) {
$out[] = ApiUtils::formatLink($link, $indexUrl);
}
foreach ($searchResult->getBookmarks() as $bookmark) {
$out[] = ApiUtils::formatLink($bookmark, $indexUrl);
}
return $response->withJson($out, 200, $this->jsonStyle);
@ -97,11 +96,12 @@ class Links extends ApiController
*/
public function getLink($request, $response, $args)
{
if (!isset($this->linkDb[$args['id']])) {
$id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
if ($id === null || ! $this->bookmarkService->exists($id)) {
throw new ApiLinkNotFoundException();
}
$index = index_url($this->ci['environment']);
$out = ApiUtils::formatLink($this->linkDb[$args['id']], $index);
$out = ApiUtils::formatLink($this->bookmarkService->get($id), $index);
return $response->withJson($out, 200, $this->jsonStyle);
}
@ -116,10 +116,17 @@ class Links extends ApiController
*/
public function postLink($request, $response)
{
$data = $request->getParsedBody();
$link = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links'));
$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($link['url']) && ! empty($dup = $this->linkDb->getLinkFromUrl($link['url']))) {
if (
! empty($bookmark->getUrl())
&& ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl()))
) {
return $response->withJson(
ApiUtils::formatLink($dup, index_url($this->ci['environment'])),
409,
@ -127,23 +134,9 @@ class Links extends ApiController
);
}
$link['id'] = $this->linkDb->getNextId();
$link['shorturl'] = link_small_hash($link['created'], $link['id']);
// note: general relative URL
if (empty($link['url'])) {
$link['url'] = '?' . $link['shorturl'];
}
if (empty($link['title'])) {
$link['title'] = $link['url'];
}
$this->linkDb[$link['id']] = $link;
$this->linkDb->save($this->conf->get('resource.page_cache'));
$this->history->addLink($link);
$out = ApiUtils::formatLink($link, index_url($this->ci['environment']));
$redirect = $this->ci->router->relativePathFor('getLink', ['id' => $link['id']]);
$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);
}
@ -161,18 +154,24 @@ class Links extends ApiController
*/
public function putLink($request, $response, $args)
{
if (! isset($this->linkDb[$args['id']])) {
$id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
if ($id === null || !$this->bookmarkService->exists($id)) {
throw new ApiLinkNotFoundException();
}
$index = index_url($this->ci['environment']);
$data = $request->getParsedBody();
$requestLink = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links'));
$requestBookmark = ApiUtils::buildBookmarkFromRequest(
$data,
$this->conf->get('privacy.default_private_links'),
$this->conf->get('general.tags_separator', ' ')
);
// duplicate URL on a different link, return 409 Conflict
if (! empty($requestLink['url'])
&& ! empty($dup = $this->linkDb->getLinkFromUrl($requestLink['url']))
&& $dup['id'] != $args['id']
if (
! empty($requestBookmark->getUrl())
&& ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl()))
&& $dup->getId() != $id
) {
return $response->withJson(
ApiUtils::formatLink($dup, $index),
@ -181,13 +180,11 @@ class Links extends ApiController
);
}
$responseLink = $this->linkDb[$args['id']];
$responseLink = ApiUtils::updateLink($responseLink, $requestLink);
$this->linkDb[$responseLink['id']] = $responseLink;
$this->linkDb->save($this->conf->get('resource.page_cache'));
$this->history->updateLink($responseLink);
$responseBookmark = $this->bookmarkService->get($id);
$responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark);
$this->bookmarkService->set($responseBookmark);
$out = ApiUtils::formatLink($responseLink, $index);
$out = ApiUtils::formatLink($responseBookmark, $index);
return $response->withJson($out, 200, $this->jsonStyle);
}
@ -204,13 +201,12 @@ class Links extends ApiController
*/
public function deleteLink($request, $response, $args)
{
if (! isset($this->linkDb[$args['id']])) {
$id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
if ($id === null || !$this->bookmarkService->exists($id)) {
throw new ApiLinkNotFoundException();
}
$link = $this->linkDb[$args['id']];
unset($this->linkDb[(int) $args['id']]);
$this->linkDb->save($this->conf->get('resource.page_cache'));
$this->history->deleteLink($link);
$bookmark = $this->bookmarkService->get($id);
$this->bookmarkService->remove($bookmark);
return $response->withStatus(204);
}

View File

@ -4,8 +4,8 @@ namespace Shaarli\Api\Controllers;
use Shaarli\Api\ApiUtils;
use Shaarli\Api\Exceptions\ApiBadParametersException;
use Shaarli\Api\Exceptions\ApiLinkNotFoundException;
use Shaarli\Api\Exceptions\ApiTagNotFoundException;
use Shaarli\Bookmark\BookmarkFilter;
use Slim\Http\Request;
use Slim\Http\Response;
@ -19,7 +19,7 @@ use Slim\Http\Response;
class Tags extends ApiController
{
/**
* @var int Number of links returned if no limit is provided.
* @var int Number of bookmarks returned if no limit is provided.
*/
public static $DEFAULT_LIMIT = 'all';
@ -36,7 +36,7 @@ class Tags extends ApiController
public function getTags($request, $response)
{
$visibility = $request->getParam('visibility');
$tags = $this->linkDb->linksCountPerTag([], $visibility);
$tags = $this->bookmarkService->bookmarksCountPerTag([], $visibility);
// Return tags from the {offset}th tag, starting from 0.
$offset = $request->getParam('offset');
@ -48,7 +48,7 @@ class Tags extends ApiController
return $response->withJson([], 200, $this->jsonStyle);
}
// limit parameter is either a number of links or 'all' for everything.
// limit parameter is either a number of bookmarks or 'all' for everything.
$limit = $request->getParam('limit');
if (empty($limit)) {
$limit = self::$DEFAULT_LIMIT;
@ -88,7 +88,7 @@ class Tags extends ApiController
*/
public function getTag($request, $response, $args)
{
$tags = $this->linkDb->linksCountPerTag();
$tags = $this->bookmarkService->bookmarksCountPerTag();
if (!isset($tags[$args['tagName']])) {
throw new ApiTagNotFoundException();
}
@ -112,7 +112,7 @@ class Tags extends ApiController
*/
public function putTag($request, $response, $args)
{
$tags = $this->linkDb->linksCountPerTag();
$tags = $this->bookmarkService->bookmarksCountPerTag();
if (! isset($tags[$args['tagName']])) {
throw new ApiTagNotFoundException();
}
@ -122,13 +122,19 @@ class Tags extends ApiController
throw new ApiBadParametersException('New tag name is required in the request body');
}
$updated = $this->linkDb->renameTag($args['tagName'], $data['name']);
$this->linkDb->save($this->conf->get('resource.page_cache'));
foreach ($updated as $link) {
$this->history->updateLink($link);
$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->linkDb->linksCountPerTag();
$tags = $this->bookmarkService->bookmarksCountPerTag();
$out = ApiUtils::formatTag($data['name'], $tags[$data['name']]);
return $response->withJson($out, 200, $this->jsonStyle);
}
@ -146,15 +152,22 @@ class Tags extends ApiController
*/
public function deleteTag($request, $response, $args)
{
$tags = $this->linkDb->linksCountPerTag();
$tags = $this->bookmarkService->bookmarksCountPerTag();
if (! isset($tags[$args['tagName']])) {
throw new ApiTagNotFoundException();
}
$updated = $this->linkDb->renameTag($args['tagName'], null);
$this->linkDb->save($this->conf->get('resource.page_cache'));
foreach ($updated as $link) {
$this->history->updateLink($link);
$searchResult = $this->bookmarkService->search(
['searchtags' => $args['tagName']],
BookmarkFilter::$ALL,
true
);
foreach ($searchResult->getBookmarks() as $bookmark) {
$bookmark->deleteTag($args['tagName']);
$this->bookmarkService->set($bookmark, false);
$this->history->updateLink($bookmark);
}
$this->bookmarkService->save();
return $response->withStatus(204);
}

View File

@ -28,7 +28,7 @@ class ApiAuthorizationException extends ApiException
*/
public function setMessage($message)
{
$original = $this->debug === true ? ': '. $this->getMessage() : '';
$original = $this->debug === true ? ': ' . $this->getMessage() : '';
$this->message = $message . $original;
}
}

View File

@ -10,8 +10,8 @@ use Slim\Http\Response;
* 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 {
abstract class ApiException extends \Exception
{
/**
* @var Response instance from Slim.
*/
@ -27,7 +27,7 @@ abstract class ApiException extends \Exception {
*
* @return Response Final response to give.
*/
public abstract function getApiResponse();
abstract public function getApiResponse();
/**
* Creates ApiResponse body.
@ -36,13 +36,14 @@ abstract class ApiException extends \Exception {
*
* @return array|string response body
*/
protected function getApiResponseBody() {
protected function getApiResponseBody()
{
if ($this->debug !== true) {
return $this->getMessage();
}
return [
'message' => $this->getMessage(),
'stacktrace' => get_class($this) .': '. $this->getTraceAsString()
'stacktrace' => get_class($this) . ': ' . $this->getTraceAsString()
];
}

View File

@ -2,9 +2,6 @@
namespace Shaarli\Api\Exceptions;
use Slim\Http\Response;
/**
* Class ApiLinkNotFoundException
*

View File

@ -2,9 +2,6 @@
namespace Shaarli\Api\Exceptions;
use Slim\Http\Response;
/**
* Class ApiTagNotFoundException
*

View File

@ -0,0 +1,542 @@
<?php
declare(strict_types=1);
namespace Shaarli\Bookmark;
use DateTime;
use DateTimeInterface;
use Shaarli\Bookmark\Exception\InvalidBookmarkException;
/**
* Class Bookmark
*
* This class represent a single Bookmark with all its attributes.
* Every bookmark should manipulated using this, before being formatted.
*
* @package Shaarli\Bookmark
*/
class Bookmark
{
/** @var string Date format used in string (former ID format) */
public const LINK_DATE_FORMAT = 'Ymd_His';
/** @var int Bookmark ID */
protected $id;
/** @var string Permalink identifier */
protected $shortUrl;
/** @var string Bookmark's URL - $shortUrl prefixed with `?` for notes */
protected $url;
/** @var string Bookmark's title */
protected $title;
/** @var string Raw bookmark's description */
protected $description;
/** @var array List of bookmark's tags */
protected $tags;
/** @var string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found */
protected $thumbnail;
/** @var bool Set to true if the bookmark is set as sticky */
protected $sticky;
/** @var DateTimeInterface Creation datetime */
protected $created;
/** @var DateTimeInterface datetime */
protected $updated;
/** @var bool True if the bookmark can only be seen while logged in */
protected $private;
/** @var mixed[] Available to store any additional content for a bookmark. Currently used for search highlight. */
protected $additionalContent = [];
/**
* Initialize a link from array data. Especially useful to create a Bookmark from former link storage format.
*
* @param array $data
* @param string $tagsSeparator Tags separator loaded from the config file.
* This is a context data, and it should *never* be stored in the Bookmark object.
*
* @return $this
*/
public function fromArray(array $data, string $tagsSeparator = ' '): Bookmark
{
$this->id = $data['id'] ?? null;
$this->shortUrl = $data['shorturl'] ?? null;
$this->url = $data['url'] ?? null;
$this->title = $data['title'] ?? null;
$this->description = $data['description'] ?? null;
$this->thumbnail = $data['thumbnail'] ?? null;
$this->sticky = $data['sticky'] ?? false;
$this->created = $data['created'] ?? null;
if (is_array($data['tags'])) {
$this->tags = $data['tags'];
} else {
$this->tags = tags_str2array($data['tags'] ?? '', $tagsSeparator);
}
if (! empty($data['updated'])) {
$this->updated = $data['updated'];
}
$this->private = ($data['private'] ?? false) ? true : false;
$this->additionalContent = $data['additional_content'] ?? [];
return $this;
}
/**
* Make sure that the current instance of Bookmark is valid and can be saved into the data store.
* A valid link requires:
* - an integer ID
* - a short URL (for permalinks)
* - a creation date
*
* This function also initialize optional empty fields:
* - the URL with the permalink
* - the title with the URL
*
* Also make sure that we do not save search highlights in the datastore.
*
* @throws InvalidBookmarkException
*/
public function validate(): void
{
if (
$this->id === null
|| ! is_int($this->id)
|| empty($this->shortUrl)
|| empty($this->created)
) {
throw new InvalidBookmarkException($this);
}
if (empty($this->url)) {
$this->url = '/shaare/' . $this->shortUrl;
}
if (empty($this->title)) {
$this->title = $this->url;
}
if (array_key_exists('search_highlight', $this->additionalContent)) {
unset($this->additionalContent['search_highlight']);
}
}
/**
* Set the Id.
* If they're not already initialized, this function also set:
* - created: with the current datetime
* - shortUrl: with a generated small hash from the date and the given ID
*
* @param int|null $id
*
* @return Bookmark
*/
public function setId(?int $id): Bookmark
{
$this->id = $id;
if (empty($this->created)) {
$this->created = new DateTime();
}
if (empty($this->shortUrl)) {
$this->shortUrl = link_small_hash($this->created, $this->id);
}
return $this;
}
/**
* Get the Id.
*
* @return int|null
*/
public function getId(): ?int
{
return $this->id;
}
/**
* Get the ShortUrl.
*
* @return string|null
*/
public function getShortUrl(): ?string
{
return $this->shortUrl;
}
/**
* Get the Url.
*
* @return string|null
*/
public function getUrl(): ?string
{
return $this->url;
}
/**
* Get the Title.
*
* @return string
*/
public function getTitle(): ?string
{
return $this->title;
}
/**
* Get the Description.
*
* @return string
*/
public function getDescription(): string
{
return ! empty($this->description) ? $this->description : '';
}
/**
* Get the Created.
*
* @return DateTimeInterface
*/
public function getCreated(): ?DateTimeInterface
{
return $this->created;
}
/**
* Get the Updated.
*
* @return DateTimeInterface
*/
public function getUpdated(): ?DateTimeInterface
{
return $this->updated;
}
/**
* Set the ShortUrl.
*
* @param string|null $shortUrl
*
* @return Bookmark
*/
public function setShortUrl(?string $shortUrl): Bookmark
{
$this->shortUrl = $shortUrl;
return $this;
}
/**
* Set the Url.
*
* @param string|null $url
* @param string[] $allowedProtocols
*
* @return Bookmark
*/
public function setUrl(?string $url, array $allowedProtocols = []): Bookmark
{
$url = $url !== null ? trim($url) : '';
if (! empty($url)) {
$url = whitelist_protocols($url, $allowedProtocols);
}
$this->url = $url;
return $this;
}
/**
* Set the Title.
*
* @param string|null $title
*
* @return Bookmark
*/
public function setTitle(?string $title): Bookmark
{
$this->title = $title !== null ? trim($title) : '';
return $this;
}
/**
* Set the Description.
*
* @param string|null $description
*
* @return Bookmark
*/
public function setDescription(?string $description): Bookmark
{
$this->description = $description;
return $this;
}
/**
* Set the Created.
* Note: you shouldn't set this manually except for special cases (like bookmark import)
*
* @param DateTimeInterface|null $created
*
* @return Bookmark
*/
public function setCreated(?DateTimeInterface $created): Bookmark
{
$this->created = $created;
return $this;
}
/**
* Set the Updated.
*
* @param DateTimeInterface|null $updated
*
* @return Bookmark
*/
public function setUpdated(?DateTimeInterface $updated): Bookmark
{
$this->updated = $updated;
return $this;
}
/**
* Get the Private.
*
* @return bool
*/
public function isPrivate(): bool
{
return $this->private ? true : false;
}
/**
* Set the Private.
*
* @param bool|null $private
*
* @return Bookmark
*/
public function setPrivate(?bool $private): Bookmark
{
$this->private = $private ? true : false;
return $this;
}
/**
* Get the Tags.
*
* @return string[]
*/
public function getTags(): array
{
return is_array($this->tags) ? $this->tags : [];
}
/**
* Set the Tags.
*
* @param string[]|null $tags
*
* @return Bookmark
*/
public function setTags(?array $tags): Bookmark
{
$this->tags = array_map(
function (string $tag): string {
return $tag[0] === '-' ? substr($tag, 1) : $tag;
},
tags_filter($tags, ' ')
);
return $this;
}
/**
* Get the Thumbnail.
*
* @return string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found
*/
public function getThumbnail()
{
return !$this->isNote() ? $this->thumbnail : false;
}
/**
* Set the Thumbnail.
*
* @param string|bool|null $thumbnail Thumbnail's URL - false if no thumbnail could be found
*
* @return Bookmark
*/
public function setThumbnail($thumbnail): Bookmark
{
$this->thumbnail = $thumbnail;
return $this;
}
/**
* Return true if:
* - the bookmark's thumbnail is not already set to false (= not found)
* - it's not a note
* - it's an HTTP(S) link
* - the thumbnail has not yet be retrieved (null) or its associated cache file doesn't exist anymore
*
* @return bool True if the bookmark's thumbnail needs to be retrieved.
*/
public function shouldUpdateThumbnail(): bool
{
return $this->thumbnail !== false
&& !$this->isNote()
&& startsWith(strtolower($this->url), 'http')
&& (null === $this->thumbnail || !is_file($this->thumbnail))
;
}
/**
* Get the Sticky.
*
* @return bool
*/
public function isSticky(): bool
{
return $this->sticky ? true : false;
}
/**
* Set the Sticky.
*
* @param bool|null $sticky
*
* @return Bookmark
*/
public function setSticky(?bool $sticky): Bookmark
{
$this->sticky = $sticky ? true : false;
return $this;
}
/**
* @param string $separator Tags separator loaded from the config file.
*
* @return string Bookmark's tags as a string, separated by a separator
*/
public function getTagsString(string $separator = ' '): string
{
return tags_array2str($this->getTags(), $separator);
}
/**
* @return bool
*/
public function isNote(): bool
{
// We check empty value to get a valid result if the link has not been saved yet
return empty($this->url) || startsWith($this->url, '/shaare/') || $this->url[0] === '?';
}
/**
* Set tags from a string.
* Note:
* - tags must be separated whether by a space or a comma
* - multiple spaces will be removed
* - trailing dash in tags will be removed
*
* @param string|null $tags
* @param string $separator Tags separator loaded from the config file.
*
* @return $this
*/
public function setTagsString(?string $tags, string $separator = ' '): Bookmark
{
$this->setTags(tags_str2array($tags, $separator));
return $this;
}
/**
* Get entire additionalContent array.
*
* @return mixed[]
*/
public function getAdditionalContent(): array
{
return $this->additionalContent;
}
/**
* Set a single entry in additionalContent, by key.
*
* @param string $key
* @param mixed|null $value Any type of value can be set.
*
* @return $this
*/
public function setAdditionalContentEntry(string $key, $value): self
{
$this->additionalContent[$key] = $value;
return $this;
}
/**
* Get a single entry in additionalContent, by key.
*
* @param string $key
* @param mixed|null $default
*
* @return mixed|null can be any type or even null.
*/
public function getAdditionalContentEntry(string $key, $default = null)
{
return array_key_exists($key, $this->additionalContent) ? $this->additionalContent[$key] : $default;
}
/**
* Rename a tag in tags list.
*
* @param string $fromTag
* @param string $toTag
*/
public function renameTag(string $fromTag, string $toTag): void
{
if (($pos = array_search($fromTag, $this->tags ?? [])) !== false) {
$this->tags[$pos] = trim($toTag);
}
}
/**
* Add a tag in tags list.
*
* @param string $tag
*/
public function addTag(string $tag): self
{
return $this->setTags(array_unique(array_merge($this->getTags(), [$tag])));
}
/**
* Delete a tag from tags list.
*
* @param string $tag
*/
public function deleteTag(string $tag): void
{
while (($pos = array_search($tag, $this->tags ?? [])) !== false) {
unset($this->tags[$pos]);
$this->tags = array_values($this->tags);
}
}
}

View File

@ -0,0 +1,264 @@
<?php
declare(strict_types=1);
namespace Shaarli\Bookmark;
use Shaarli\Bookmark\Exception\InvalidBookmarkException;
/**
* Class BookmarkArray
*
* Implementing ArrayAccess, this allows us to use the bookmark list
* as an array and iterate over it.
*
* @package Shaarli\Bookmark
*/
class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
{
/**
* @var Bookmark[]
*/
protected $bookmarks;
/**
* @var array List of all bookmarks IDS mapped with their array offset.
* Map: id->offset.
*/
protected $ids;
/**
* @var int Position in the $this->keys array (for the Iterator interface)
*/
protected $position;
/**
* @var array List of offset keys (for the Iterator interface implementation)
*/
protected $keys;
/**
* @var array List of all recorded URLs (key=url, value=bookmark offset)
* for fast reserve search (url-->bookmark offset)
*/
protected $urls;
public function __construct()
{
$this->ids = [];
$this->bookmarks = [];
$this->keys = [];
$this->urls = [];
$this->position = 0;
}
/**
* Countable - Counts elements of an object
*
* @return int Number of bookmarks
*/
public function count(): int
{
return count($this->bookmarks);
}
/**
* ArrayAccess - Assigns a value to the specified offset
*
* @param int $offset Bookmark ID
* @param Bookmark $value instance
*
* @throws InvalidBookmarkException
*/
public function offsetSet($offset, $value): void
{
if (
! $value instanceof Bookmark
|| $value->getId() === null || empty($value->getUrl())
|| ($offset !== null && ! is_int($offset)) || ! is_int($value->getId())
|| $offset !== null && $offset !== $value->getId()
) {
throw new InvalidBookmarkException($value);
}
// If the bookmark exists, we reuse the real offset, otherwise new entry
if ($offset !== null) {
$existing = $this->getBookmarkOffset($offset);
} else {
$existing = $this->getBookmarkOffset($value->getId());
}
if ($existing !== null) {
$offset = $existing;
} else {
$offset = count($this->bookmarks);
}
$this->bookmarks[$offset] = $value;
$this->urls[$value->getUrl()] = $offset;
$this->ids[$value->getId()] = $offset;
}
/**
* ArrayAccess - Whether or not an offset exists
*
* @param int $offset Bookmark ID
*
* @return bool true if it exists, false otherwise
*/
public function offsetExists($offset): bool
{
return array_key_exists($this->getBookmarkOffset($offset), $this->bookmarks);
}
/**
* ArrayAccess - Unsets an offset
*
* @param int $offset Bookmark ID
*/
public function offsetUnset($offset): void
{
$realOffset = $this->getBookmarkOffset($offset);
$url = $this->bookmarks[$realOffset]->getUrl();
unset($this->urls[$url]);
unset($this->ids[$offset]);
unset($this->bookmarks[$realOffset]);
}
/**
* ArrayAccess - Returns the value at specified offset
*
* @param int $offset Bookmark ID
*
* @return Bookmark|null The Bookmark if found, null otherwise
*/
public function offsetGet($offset): ?Bookmark
{
$realOffset = $this->getBookmarkOffset($offset);
return isset($this->bookmarks[$realOffset]) ? $this->bookmarks[$realOffset] : null;
}
/**
* Iterator - Returns the current element
*
* @return Bookmark corresponding to the current position
*/
public function current(): Bookmark
{
return $this[$this->keys[$this->position]];
}
/**
* Iterator - Returns the key of the current element
*
* @return int Bookmark ID corresponding to the current position
*/
public function key(): int
{
return $this->keys[$this->position];
}
/**
* Iterator - Moves forward to next element
*/
public function next(): void
{
++$this->position;
}
/**
* Iterator - Rewinds the Iterator to the first element
*
* Entries are sorted by date (latest first)
*/
public function rewind(): void
{
$this->keys = array_keys($this->ids);
$this->position = 0;
}
/**
* Iterator - Checks if current position is valid
*
* @return bool true if the current Bookmark ID exists, false otherwise
*/
public function valid(): bool
{
return isset($this->keys[$this->position]);
}
/**
* Returns a bookmark offset in bookmarks array from its unique ID.
*
* @param int|null $id Persistent ID of a bookmark.
*
* @return int Real offset in local array, or null if doesn't exist.
*/
protected function getBookmarkOffset(?int $id): ?int
{
if ($id !== null && isset($this->ids[$id])) {
return $this->ids[$id];
}
return null;
}
/**
* Return the next key for bookmark creation.
* E.g. If the last ID is 597, the next will be 598.
*
* @return int next ID.
*/
public function getNextId(): int
{
if (!empty($this->ids)) {
return max(array_keys($this->ids)) + 1;
}
return 0;
}
/**
* @param string $url
*
* @return Bookmark|null
*/
public function getByUrl(string $url): ?Bookmark
{
if (
! empty($url)
&& isset($this->urls[$url])
&& isset($this->bookmarks[$this->urls[$url]])
) {
return $this->bookmarks[$this->urls[$url]];
}
return null;
}
/**
* Reorder links by creation date (newest first).
*
* Also update the urls and ids mapping arrays.
*
* @param string $order ASC|DESC
* @param bool $ignoreSticky If set to true, sticky bookmarks won't be first
*/
public function reorder(string $order = 'DESC', bool $ignoreSticky = false): void
{
$order = $order === 'ASC' ? -1 : 1;
// Reorder array by dates.
usort($this->bookmarks, function ($a, $b) use ($order, $ignoreSticky) {
/** @var $a Bookmark */
/** @var $b Bookmark */
if (false === $ignoreSticky && $a->isSticky() !== $b->isSticky()) {
return $a->isSticky() ? -1 : 1;
}
return $a->getCreated() < $b->getCreated() ? 1 * $order : -1 * $order;
});
$this->urls = [];
$this->ids = [];
foreach ($this->bookmarks as $key => $bookmark) {
$this->urls[$bookmark->getUrl()] = $key;
$this->ids[$bookmark->getId()] = $key;
}
}
}

View File

@ -0,0 +1,443 @@
<?php
declare(strict_types=1);
namespace Shaarli\Bookmark;
use DateTime;
use Exception;
use malkusch\lock\mutex\Mutex;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
use Shaarli\Bookmark\Exception\EmptyDataStoreException;
use Shaarli\Config\ConfigManager;
use Shaarli\Formatter\BookmarkMarkdownFormatter;
use Shaarli\History;
use Shaarli\Legacy\LegacyLinkDB;
use Shaarli\Legacy\LegacyUpdater;
use Shaarli\Plugin\PluginManager;
use Shaarli\Render\PageCacheManager;
use Shaarli\Updater\UpdaterUtils;
/**
* Class BookmarksService
*
* This is the entry point to manipulate the bookmark DB.
* It manipulates loads links from a file data store containing all bookmarks.
*
* It also triggers the legacy format (bookmarks as arrays) migration.
*/
class BookmarkFileService implements BookmarkServiceInterface
{
/** @var Bookmark[] instance */
protected $bookmarks;
/** @var BookmarkIO instance */
protected $bookmarksIO;
/** @var BookmarkFilter */
protected $bookmarkFilter;
/** @var ConfigManager instance */
protected $conf;
/** @var PluginManager */
protected $pluginManager;
/** @var History instance */
protected $history;
/** @var PageCacheManager instance */
protected $pageCacheManager;
/** @var bool true for logged in users. Default value to retrieve private bookmarks. */
protected $isLoggedIn;
/** @var Mutex */
protected $mutex;
/**
* @inheritDoc
*/
public function __construct(
ConfigManager $conf,
PluginManager $pluginManager,
History $history,
Mutex $mutex,
bool $isLoggedIn
) {
$this->conf = $conf;
$this->history = $history;
$this->mutex = $mutex;
$this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn);
$this->bookmarksIO = new BookmarkIO($this->conf, $this->mutex);
$this->isLoggedIn = $isLoggedIn;
if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) {
$this->bookmarks = new BookmarkArray();
} else {
try {
$this->bookmarks = $this->bookmarksIO->read();
} catch (EmptyDataStoreException | DatastoreNotInitializedException $e) {
$this->bookmarks = new BookmarkArray();
if ($this->isLoggedIn) {
// Datastore file does not exists, we initialize it with default bookmarks.
if ($e instanceof DatastoreNotInitializedException) {
$this->initialize();
} else {
$this->save();
}
}
}
if (! $this->bookmarks instanceof BookmarkArray) {
$this->migrate();
exit(
'Your data store has been migrated, please reload the page.' . PHP_EOL .
'If this message keeps showing up, please delete data/updates.txt file.'
);
}
}
$this->pluginManager = $pluginManager;
$this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf, $this->pluginManager);
}
/**
* @inheritDoc
*/
public function findByHash(string $hash, string $privateKey = null): Bookmark
{
$bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash);
// PHP 7.3 introduced array_key_first() to avoid this hack
$first = reset($bookmark);
if (
!$this->isLoggedIn
&& $first->isPrivate()
&& (empty($privateKey) || $privateKey !== $first->getAdditionalContentEntry('private_key'))
) {
throw new BookmarkNotFoundException();
}
return $first;
}
/**
* @inheritDoc
*/
public function findByUrl(string $url): ?Bookmark
{
return $this->bookmarks->getByUrl($url);
}
/**
* @inheritDoc
*/
public function search(
array $request = [],
string $visibility = null,
bool $caseSensitive = false,
bool $untaggedOnly = false,
bool $ignoreSticky = false,
array $pagination = []
): SearchResult {
if ($visibility === null) {
$visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
}
// Filter bookmark database according to parameters.
$searchTags = isset($request['searchtags']) ? $request['searchtags'] : '';
$searchTerm = isset($request['searchterm']) ? $request['searchterm'] : '';
if ($ignoreSticky) {
$this->bookmarks->reorder('DESC', true);
}
$bookmarks = $this->bookmarkFilter->filter(
BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT,
[$searchTags, $searchTerm],
$caseSensitive,
$visibility,
$untaggedOnly
);
return SearchResult::getSearchResult(
$bookmarks,
$pagination['offset'] ?? 0,
$pagination['limit'] ?? null,
$pagination['allowOutOfBounds'] ?? false
);
}
/**
* @inheritDoc
*/
public function get(int $id, string $visibility = null): Bookmark
{
if (! isset($this->bookmarks[$id])) {
throw new BookmarkNotFoundException();
}
if ($visibility === null) {
$visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
}
$bookmark = $this->bookmarks[$id];
if (
($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
|| (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
) {
throw new Exception('Unauthorized');
}
return $bookmark;
}
/**
* @inheritDoc
*/
public function set(Bookmark $bookmark, bool $save = true): Bookmark
{
if (true !== $this->isLoggedIn) {
throw new Exception(t('You\'re not authorized to alter the datastore'));
}
if (! isset($this->bookmarks[$bookmark->getId()])) {
throw new BookmarkNotFoundException();
}
$bookmark->validate();
$bookmark->setUpdated(new DateTime());
$this->bookmarks[$bookmark->getId()] = $bookmark;
if ($save === true) {
$this->save();
$this->history->updateLink($bookmark);
}
return $this->bookmarks[$bookmark->getId()];
}
/**
* @inheritDoc
*/
public function add(Bookmark $bookmark, bool $save = true): Bookmark
{
if (true !== $this->isLoggedIn) {
throw new Exception(t('You\'re not authorized to alter the datastore'));
}
if (!empty($bookmark->getId())) {
throw new Exception(t('This bookmarks already exists'));
}
$bookmark->setId($this->bookmarks->getNextId());
$bookmark->validate();
$this->bookmarks[$bookmark->getId()] = $bookmark;
if ($save === true) {
$this->save();
$this->history->addLink($bookmark);
}
return $this->bookmarks[$bookmark->getId()];
}
/**
* @inheritDoc
*/
public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark
{
if (true !== $this->isLoggedIn) {
throw new Exception(t('You\'re not authorized to alter the datastore'));
}
if ($bookmark->getId() === null) {
return $this->add($bookmark, $save);
}
return $this->set($bookmark, $save);
}
/**
* @inheritDoc
*/
public function remove(Bookmark $bookmark, bool $save = true): void
{
if (true !== $this->isLoggedIn) {
throw new Exception(t('You\'re not authorized to alter the datastore'));
}
if (! isset($this->bookmarks[$bookmark->getId()])) {
throw new BookmarkNotFoundException();
}
unset($this->bookmarks[$bookmark->getId()]);
if ($save === true) {
$this->save();
$this->history->deleteLink($bookmark);
}
}
/**
* @inheritDoc
*/
public function exists(int $id, string $visibility = null): bool
{
if (! isset($this->bookmarks[$id])) {
return false;
}
if ($visibility === null) {
$visibility = $this->isLoggedIn ? 'all' : 'public';
}
$bookmark = $this->bookmarks[$id];
if (
($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
|| (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
) {
return false;
}
return true;
}
/**
* @inheritDoc
*/
public function count(string $visibility = null): int
{
return $this->search([], $visibility)->getResultCount();
}
/**
* @inheritDoc
*/
public function save(): void
{
if (true !== $this->isLoggedIn) {
// TODO: raise an Exception instead
die('You are not authorized to change the database.');
}
$this->bookmarks->reorder();
$this->bookmarksIO->write($this->bookmarks);
$this->pageCacheManager->invalidateCaches();
}
/**
* @inheritDoc
*/
public function bookmarksCountPerTag(array $filteringTags = [], string $visibility = null): array
{
$searchResult = $this->search(['searchtags' => $filteringTags], $visibility);
$tags = [];
$caseMapping = [];
foreach ($searchResult->getBookmarks() as $bookmark) {
foreach ($bookmark->getTags() as $tag) {
if (
empty($tag)
|| (! $this->isLoggedIn && startsWith($tag, '.'))
|| $tag === BookmarkMarkdownFormatter::NO_MD_TAG
|| in_array($tag, $filteringTags, true)
) {
continue;
}
// The first case found will be displayed.
if (!isset($caseMapping[strtolower($tag)])) {
$caseMapping[strtolower($tag)] = $tag;
$tags[$caseMapping[strtolower($tag)]] = 0;
}
$tags[$caseMapping[strtolower($tag)]]++;
}
}
/*
* Formerly used arsort(), which doesn't define the sort behaviour for equal values.
* Also, this function doesn't produce the same result between PHP 5.6 and 7.
*
* So we now use array_multisort() to sort tags by DESC occurrences,
* then ASC alphabetically for equal values.
*
* @see https://github.com/shaarli/Shaarli/issues/1142
*/
$keys = array_keys($tags);
$tmpTags = array_combine($keys, $keys);
array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
return $tags;
}
/**
* @inheritDoc
*/
public function findByDate(
\DateTimeInterface $from,
\DateTimeInterface $to,
?\DateTimeInterface &$previous,
?\DateTimeInterface &$next
): array {
$out = [];
$previous = null;
$next = null;
foreach ($this->search([], null, false, false, true)->getBookmarks() as $bookmark) {
if ($to < $bookmark->getCreated()) {
$next = $bookmark->getCreated();
} elseif ($from < $bookmark->getCreated() && $to > $bookmark->getCreated()) {
$out[] = $bookmark;
} else {
if ($previous !== null) {
break;
}
$previous = $bookmark->getCreated();
}
}
return $out;
}
/**
* @inheritDoc
*/
public function getLatest(): ?Bookmark
{
foreach ($this->search([], null, false, false, true)->getBookmarks() as $bookmark) {
return $bookmark;
}
return null;
}
/**
* @inheritDoc
*/
public function initialize(): void
{
$initializer = new BookmarkInitializer($this);
$initializer->initialize();
if (true === $this->isLoggedIn) {
$this->save();
}
}
/**
* Handles migration to the new database format (BookmarksArray).
*/
protected function migrate(): void
{
$bookmarkDb = new LegacyLinkDB(
$this->conf->get('resource.datastore'),
true,
false
);
$updater = new LegacyUpdater(
UpdaterUtils::readUpdatesFile($this->conf->get('resource.updates')),
$bookmarkDb,
$this->conf,
true
);
$newUpdates = $updater->update();
if (! empty($newUpdates)) {
UpdaterUtils::writeUpdatesFile(
$this->conf->get('resource.updates'),
$updater->getDoneUpdates()
);
}
}
}

View File

@ -0,0 +1,635 @@
<?php
declare(strict_types=1);
namespace Shaarli\Bookmark;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Config\ConfigManager;
use Shaarli\Plugin\PluginManager;
/**
* Class LinkFilter.
*
* Perform search and filter operation on link data list.
*/
class BookmarkFilter
{
/**
* @var string permalinks.
*/
public static $FILTER_HASH = 'permalink';
/**
* @var string text search.
*/
public static $FILTER_TEXT = 'fulltext';
/**
* @var string tag filter.
*/
public static $FILTER_TAG = 'tags';
/**
* @var string filter by day.
*/
public static $DEFAULT = 'NO_FILTER';
/** @var string Visibility: all */
public static $ALL = 'all';
/** @var string Visibility: public */
public static $PUBLIC = 'public';
/** @var string Visibility: private */
public static $PRIVATE = 'private';
/**
* @var string Allowed characters for hashtags (regex syntax).
*/
public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}';
/**
* @var Bookmark[] all available bookmarks.
*/
private $bookmarks;
/** @var ConfigManager */
protected $conf;
/** @var PluginManager */
protected $pluginManager;
/**
* @param Bookmark[] $bookmarks initialization.
*/
public function __construct($bookmarks, ConfigManager $conf, PluginManager $pluginManager)
{
$this->bookmarks = $bookmarks;
$this->conf = $conf;
$this->pluginManager = $pluginManager;
}
/**
* Filter bookmarks according to parameters.
*
* @param string $type Type of filter (eg. tags, permalink, etc.).
* @param mixed $request Filter content.
* @param bool $casesensitive Optional: Perform case sensitive filter if true.
* @param string $visibility Optional: return only all/private/public bookmarks
* @param bool $untaggedonly Optional: return only untagged bookmarks. Applies only if $type includes FILTER_TAG
*
* @return Bookmark[] filtered bookmark list.
*
* @throws BookmarkNotFoundException
*/
public function filter(
string $type,
$request,
bool $casesensitive = false,
string $visibility = 'all',
bool $untaggedonly = false
) {
if (!in_array($visibility, ['all', 'public', 'private'])) {
$visibility = 'all';
}
switch ($type) {
case self::$FILTER_HASH:
return $this->filterSmallHash($request);
case self::$FILTER_TAG | self::$FILTER_TEXT: // == "vuotext"
$noRequest = empty($request) || (empty($request[0]) && empty($request[1]));
if ($noRequest) {
if ($untaggedonly) {
return $this->filterUntagged($visibility);
}
return $this->noFilter($visibility);
}
if ($untaggedonly) {
$filtered = $this->filterUntagged($visibility);
} else {
$filtered = $this->bookmarks;
}
if (!empty($request[0])) {
$filtered = (new BookmarkFilter($filtered, $this->conf, $this->pluginManager))
->filterTags($request[0], $casesensitive, $visibility)
;
}
if (!empty($request[1])) {
$filtered = (new BookmarkFilter($filtered, $this->conf, $this->pluginManager))
->filterFulltext($request[1], $visibility)
;
}
return $filtered;
case self::$FILTER_TEXT:
return $this->filterFulltext($request, $visibility);
case self::$FILTER_TAG:
if ($untaggedonly) {
return $this->filterUntagged($visibility);
} else {
return $this->filterTags($request, $casesensitive, $visibility);
}
default:
return $this->noFilter($visibility);
}
}
/**
* Unknown filter, but handle private only.
*
* @param string $visibility Optional: return only all/private/public bookmarks
*
* @return Bookmark[] filtered bookmarks.
*/
private function noFilter(string $visibility = 'all')
{
$out = [];
foreach ($this->bookmarks as $key => $value) {
if (
!$this->pluginManager->filterSearchEntry(
$value,
['source' => 'no_filter', 'visibility' => $visibility]
)
) {
continue;
}
if ($visibility === 'all') {
$out[$key] = $value;
} elseif ($value->isPrivate() && $visibility === 'private') {
$out[$key] = $value;
} elseif (!$value->isPrivate() && $visibility === 'public') {
$out[$key] = $value;
}
}
return $out;
}
/**
* Returns the shaare corresponding to a smallHash.
*
* @param string $smallHash permalink hash.
*
* @return Bookmark[] $filtered array containing permalink data.
*
* @throws BookmarkNotFoundException if the smallhash doesn't match any link.
*/
private function filterSmallHash(string $smallHash)
{
foreach ($this->bookmarks as $key => $l) {
if ($smallHash == $l->getShortUrl()) {
// Yes, this is ugly and slow
return [$key => $l];
}
}
throw new BookmarkNotFoundException();
}
/**
* Returns the list of bookmarks corresponding to a full-text search
*
* Searches:
* - in the URLs, title and description;
* - are case-insensitive;
* - terms surrounded by quotes " are exact terms search.
* - terms starting with a dash - are excluded (except exact terms).
*
* Example:
* print_r($mydb->filterFulltext('hollandais'));
*
* mb_convert_case($val, MB_CASE_LOWER, 'UTF-8')
* - allows to perform searches on Unicode text
* - see https://github.com/shaarli/Shaarli/issues/75 for examples
*
* @param string $searchterms search query.
* @param string $visibility Optional: return only all/private/public bookmarks.
*
* @return Bookmark[] search results.
*/
private function filterFulltext(string $searchterms, string $visibility = 'all')
{
if (empty($searchterms)) {
return $this->noFilter($visibility);
}
$filtered = [];
$search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
$exactRegex = '/"([^"]+)"/';
// Retrieve exact search terms.
preg_match_all($exactRegex, $search, $exactSearch);
$exactSearch = array_values(array_filter($exactSearch[1]));
// Remove exact search terms to get AND terms search.
$explodedSearchAnd = explode(' ', trim(preg_replace($exactRegex, '', $search)));
$explodedSearchAnd = array_values(array_filter($explodedSearchAnd));
// Filter excluding terms and update andSearch.
$excludeSearch = [];
$andSearch = [];
foreach ($explodedSearchAnd as $needle) {
if ($needle[0] == '-' && strlen($needle) > 1) {
$excludeSearch[] = substr($needle, 1);
} else {
$andSearch[] = $needle;
}
}
// Iterate over every stored link.
foreach ($this->bookmarks as $id => $bookmark) {
if (
!$this->pluginManager->filterSearchEntry(
$bookmark,
[
'source' => 'fulltext',
'searchterms' => $searchterms,
'andSearch' => $andSearch,
'exactSearch' => $exactSearch,
'excludeSearch' => $excludeSearch,
'visibility' => $visibility
]
)
) {
continue;
}
// ignore non private bookmarks when 'privatonly' is on.
if ($visibility !== 'all') {
if (!$bookmark->isPrivate() && $visibility === 'private') {
continue;
} elseif ($bookmark->isPrivate() && $visibility === 'public') {
continue;
}
}
$lengths = [];
$content = $this->buildFullTextSearchableLink($bookmark, $lengths);
// Be optimistic
$found = true;
$foundPositions = [];
// First, we look for exact term search
// Then iterate over keywords, if keyword is not found,
// no need to check for the others. We want all or nothing.
foreach ([$exactSearch, $andSearch] as $search) {
for ($i = 0; $i < count($search) && $found !== false; $i++) {
$found = mb_strpos($content, $search[$i]);
if ($found === false) {
break;
}
$foundPositions[] = ['start' => $found, 'end' => $found + mb_strlen($search[$i])];
}
}
// Exclude terms.
for ($i = 0; $i < count($excludeSearch) && $found !== false; $i++) {
$found = strpos($content, $excludeSearch[$i]) === false;
}
if ($found !== false) {
$bookmark->setAdditionalContentEntry(
'search_highlight',
$this->postProcessFoundPositions($lengths, $foundPositions)
);
$filtered[$id] = $bookmark;
}
}
return $filtered;
}
/**
* Returns the list of bookmarks associated with a given list of tags
*
* You can specify one or more tags, separated by space or a comma, e.g.
* print_r($mydb->filterTags('linux programming'));
*
* @param string|array $tags list of tags, separated by commas or blank spaces if passed as string.
* @param bool $casesensitive ignore case if false.
* @param string $visibility Optional: return only all/private/public bookmarks.
*
* @return Bookmark[] filtered bookmarks.
*/
public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all')
{
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
// get single tags (we may get passed an array, even though the docs say different)
$inputTags = $tags;
if (!is_array($tags)) {
// we got an input string, split tags
$inputTags = tags_str2array($inputTags, $tagsSeparator);
}
if (count($inputTags) === 0) {
// no input tags
return $this->noFilter($visibility);
}
// If we only have public visibility, we can't look for hidden tags
if ($visibility === self::$PUBLIC) {
$inputTags = array_values(array_filter($inputTags, function ($tag) {
return ! startsWith($tag, '.');
}));
if (empty($inputTags)) {
return [];
}
}
// build regex from all tags
$re_and = implode(array_map([$this, 'tag2regex'], $inputTags));
$re = '/^' . $re_and;
$orTags = array_filter(array_map(function ($tag) {
return startsWith($tag, '~') ? substr($tag, 1) : null;
}, $inputTags));
$re_or = implode('|', array_map([$this, 'tag2matchterm'], $orTags));
if ($re_or) {
$re_or = '(' . $re_or . ')';
$re .= $this->term2match($re_or, false);
}
$re .= '.*$/';
if (!$casesensitive) {
// make regex case insensitive
$re .= 'i';
}
// create resulting array
$filtered = [];
// iterate over each link
foreach ($this->bookmarks as $key => $bookmark) {
if (
!$this->pluginManager->filterSearchEntry(
$bookmark,
[
'source' => 'tags',
'tags' => $tags,
'casesensitive' => $casesensitive,
'visibility' => $visibility
]
)
) {
continue;
}
// check level of visibility
// ignore non private bookmarks when 'privateonly' is on.
if ($visibility !== 'all') {
if (!$bookmark->isPrivate() && $visibility === 'private') {
continue;
} elseif ($bookmark->isPrivate() && $visibility === 'public') {
continue;
}
}
// build search string, start with tags of current link
$search = $bookmark->getTagsString($tagsSeparator);
if (strlen(trim($bookmark->getDescription())) && strpos($bookmark->getDescription(), '#') !== false) {
// description given and at least one possible tag found
$descTags = [];
// find all tags in the form of #tag in the description
preg_match_all(
'/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
$bookmark->getDescription(),
$descTags
);
if (count($descTags[1])) {
// there were some tags in the description, add them to the search string
$search .= $tagsSeparator . tags_array2str($descTags[1], $tagsSeparator);
}
}
// match regular expression with search string
if (!preg_match($re, $search)) {
// this entry does _not_ match our regex
continue;
}
$filtered[$key] = $bookmark;
}
return $filtered;
}
/**
* Return only bookmarks without any tag.
*
* @param string $visibility return only all/private/public bookmarks.
*
* @return Bookmark[] filtered bookmarks.
*/
public function filterUntagged(string $visibility)
{
$filtered = [];
foreach ($this->bookmarks as $key => $bookmark) {
if (
!$this->pluginManager->filterSearchEntry(
$bookmark,
['source' => 'untagged', 'visibility' => $visibility]
)
) {
continue;
}
if ($visibility !== 'all') {
if (!$bookmark->isPrivate() && $visibility === 'private') {
continue;
} elseif ($bookmark->isPrivate() && $visibility === 'public') {
continue;
}
}
if (empty($bookmark->getTags())) {
$filtered[$key] = $bookmark;
}
}
return $filtered;
}
/**
* Convert a list of tags (str) to an array. Also
* - handle case sensitivity.
* - accepts spaces commas as separator.
*
* @param string $tags string containing a list of tags.
* @param bool $casesensitive will convert everything to lowercase if false.
*
* @return string[] filtered tags string.
*/
public static function tagsStrToArray(string $tags, bool $casesensitive): array
{
// We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
$tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8');
$tagsOut = str_replace(',', ' ', $tagsOut);
return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY);
}
/**
* generate a regex fragment out of a tag
*
* @param string $tag to generate regexs from. may start with '-'
* to negate, contain '*' as wildcard. Tags starting with '~' are
* treated separately as an 'OR' clause.
*
* @return string generated regex fragment
*/
protected function tag2regex(string $tag): string
{
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
if (!$tag || $tag === "-" || $tag === "*" || $tag[0] === "~") {
// nothing to search, return empty regex
return '';
}
$negate = false;
if ($tag[0] === "+" && $tag[1]) {
$tag = substr($tag, 1); // use offset to start after '+' character
}
if ($tag[0] === "-") {
// query is negated
$tag = substr($tag, 1); // use offset to start after '-' character
$negate = true;
}
$term = $this->tag2matchterm($tag);
return $this->term2match($term, $negate);
}
/**
* generate a regex match term fragment out of a tag
*
* @param string $tag to to generate regexs from. This function
* assumes any leading flags ('-', '~') have been stripped. The
* wildcard flag '*' is expanded by this function and any other
* regex characters are escaped.
*
* @return string generated regex match term fragment
*/
protected function tag2matchterm(string $tag): string
{
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
$len = strlen($tag);
$term = '';
// iterate over string, separating it into placeholder and content
$i = 0; // start at first character
for (; $i < $len; $i++) {
if ($tag[$i] === '*') {
// placeholder found
$term .= '[^' . $tagsSeparator . ']*?';
} else {
// regular characters
$offset = strpos($tag, '*', $i);
if ($offset === false) {
// no placeholder found, set offset to end of string
$offset = $len;
}
// subtract one, as we want to get before the placeholder or end of string
$offset -= 1;
// we got a tag name that we want to search for. escape any regex characters to prevent conflicts.
$term .= preg_quote(substr($tag, $i, $offset - $i + 1), '/');
// move $i on
$i = $offset;
}
}
return $term;
}
/**
* generate a regex fragment out of a match term
*
* @param string $term is the match term already generated by tag2matchterm
* @param bool $negate if true create a negative lookahead
*
* @return string generated regex fragment
*/
protected function term2match(string $term, bool $negate): string
{
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
$regex = $negate ? '(?!' : '(?='; // use negative or positive lookahead
// before tag may only be the separator or the beginning
$regex .= '.*(?:^|' . $tagsSeparator . ')';
$regex .= $term;
// after the tag may only be the separator or the end
$regex .= '(?:$|' . $tagsSeparator . '))';
return $regex;
}
/**
* This method finalize the content of the foundPositions array,
* by associated all search results to their associated bookmark field,
* making sure that there is no overlapping results, etc.
*
* @param array $fieldLengths Start and end positions of every bookmark fields in the aggregated bookmark content.
* @param array $foundPositions Positions where the search results were found in the aggregated content.
*
* @return array Updated $foundPositions, by bookmark field.
*/
protected function postProcessFoundPositions(array $fieldLengths, array $foundPositions): array
{
// Sort results by starting position ASC.
usort($foundPositions, function (array $entryA, array $entryB): int {
return $entryA['start'] > $entryB['start'] ? 1 : -1;
});
$out = [];
$currentMax = -1;
foreach ($foundPositions as $foundPosition) {
// we do not allow overlapping highlights
if ($foundPosition['start'] < $currentMax) {
continue;
}
$currentMax = $foundPosition['end'];
foreach ($fieldLengths as $part => $length) {
if ($foundPosition['start'] < $length['start'] || $foundPosition['start'] > $length['end']) {
continue;
}
$out[$part][] = [
'start' => $foundPosition['start'] - $length['start'],
'end' => $foundPosition['end'] - $length['start'],
];
break;
}
}
return $out;
}
/**
* Concatenate link fields to search across fields. Adds a '\' separator for exact search terms.
* Also populate $length array with starting and ending positions of every bookmark field
* inside concatenated content.
*
* @param Bookmark $link
* @param array $lengths (by reference)
*
* @return string Lowercase concatenated fields content.
*/
protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string
{
$tagString = $link->getTagsString($this->conf->get('general.tags_separator', ' '));
$content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') . '\\';
$content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') . '\\';
$content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') . '\\';
$content .= mb_convert_case($tagString, MB_CASE_LOWER, 'UTF-8') . '\\';
$lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())];
$nextField = $lengths['title']['end'] + 1;
$lengths['description'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getDescription())];
$nextField = $lengths['description']['end'] + 1;
$lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())];
$nextField = $lengths['url']['end'] + 1;
$lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($tagString)];
return $content;
}
}

View File

@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace Shaarli\Bookmark;
use malkusch\lock\exception\LockAcquireException;
use malkusch\lock\mutex\Mutex;
use malkusch\lock\mutex\NoMutex;
use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
use Shaarli\Bookmark\Exception\EmptyDataStoreException;
use Shaarli\Bookmark\Exception\InvalidWritableDataException;
use Shaarli\Bookmark\Exception\NotEnoughSpaceException;
use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
use Shaarli\Config\ConfigManager;
/**
* Class BookmarkIO
*
* This class performs read/write operation to the file data store.
* Used by BookmarkFileService.
*
* @package Shaarli\Bookmark
*/
class BookmarkIO
{
/**
* @var string Datastore file path
*/
protected $datastore;
/**
* @var ConfigManager instance
*/
protected $conf;
/** @var Mutex */
protected $mutex;
/**
* string Datastore PHP prefix
*/
protected static $phpPrefix = '<?php /* ';
/**
* string Datastore PHP suffix
*/
protected static $phpSuffix = ' */ ?>';
/**
* LinksIO constructor.
*
* @param ConfigManager $conf instance
*/
public function __construct(ConfigManager $conf, Mutex $mutex = null)
{
if ($mutex === null) {
// This should only happen with legacy classes
$mutex = new NoMutex();
}
$this->conf = $conf;
$this->datastore = $conf->get('resource.datastore');
$this->mutex = $mutex;
}
/**
* Reads database from disk to memory
*
* @return Bookmark[]
*
* @throws NotWritableDataStoreException Data couldn't be loaded
* @throws EmptyDataStoreException Datastore file exists but does not contain any bookmark
* @throws DatastoreNotInitializedException File does not exists
*/
public function read()
{
if (! file_exists($this->datastore)) {
throw new DatastoreNotInitializedException();
}
if (!is_writable($this->datastore)) {
throw new NotWritableDataStoreException($this->datastore);
}
$content = null;
$this->synchronized(function () use (&$content) {
$content = file_get_contents($this->datastore);
});
// Note that gzinflate is faster than gzuncompress.
// See: http://www.php.net/manual/en/function.gzdeflate.php#96439
$links = unserialize(gzinflate(base64_decode(
substr($content, strlen(self::$phpPrefix), -strlen(self::$phpSuffix))
)));
if (empty($links)) {
if (filesize($this->datastore) > 100) {
throw new NotWritableDataStoreException($this->datastore);
}
throw new EmptyDataStoreException();
}
return $links;
}
/**
* Saves the database from memory to disk
*
* @param Bookmark[] $links
*
* @throws NotWritableDataStoreException the datastore is not writable
* @throws InvalidWritableDataException
*/
public function write($links)
{
if (is_file($this->datastore) && !is_writeable($this->datastore)) {
// The datastore exists but is not writeable
throw new NotWritableDataStoreException($this->datastore);
} elseif (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
// The datastore does not exist and its parent directory is not writeable
throw new NotWritableDataStoreException(dirname($this->datastore));
}
$data = base64_encode(gzdeflate(serialize($links)));
if (empty($data)) {
throw new InvalidWritableDataException();
}
$data = self::$phpPrefix . $data . self::$phpSuffix;
$this->synchronized(function () use ($data) {
if (!$this->checkDiskSpace($data)) {
throw new NotEnoughSpaceException();
}
file_put_contents(
$this->datastore,
$data
);
});
}
/**
* Wrapper applying mutex to provided function.
* If the lock can't be acquired (e.g. some shared hosting provider), we execute the function without mutex.
*
* @see https://github.com/shaarli/Shaarli/issues/1650
*
* @param callable $function
*/
protected function synchronized(callable $function): void
{
try {
$this->mutex->synchronized($function);
} catch (LockAcquireException $exception) {
$function();
}
}
/**
* Make sure that there is enough disk space available to save the current data store.
* We add an arbitrary margin of 500kB.
*
* @param string $data to be saved
*
* @return bool True if data can safely be saved
*/
public function checkDiskSpace(string $data): bool
{
return disk_free_space(dirname($this->datastore)) > (strlen($data) + 1024 * 500);
}
}

View File

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Shaarli\Bookmark;
/**
* Class BookmarkInitializer
*
* This class is used to initialized default bookmarks after a fresh install of Shaarli.
* It should be only called if the datastore file does not exist(users might want to delete the default bookmarks).
*
* To prevent data corruption, it does not overwrite existing bookmarks,
* even though there should not be any.
*
* We disable this because otherwise it creates indentation issues, and heredoc is not supported by PHP gettext.
* @phpcs:disable Generic.Files.LineLength.TooLong
*
* @package Shaarli\Bookmark
*/
class BookmarkInitializer
{
/** @var BookmarkServiceInterface */
protected $bookmarkService;
/**
* BookmarkInitializer constructor.
*
* @param BookmarkServiceInterface $bookmarkService
*/
public function __construct(BookmarkServiceInterface $bookmarkService)
{
$this->bookmarkService = $bookmarkService;
}
/**
* Initialize the data store with default bookmarks
*/
public function initialize(): void
{
$bookmark = new Bookmark();
$bookmark->setTitle('Calm Jazz Music - YouTube ' . t('(private bookmark with thumbnail demo)'));
$bookmark->setUrl('https://www.youtube.com/watch?v=DVEUcbPkb-c');
$bookmark->setDescription(t(
'Shaarli will automatically pick up the thumbnail for links to a variety of websites.
Explore your new Shaarli instance by trying out controls and menus.
Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli.
Now you can edit or delete the default shaares.
'
));
$bookmark->setTagsString('shaarli help thumbnail');
$bookmark->setPrivate(true);
$this->bookmarkService->add($bookmark, false);
$bookmark = new Bookmark();
$bookmark->setTitle(t('Note: Shaare descriptions'));
$bookmark->setDescription(t(
'Adding a shaare without entering a URL creates a text-only "note" post such as this one.
This note is private, so you are the only one able to see it while logged in.
You can use this to keep notes, post articles, code snippets, and much more.
The Markdown formatting setting allows you to format your notes and bookmark description:
### Title headings
#### Multiple headings levels
* bullet lists
* _italic_ text
* **bold** text
* ~~strike through~~ text
* `code` blocks
* images
* [links](https://en.wikipedia.org/wiki/Markdown)
Markdown also supports tables:
| Name | Type | Color | Qty |
| ------- | --------- | ------ | ----- |
| Orange | Fruit | Orange | 126 |
| Apple | Fruit | Any | 62 |
| Lemon | Fruit | Yellow | 30 |
| Carrot | Vegetable | Red | 14 |
'
));
$bookmark->setTagsString('shaarli help');
$bookmark->setPrivate(true);
$this->bookmarkService->add($bookmark, false);
$bookmark = new Bookmark();
$bookmark->setTitle(
'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service')
);
$bookmark->setDescription(t(
'Welcome to Shaarli!
Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately.
You can add a description to your bookmarks, such as this one, and tag them.
Create a new shaare by clicking the `+Shaare` button, or using any of the recommended tools (browser extension, mobile app, bookmarklet, REST API, etc.).
You can easily retrieve your links, even with thousands of them, using the internal search engine, or search through tags (e.g. this Shaare is tagged with `shaarli` and `help`).
Hashtags such as #shaarli #help are also supported.
You can also filter the available [RSS feed](/feed/atom) and picture wall by tag or plaintext search.
We hope that you will enjoy using Shaarli, maintained with ❤️ by the community!
Feel free to open [an issue](https://github.com/shaarli/Shaarli/issues) if you have a suggestion or encounter an issue.
'
));
$bookmark->setTagsString('shaarli help');
$this->bookmarkService->add($bookmark, false);
}
}

View File

@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace Shaarli\Bookmark;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
/**
* Class BookmarksService
*
* This is the entry point to manipulate the bookmark DB.
*
* Regarding return types of a list of bookmarks, it can either be an array or an ArrayAccess implementation,
* so until PHP 8.0 is the minimal supported version with union return types it cannot be explicitly added.
*/
interface BookmarkServiceInterface
{
/**
* Find a bookmark by hash
*
* @param string $hash Bookmark's hash
* @param string|null $privateKey Optional key used to access private links while logged out
*
* @return Bookmark
*
* @throws \Exception
*/
public function findByHash(string $hash, string $privateKey = null);
/**
* @param $url
*
* @return Bookmark|null
*/
public function findByUrl(string $url): ?Bookmark;
/**
* Search bookmarks
*
* @param array $request
* @param ?string $visibility
* @param bool $caseSensitive
* @param bool $untaggedOnly
* @param bool $ignoreSticky
* @param array $pagination This array can contain the following keys for pagination: limit, offset.
*
* @return SearchResult
*/
public function search(
array $request = [],
string $visibility = null,
bool $caseSensitive = false,
bool $untaggedOnly = false,
bool $ignoreSticky = false,
array $pagination = []
): SearchResult;
/**
* Get a single bookmark by its ID.
*
* @param int $id Bookmark ID
* @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
* exception
*
* @return Bookmark
*
* @throws BookmarkNotFoundException
* @throws \Exception
*/
public function get(int $id, string $visibility = null);
/**
* Updates an existing bookmark (depending on its ID).
*
* @param Bookmark $bookmark
* @param bool $save Writes to the datastore if set to true
*
* @return Bookmark Updated bookmark
*
* @throws BookmarkNotFoundException
* @throws \Exception
*/
public function set(Bookmark $bookmark, bool $save = true): Bookmark;
/**
* Adds a new bookmark (the ID must be empty).
*
* @param Bookmark $bookmark
* @param bool $save Writes to the datastore if set to true
*
* @return Bookmark new bookmark
*
* @throws \Exception
*/
public function add(Bookmark $bookmark, bool $save = true): Bookmark;
/**
* Adds or updates a bookmark depending on its ID:
* - a Bookmark without ID will be added
* - a Bookmark with an existing ID will be updated
*
* @param Bookmark $bookmark
* @param bool $save
*
* @return Bookmark
*
* @throws \Exception
*/
public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark;
/**
* Deletes a bookmark.
*
* @param Bookmark $bookmark
* @param bool $save
*
* @throws \Exception
*/
public function remove(Bookmark $bookmark, bool $save = true): void;
/**
* Get a single bookmark by its ID.
*
* @param int $id Bookmark ID
* @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
* exception
*
* @return bool
*/
public function exists(int $id, string $visibility = null): bool;
/**
* Return the number of available bookmarks for given visibility.
*
* @param ?string $visibility public|private|all
*
* @return int Number of bookmarks
*/
public function count(string $visibility = null): int;
/**
* Write the datastore.
*
* @throws NotWritableDataStoreException
*/
public function save(): void;
/**
* Returns the list tags appearing in the bookmarks with the given tags
*
* @param array|null $filteringTags tags selecting the bookmarks to consider
* @param string|null $visibility process only all/private/public bookmarks
*
* @return array tag => bookmarksCount
*/
public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array;
/**
* Return a list of bookmark matching provided period of time.
* It also update directly previous and next date outside of given period found in the datastore.
*
* @param \DateTimeInterface $from Starting date.
* @param \DateTimeInterface $to Ending date.
* @param \DateTimeInterface|null $previous (by reference) updated with first created date found before $from.
* @param \DateTimeInterface|null $next (by reference) updated with first created date found after $to.
*
* @return array List of bookmarks matching provided period of time.
*/
public function findByDate(
\DateTimeInterface $from,
\DateTimeInterface $to,
?\DateTimeInterface &$previous,
?\DateTimeInterface &$next
): array;
/**
* Returns the latest bookmark by creation date.
*
* @return Bookmark|null Found Bookmark or null if the datastore is empty.
*/
public function getLatest(): ?Bookmark;
/**
* Creates the default database after a fresh install.
*/
public function initialize(): void;
}

View File

@ -1,4 +1,15 @@
<?php
namespace Shaarli\Bookmark;
use ArrayAccess;
use Countable;
use DateTime;
use Iterator;
use Shaarli\Bookmark\Exception\LinkNotFoundException;
use Shaarli\Exceptions\IOException;
use Shaarli\FileUtils;
/**
* Data storage for links.
*
@ -18,10 +29,10 @@
* - private: Is this link private? 0=no, other value=yes
* - tags: tags attached to this entry (separated by spaces)
* - title Title of the link
* - url URL of the link. Used for displayable links (no redirector, relative, etc.).
* Can be absolute or relative.
* Relative URLs are permalinks (e.g.'?m-ukcw')
* - real_url Absolute processed URL.
* - url URL of the link. Used for displayable links.
* Can be absolute or relative in the database but the relative links
* will be converted to absolute ones in templates.
* - real_url Raw URL in stored in the DB (absolute or relative).
* - shorturl Permalink smallhash
*
* Implements 3 interfaces:
@ -77,19 +88,6 @@ class LinkDB implements Iterator, Countable, ArrayAccess
// Hide public links
private $hidePublicLinks;
// link redirector set in user settings.
private $redirector;
/**
* Set this to `true` to urlencode link behind redirector link, `false` to leave it untouched.
*
* Example:
* anonym.to needs clean URL while dereferer.org needs urlencoded URL.
*
* @var boolean $redirectorEncode parameter: true or false
*/
private $redirectorEncode;
/**
* Creates a new LinkDB
*
@ -98,22 +96,16 @@ class LinkDB implements Iterator, Countable, ArrayAccess
* @param string $datastore datastore file path.
* @param boolean $isLoggedIn is the user logged in?
* @param boolean $hidePublicLinks if true all links are private.
* @param string $redirector link redirector set in user settings.
* @param boolean $redirectorEncode Enable urlencode on redirected urls (default: true).
*/
public function __construct(
$datastore,
$isLoggedIn,
$hidePublicLinks,
$redirector = '',
$redirectorEncode = true
)
{
$hidePublicLinks
) {
$this->datastore = $datastore;
$this->loggedIn = $isLoggedIn;
$this->hidePublicLinks = $hidePublicLinks;
$this->redirector = $redirector;
$this->redirectorEncode = $redirectorEncode === true;
$this->check();
$this->read();
}
@ -138,7 +130,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
if (!isset($value['id']) || empty($value['url'])) {
die(t('Internal Error: A link should always have an id and URL.'));
}
if (($offset !== null && ! is_int($offset)) || ! is_int($value['id'])) {
if (($offset !== null && !is_int($offset)) || !is_int($value['id'])) {
die(t('You must specify an integer as a key.'));
}
if ($offset !== null && $offset !== $value['id']) {
@ -248,28 +240,33 @@ class LinkDB implements Iterator, Countable, ArrayAccess
$this->links = array();
$link = array(
'id' => 1,
'title'=> t('The personal, minimalist, super-fast, database free, bookmarking service'),
'url'=>'https://shaarli.readthedocs.io',
'description'=>t('Welcome to Shaarli! This is your first public bookmark. To edit or delete me, you must first login.
'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'),
'url' => 'https://shaarli.readthedocs.io',
'description' => t(
'Welcome to Shaarli! This is your first public bookmark. '
. 'To edit or delete me, you must first login.
To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page.
You use the community supported version of the original Shaarli project, by Sebastien Sauvage.'),
'private'=>0,
'created'=> new DateTime(),
'tags'=>'opensource software'
You use the community supported version of the original Shaarli project, by Sebastien Sauvage.'
),
'private' => 0,
'created' => new DateTime(),
'tags' => 'opensource software',
'sticky' => false,
);
$link['shorturl'] = link_small_hash($link['created'], $link['id']);
$this->links[1] = $link;
$link = array(
'id' => 0,
'title'=> t('My secret stuff... - Pastebin.com'),
'url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
'description'=> t('Shhhh! I\'m a private link only YOU can see. You can delete me too.'),
'private'=>1,
'created'=> new DateTime('1 minute ago'),
'tags'=>'secretstuff',
'title' => t('My secret stuff... - Pastebin.com'),
'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
'description' => t('Shhhh! I\'m a private link only YOU can see. You can delete me too.'),
'private' => 1,
'created' => new DateTime('1 minute ago'),
'tags' => 'secretstuff',
'sticky' => false,
);
$link['shorturl'] = link_small_hash($link['created'], $link['id']);
$this->links[0] = $link;
@ -295,7 +292,7 @@ You use the community supported version of the original Shaarli project, by Seba
$toremove = array();
foreach ($this->links as $key => &$link) {
if (! $this->loggedIn && $link['private'] != 0) {
if (!$this->loggedIn && $link['private'] != 0) {
// Transition for not upgraded databases.
unset($this->links[$key]);
continue;
@ -305,28 +302,21 @@ You use the community supported version of the original Shaarli project, by Seba
sanitizeLink($link);
// Remove private tags if the user is not logged in.
if (! $this->loggedIn) {
if (!$this->loggedIn) {
$link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']);
}
// Do not use the redirector for internal links (Shaarli note URL starting with a '?').
if (!empty($this->redirector) && !startsWith($link['url'], '?')) {
$link['real_url'] = $this->redirector;
if ($this->redirectorEncode) {
$link['real_url'] .= urlencode(unescape($link['url']));
} else {
$link['real_url'] .= $link['url'];
}
}
else {
$link['real_url'] = $link['url'];
}
$link['real_url'] = $link['url'];
$link['sticky'] = isset($link['sticky']) ? $link['sticky'] : false;
$link['sticky'] = isset($link['sticky']) ? $link['sticky'] : false;
// To be able to load links before running the update, and prepare the update
if (! isset($link['created'])) {
if (!isset($link['created'])) {
$link['id'] = $link['linkdate'];
$link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']);
if (! empty($link['updated'])) {
if (!empty($link['updated'])) {
$link['updated'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['updated']);
}
$link['shorturl'] = smallHash($link['linkdate']);
@ -403,7 +393,8 @@ You use the community supported version of the original Shaarli project, by Seba
*
* @return array list of shaare found.
*/
public function filterDay($request) {
public function filterDay($request)
{
$linkFilter = new LinkFilter($this->links);
return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request);
}
@ -411,17 +402,22 @@ You use the community supported version of the original Shaarli project, by Seba
/**
* Filter links according to search parameters.
*
* @param array $filterRequest Search request content. Supported keys:
* @param array $filterRequest Search request content. Supported keys:
* - searchtags: list of tags
* - searchterm: term search
* @param bool $casesensitive Optional: Perform case sensitive filter
* @param string $visibility return only all/private/public links
* @param string $untaggedonly return only untagged links
* @param bool $casesensitive Optional: Perform case sensitive filter
* @param string $visibility return only all/private/public links
* @param bool $untaggedonly return only untagged links
*
* @return array filtered links, all links if no suitable filter was provided.
*/
public function filterSearch($filterRequest = array(), $casesensitive = false, $visibility = 'all', $untaggedonly = false)
{
public function filterSearch(
$filterRequest = array(),
$casesensitive = false,
$visibility = 'all',
$untaggedonly = false
) {
// Filter link database according to parameters.
$searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
$searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
@ -437,8 +433,8 @@ You use the community supported version of the original Shaarli project, by Seba
/**
* Returns the list tags appearing in the links with the given tags
*
* @param array $filteringTags tags selecting the links to consider
* @param string $visibility process only all/private/public links
* @param array $filteringTags tags selecting the links to consider
* @param string $visibility process only all/private/public links
*
* @return array tag => linksCount
*/
@ -492,8 +488,7 @@ You use the community supported version of the original Shaarli project, by Seba
$delete = empty($to);
// True for case-sensitive tag search.
$linksToAlter = $this->filterSearch(['searchtags' => $from], true);
foreach($linksToAlter as $key => &$value)
{
foreach ($linksToAlter as $key => &$value) {
$tags = preg_split('/\s+/', trim($value['tags']));
if (($pos = array_search($from, $tags)) !== false) {
if ($delete) {
@ -536,7 +531,10 @@ You use the community supported version of the original Shaarli project, by Seba
{
$order = $order === 'ASC' ? -1 : 1;
// Reorder array by dates.
usort($this->links, function($a, $b) use ($order) {
usort($this->links, function ($a, $b) use ($order) {
if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) {
return $a['sticky'] ? -1 : 1;
}
return $a['created'] < $b['created'] ? 1 * $order : -1 * $order;
});

View File

@ -1,5 +1,10 @@
<?php
namespace Shaarli\Bookmark;
use Exception;
use Shaarli\Bookmark\Exception\LinkNotFoundException;
/**
* Class LinkFilter.
*
@ -10,22 +15,22 @@ class LinkFilter
/**
* @var string permalinks.
*/
public static $FILTER_HASH = 'permalink';
public static $FILTER_HASH = 'permalink';
/**
* @var string text search.
*/
public static $FILTER_TEXT = 'fulltext';
public static $FILTER_TEXT = 'fulltext';
/**
* @var string tag filter.
*/
public static $FILTER_TAG = 'tags';
public static $FILTER_TAG = 'tags';
/**
* @var string filter by day.
*/
public static $FILTER_DAY = 'FILTER_DAY';
public static $FILTER_DAY = 'FILTER_DAY';
/**
* @var string Allowed characters for hashtags (regex syntax).
@ -58,11 +63,11 @@ class LinkFilter
*/
public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false)
{
if (! in_array($visibility, ['all', 'public', 'private'])) {
if (!in_array($visibility, ['all', 'public', 'private'])) {
$visibility = 'all';
}
switch($type) {
switch ($type) {
case self::$FILTER_HASH:
return $this->filterSmallHash($request);
case self::$FILTER_TAG | self::$FILTER_TEXT: // == "vuotext"
@ -117,7 +122,7 @@ class LinkFilter
foreach ($this->links as $key => $value) {
if ($value['private'] && $visibility === 'private') {
$out[$key] = $value;
} elseif (! $value['private'] && $visibility === 'public') {
} elseif (!$value['private'] && $visibility === 'public') {
$out[$key] = $value;
}
}
@ -132,7 +137,7 @@ class LinkFilter
*
* @return array $filtered array containing permalink data.
*
* @throws LinkNotFoundException if the smallhash doesn't match any link.
* @throws \Shaarli\Bookmark\Exception\LinkNotFoundException if the smallhash doesn't match any link.
*/
private function filterSmallHash($smallHash)
{
@ -169,7 +174,7 @@ class LinkFilter
* - see https://github.com/shaarli/Shaarli/issues/75 for examples
*
* @param string $searchterms search query.
* @param string $visibility Optional: return only all/private/public links.
* @param string $visibility Optional: return only all/private/public links.
*
* @return array search results.
*/
@ -205,10 +210,9 @@ class LinkFilter
// Iterate over every stored link.
foreach ($this->links as $id => $link) {
// ignore non private links when 'privatonly' is on.
if ($visibility !== 'all') {
if (! $link['private'] && $visibility === 'private') {
if (!$link['private'] && $visibility === 'private') {
continue;
} elseif ($link['private'] && $visibility === 'public') {
continue;
@ -251,17 +255,19 @@ class LinkFilter
/**
* generate a regex fragment out of a tag
*
* @param string $tag to to generate regexs from. may start with '-' to negate, contain '*' as wildcard
*
* @return string generated regex fragment
*/
private static function tag2regex($tag)
{
$len = strlen($tag);
if(!$len || $tag === "-" || $tag === "*"){
if (!$len || $tag === "-" || $tag === "*") {
// nothing to search, return empty regex
return '';
}
if($tag[0] === "-") {
if ($tag[0] === "-") {
// query is negated
$i = 1; // use offset to start after '-' character
$regex = '(?!'; // create negative lookahead
@ -271,14 +277,14 @@ class LinkFilter
}
$regex .= '.*(?:^| )'; // before tag may only be a space or the beginning
// iterate over string, separating it into placeholder and content
for(; $i < $len; $i++){
if($tag[$i] === '*'){
for (; $i < $len; $i++) {
if ($tag[$i] === '*') {
// placeholder found
$regex .= '[^ ]*?';
} else {
// regular characters
$offset = strpos($tag, '*', $i);
if($offset === false){
if ($offset === false) {
// no placeholder found, set offset to end of string
$offset = $len;
}
@ -310,19 +316,19 @@ class LinkFilter
{
// get single tags (we may get passed an array, even though the docs say different)
$inputTags = $tags;
if(!is_array($tags)) {
if (!is_array($tags)) {
// we got an input string, split tags
$inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY);
}
if(!count($inputTags)){
if (!count($inputTags)) {
// no input tags
return $this->noFilter($visibility);
}
// build regex from all tags
$re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/';
if(!$casesensitive) {
if (!$casesensitive) {
// make regex case insensitive
$re .= 'i';
}
@ -335,14 +341,14 @@ class LinkFilter
// check level of visibility
// ignore non private links when 'privateonly' is on.
if ($visibility !== 'all') {
if (! $link['private'] && $visibility === 'private') {
if (!$link['private'] && $visibility === 'private') {
continue;
} elseif ($link['private'] && $visibility === 'public') {
continue;
}
}
$search = $link['tags']; // build search string, start with tags of current link
if(strlen(trim($link['description'])) && strpos($link['description'], '#') !== false){
if (strlen(trim($link['description'])) && strpos($link['description'], '#') !== false) {
// description given and at least one possible tag found
$descTags = array();
// find all tags in the form of #tag in the description
@ -351,13 +357,13 @@ class LinkFilter
$link['description'],
$descTags
);
if(count($descTags[1])){
if (count($descTags[1])) {
// there were some tags in the description, add them to the search string
$search .= ' ' . implode(' ', $descTags[1]);
}
};
// match regular expression with search string
if(!preg_match($re, $search)){
if (!preg_match($re, $search)) {
// this entry does _not_ match our regex
continue;
}
@ -378,7 +384,7 @@ class LinkFilter
$filtered = [];
foreach ($this->links as $key => $link) {
if ($visibility !== 'all') {
if (! $link['private'] && $visibility === 'private') {
if (!$link['private'] && $visibility === 'private') {
continue;
} elseif ($link['private'] && $visibility === 'public') {
continue;
@ -407,7 +413,7 @@ class LinkFilter
*/
public function filterDay($day)
{
if (! checkDateFormat('Ymd', $day)) {
if (!checkDateFormat('Ymd', $day)) {
throw new Exception('Invalid date format');
}
@ -441,14 +447,3 @@ class LinkFilter
return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY);
}
}
class LinkNotFoundException extends Exception
{
/**
* LinkNotFoundException constructor.
*/
public function __construct()
{
$this->message = t('The link you are trying to reach does not exist or has been deleted.');
}
}

View File

@ -0,0 +1,253 @@
<?php
use Shaarli\Bookmark\Bookmark;
use Shaarli\Formatter\BookmarkDefaultFormatter;
/**
* Extract title from an HTML document.
*
* @param string $html HTML content where to look for a title.
*
* @return bool|string Extracted title if found, false otherwise.
*/
function html_extract_title($html)
{
if (preg_match('!<title.*?>(.*?)</title>!is', $html, $matches)) {
return trim(str_replace("\n", '', $matches[1]));
}
return false;
}
/**
* Extract charset from HTTP header if it's defined.
*
* @param string $header HTTP header Content-Type line.
*
* @return bool|string Charset string if found (lowercase), false otherwise.
*/
function header_extract_charset($header)
{
preg_match('/charset=["\']?([^; "\']+)/i', $header, $match);
if (! empty($match[1])) {
return strtolower(trim($match[1]));
}
return false;
}
/**
* Extract charset HTML content (tag <meta charset>).
*
* @param string $html HTML content where to look for charset.
*
* @return bool|string Charset string if found, false otherwise.
*/
function html_extract_charset($html)
{
// Get encoding specified in HTML header.
preg_match('#<meta .*charset=["\']?([^";\'>/]+)["\']? */?>#Usi', $html, $enc);
if (!empty($enc[1])) {
return strtolower($enc[1]);
}
return false;
}
/**
* Extract meta tag from HTML content in either:
* - OpenGraph: <meta property="og:[tag]" ...>
* - Meta tag: <meta name="[tag]" ...>
*
* @param string $tag Name of the tag to retrieve.
* @param string $html HTML content where to look for charset.
*
* @return bool|string Charset string if found, false otherwise.
*/
function html_extract_tag($tag, $html)
{
$propertiesKey = ['property', 'name', 'itemprop'];
$properties = implode('|', $propertiesKey);
// We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"'
$orCondition = '["\']?(?:og:)?' . $tag . '["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]';
// Support quotes in double quoted content, and the other way around
$content = 'content=(["\'])((?:(?!\1).)*)\1';
// Try to retrieve OpenGraph tag.
$ogRegex = '#<meta[^>]+(?:' . $properties . ')=(?:' . $orCondition . ')[^>]*' . $content . '.*?>#';
// If the attributes are not in the order property => content (e.g. Github)
// New regex to keep this readable... more or less.
$ogRegexReverse = '#<meta[^>]+' . $content . '[^>]+(?:' . $properties . ')=(?:' . $orCondition . ').*?>#';
if (
preg_match($ogRegex, $html, $matches) > 0
|| preg_match($ogRegexReverse, $html, $matches) > 0
) {
return $matches[2];
}
return false;
}
/**
* In a string, converts URLs to clickable bookmarks.
*
* @param string $text input string.
*
* @return string returns $text with all bookmarks converted to HTML bookmarks.
*
* @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
*/
function text2clickable($text)
{
$regex = '!(((?:https?|ftp|file)://|apt:|magnet:)\S+[a-z0-9\(\)]/?)!si';
$format = function (array $match): string {
return '<a href="' .
str_replace(
BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN,
'',
str_replace(BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE, '', $match[1])
) .
'">' . $match[1] . '</a>'
;
};
return preg_replace_callback($regex, $format, $text);
}
/**
* Auto-link hashtags.
*
* @param string $description Given description.
* @param string $indexUrl Root URL.
*
* @return string Description with auto-linked hashtags.
*/
function hashtag_autolink($description, $indexUrl = '')
{
$tokens = '(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN . ')' .
'(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE . ')'
;
/*
* To support unicode: http://stackoverflow.com/a/35498078/1484919
* \p{Pc} - to match underscore
* \p{N} - numeric character in any script
* \p{L} - letter from any language
* \p{Mn} - any non marking space (accents, umlauts, etc)
*/
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}' . $tokens . ']+)/mui';
$format = function (array $match) use ($indexUrl): string {
$cleanMatch = str_replace(
BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN,
'',
str_replace(BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE, '', $match[2])
);
return $match[1] . '<a href="' . $indexUrl . './add-tag/' . $cleanMatch . '"' .
' title="Hashtag ' . $cleanMatch . '">' .
'#' . $match[2] .
'</a>';
};
return preg_replace_callback($regex, $format, $description);
}
/**
* This function inserts &nbsp; where relevant so that multiple spaces are properly displayed in HTML
* even in the absence of <pre> (This is used in description to keep text formatting).
*
* @param string $text input text.
*
* @return string formatted text.
*/
function space2nbsp($text)
{
return preg_replace('/(^| ) /m', '$1&nbsp;', $text);
}
/**
* Format Shaarli's description
*
* @param string $description shaare's description.
* @param string $indexUrl URL to Shaarli's index.
* @param bool $autolink Turn on/off automatic linkifications of URLs and hashtags
*
* @return string formatted description.
*/
function format_description($description, $indexUrl = '', $autolink = true)
{
if ($autolink) {
$description = hashtag_autolink(text2clickable($description), $indexUrl);
}
return nl2br(space2nbsp($description));
}
/**
* Generate a small hash for a link.
*
* @param DateTime $date Link creation date.
* @param int $id Link ID.
*
* @return string the small hash generated from link data.
*/
function link_small_hash($date, $id)
{
return smallHash($date->format(Bookmark::LINK_DATE_FORMAT) . $id);
}
/**
* Returns whether or not the link is an internal note.
* Its URL starts by `?` because it's actually a permalink.
*
* @param string $linkUrl
*
* @return bool true if internal note, false otherwise.
*/
function is_note($linkUrl)
{
return isset($linkUrl[0]) && $linkUrl[0] === '?';
}
/**
* Extract an array of tags from a given tag string, with provided separator.
*
* @param string|null $tags String containing a list of tags separated by $separator.
* @param string $separator Shaarli's default: ' ' (whitespace)
*
* @return array List of tags
*/
function tags_str2array(?string $tags, string $separator): array
{
// For whitespaces, we use the special \s regex character
$separator = str_replace([' ', '/'], ['\s', '\/'], $separator);
return preg_split('/\s*' . $separator . '+\s*/', trim($tags ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
}
/**
* Return a tag string with provided separator from a list of tags.
* Note that given array is clean up by tags_filter().
*
* @param array|null $tags List of tags
* @param string $separator
*
* @return string
*/
function tags_array2str(?array $tags, string $separator): string
{
return implode($separator, tags_filter($tags, $separator));
}
/**
* Clean an array of tags: trim + remove empty entries
*
* @param array|null $tags List of tags
* @param string $separator
*
* @return array
*/
function tags_filter(?array $tags, string $separator): array
{
$trimDefault = " \t\n\r\0\x0B";
return array_values(array_filter(array_map(function (string $entry) use ($separator, $trimDefault): string {
return trim($entry, $trimDefault . $separator);
}, $tags ?? [])));
}

View File

@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace Shaarli\Bookmark;
/**
* Read-only class used to represent search result, including pagination.
*/
class SearchResult
{
/** @var Bookmark[] List of result bookmarks with pagination applied */
protected $bookmarks;
/** @var int number of Bookmarks found, with pagination applied */
protected $resultCount;
/** @var int total number of result found */
protected $totalCount;
/** @var int pagination: limit number of result bookmarks */
protected $limit;
/** @var int pagination: offset to apply to complete result list */
protected $offset;
public function __construct(array $bookmarks, int $totalCount, int $offset, ?int $limit)
{
$this->bookmarks = $bookmarks;
$this->resultCount = count($bookmarks);
$this->totalCount = $totalCount;
$this->limit = $limit;
$this->offset = $offset;
}
/**
* Build a SearchResult from provided full result set and pagination settings.
*
* @param Bookmark[] $bookmarks Full set of result which will be filtered
* @param int $offset Start recording results from $offset
* @param int|null $limit End recording results after $limit bookmarks is reached
* @param bool $allowOutOfBounds Set to false to display the last page if the offset is out of bound,
* return empty result set otherwise (default: false)
*
* @return SearchResult
*/
public static function getSearchResult(
$bookmarks,
int $offset = 0,
?int $limit = null,
bool $allowOutOfBounds = false
): self {
$totalCount = count($bookmarks);
if (!$allowOutOfBounds && $offset > $totalCount) {
$offset = $limit === null ? 0 : $limit * -1;
}
if ($bookmarks instanceof BookmarkArray) {
$buffer = [];
foreach ($bookmarks as $key => $value) {
$buffer[$key] = $value;
}
$bookmarks = $buffer;
}
return new static(
array_slice($bookmarks, $offset, $limit, true),
$totalCount,
$offset,
$limit
);
}
/** @return Bookmark[] List of result bookmarks with pagination applied */
public function getBookmarks(): array
{
return $this->bookmarks;
}
/** @return int number of Bookmarks found, with pagination applied */
public function getResultCount(): int
{
return $this->resultCount;
}
/** @return int total number of result found */
public function getTotalCount(): int
{
return $this->totalCount;
}
/** @return int pagination: limit number of result bookmarks */
public function getLimit(): ?int
{
return $this->limit;
}
/** @return int pagination: offset to apply to complete result list */
public function getOffset(): int
{
return $this->offset;
}
/** @return int Current page of result set in complete results */
public function getPage(): int
{
if (empty($this->limit)) {
return $this->offset === 0 ? 1 : 2;
}
$base = $this->offset >= 0 ? $this->offset : $this->totalCount + $this->offset;
return (int) ceil($base / $this->limit) + 1;
}
/** @return int Get the # of the last page */
public function getLastPage(): int
{
if (empty($this->limit)) {
return $this->offset === 0 ? 1 : 2;
}
return (int) ceil($this->totalCount / $this->limit);
}
/** @return bool Either the current page is the last one or not */
public function isLastPage(): bool
{
return $this->getPage() === $this->getLastPage();
}
/** @return bool Either the current page is the first one or not */
public function isFirstPage(): bool
{
return $this->offset === 0;
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace Shaarli\Bookmark\Exception;
use Exception;
class BookmarkNotFoundException extends Exception
{
/**
* LinkNotFoundException constructor.
*/
public function __construct()
{
$this->message = t('The link you are trying to reach does not exist or has been deleted.');
}
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Shaarli\Bookmark\Exception;
class DatastoreNotInitializedException extends \Exception
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace Shaarli\Bookmark\Exception;
class EmptyDataStoreException extends \Exception
{
}

View File

@ -0,0 +1,30 @@
<?php
namespace Shaarli\Bookmark\Exception;
use Shaarli\Bookmark\Bookmark;
class InvalidBookmarkException extends \Exception
{
public function __construct($bookmark)
{
if ($bookmark instanceof Bookmark) {
if ($bookmark->getCreated() instanceof \DateTime) {
$created = $bookmark->getCreated()->format(\DateTime::ATOM);
} elseif (empty($bookmark->getCreated())) {
$created = '';
} else {
$created = 'Not a DateTime object';
}
$this->message = 'This bookmark is not valid' . PHP_EOL;
$this->message .= ' - ID: ' . $bookmark->getId() . PHP_EOL;
$this->message .= ' - Title: ' . $bookmark->getTitle() . PHP_EOL;
$this->message .= ' - Url: ' . $bookmark->getUrl() . PHP_EOL;
$this->message .= ' - ShortUrl: ' . $bookmark->getShortUrl() . PHP_EOL;
$this->message .= ' - Created: ' . $created . PHP_EOL;
} else {
$this->message = 'The provided data is not a bookmark' . PHP_EOL;
$this->message .= var_export($bookmark, true);
}
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace Shaarli\Bookmark\Exception;
class InvalidWritableDataException extends \Exception
{
/**
* InvalidWritableDataException constructor.
*/
public function __construct()
{
$this->message = 'Couldn\'t generate bookmark data to store in the datastore. Skipping file writing.';
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace Shaarli\Bookmark\Exception;
use Exception;
class LinkNotFoundException extends Exception
{
/**
* LinkNotFoundException constructor.
*/
public function __construct()
{
$this->message = t('The link you are trying to reach does not exist or has been deleted.');
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace Shaarli\Bookmark\Exception;
class NotEnoughSpaceException extends \Exception
{
/**
* NotEnoughSpaceException constructor.
*/
public function __construct()
{
$this->message = 'Not enough available disk space to save the datastore.';
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace Shaarli\Bookmark\Exception;
class NotWritableDataStoreException extends \Exception
{
/**
* NotReadableDataStore constructor.
*
* @param string $dataStore file path
*/
public function __construct($dataStore)
{
$this->message = 'Couldn\'t load data from the data store file "' . $dataStore . '". ' .
'Your data might be corrupted, or your file isn\'t readable.';
}
}

View File

@ -1,4 +1,5 @@
<?php
namespace Shaarli\Config;
/**

View File

@ -19,7 +19,7 @@ class ConfigJson implements ConfigIO
$data = file_get_contents($filepath);
$data = str_replace(self::getPhpHeaders(), '', $data);
$data = str_replace(self::getPhpSuffix(), '', $data);
$data = json_decode($data, true);
$data = json_decode(trim($data), true);
if ($data === null) {
$errorCode = json_last_error();
$error = sprintf(
@ -46,8 +46,8 @@ class ConfigJson implements ConfigIO
// JSON_PRETTY_PRINT is available from PHP 5.4.
$print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0;
$data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix();
if (!file_put_contents($filepath, $data)) {
throw new \IOException(
if (empty($filepath) || !file_put_contents($filepath, $data)) {
throw new \Shaarli\Exceptions\IOException(
$filepath,
t('Shaarli could not create the config file. '.
'Please make sure Shaarli has the right to write in the folder is it installed in.')
@ -73,7 +73,7 @@ class ConfigJson implements ConfigIO
*/
public static function getPhpHeaders()
{
return '<?php /*'. PHP_EOL;
return '<?php /*';
}
/**
@ -85,6 +85,6 @@ class ConfigJson implements ConfigIO
*/
public static function getPhpSuffix()
{
return PHP_EOL . '*/ ?>';
return '*/ ?>';
}
}

View File

@ -1,8 +1,10 @@
<?php
namespace Shaarli\Config;
use Shaarli\Config\Exception\MissingFieldConfigException;
use Shaarli\Config\Exception\UnauthorizedConfigException;
use Shaarli\Thumbnailer;
/**
* Class ConfigManager
@ -19,7 +21,7 @@ class ConfigManager
*/
protected static $NOT_FOUND = 'NOT_FOUND';
public static $DEFAULT_PLUGINS = array('qrcode');
public static $DEFAULT_PLUGINS = ['qrcode'];
/**
* @var string Config folder.
@ -132,7 +134,7 @@ class ConfigManager
public function set($setting, $value, $write = false, $isLoggedIn = false)
{
if (empty($setting) || ! is_string($setting)) {
throw new \Exception(t('Invalid setting key parameter. String expected, got: '). gettype($setting));
throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting));
}
// During the ConfigIO transition, map legacy settings to the new ones.
@ -159,7 +161,7 @@ class ConfigManager
public function remove($setting, $write = false, $isLoggedIn = false)
{
if (empty($setting) || ! is_string($setting)) {
throw new \Exception(t('Invalid setting key parameter. String expected, got: '). gettype($setting));
throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting));
}
// During the ConfigIO transition, map legacy settings to the new ones.
@ -207,12 +209,12 @@ class ConfigManager
*
* @throws MissingFieldConfigException: a mandatory field has not been provided in $conf.
* @throws UnauthorizedConfigException: user is not authorize to change configuration.
* @throws \IOException: an error occurred while writing the new config file.
* @throws \Shaarli\Exceptions\IOException: an error occurred while writing the new config file.
*/
public function write($isLoggedIn)
{
// These fields are required in configuration.
$mandatoryFields = array(
$mandatoryFields = [
'credentials.login',
'credentials.hash',
'credentials.salt',
@ -221,8 +223,7 @@ class ConfigManager
'general.title',
'general.header_link',
'privacy.default_private_links',
'redirector.url',
);
];
// Only logged in user can alter config.
if (is_file($this->getConfigFileExt()) && !$isLoggedIn) {
@ -362,13 +363,16 @@ class ConfigManager
$this->setEmpty('security.open_shaarli', false);
$this->setEmpty('security.allowed_protocols', ['ftp', 'ftps', 'magnet']);
$this->setEmpty('general.header_link', '?');
$this->setEmpty('general.header_link', '/');
$this->setEmpty('general.links_per_page', 20);
$this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
$this->setEmpty('general.default_note_title', 'Note: ');
$this->setEmpty('general.retrieve_description', true);
$this->setEmpty('general.enable_async_metadata', true);
$this->setEmpty('general.tags_separator', ' ');
$this->setEmpty('updates.check_updates', false);
$this->setEmpty('updates.check_updates_branch', 'stable');
$this->setEmpty('updates.check_updates', true);
$this->setEmpty('updates.check_updates_branch', 'latest');
$this->setEmpty('updates.check_updates_interval', 86400);
$this->setEmpty('feed.rss_permalinks', true);
@ -381,9 +385,7 @@ class ConfigManager
// default state of the 'remember me' checkbox of the login form
$this->setEmpty('privacy.remember_user_default', true);
$this->setEmpty('redirector.url', '');
$this->setEmpty('redirector.encode_url', true);
$this->setEmpty('thumbnails.mode', Thumbnailer::MODE_ALL);
$this->setEmpty('thumbnails.width', '125');
$this->setEmpty('thumbnails.height', '90');
@ -391,7 +393,9 @@ class ConfigManager
$this->setEmpty('translation.mode', 'php');
$this->setEmpty('translation.extensions', []);
$this->setEmpty('plugins', array());
$this->setEmpty('plugins', []);
$this->setEmpty('formatter', 'markdown');
}
/**

View File

@ -1,4 +1,5 @@
<?php
namespace Shaarli\Config;
/**
@ -12,7 +13,7 @@ class ConfigPhp implements ConfigIO
/**
* @var array List of config key without group.
*/
public static $ROOT_KEYS = array(
public static $ROOT_KEYS = [
'login',
'hash',
'salt',
@ -22,16 +23,16 @@ class ConfigPhp implements ConfigIO
'redirector',
'disablesessionprotection',
'privateLinkByDefault',
);
];
/**
* Map legacy config keys with the new ones.
* If ConfigPhp is used, getting <newkey> will actually look for <legacykey>.
* The Updater will use this array to transform keys when switching to JSON.
* The updater will use this array to transform keys when switching to JSON.
*
* @var array current key => legacy key.
*/
public static $LEGACY_KEYS_MAPPING = array(
public static $LEGACY_KEYS_MAPPING = [
'credentials.login' => 'login',
'credentials.hash' => 'hash',
'credentials.salt' => 'salt',
@ -68,7 +69,7 @@ class ConfigPhp implements ConfigIO
'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS',
'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS',
'security.open_shaarli' => 'config.OPEN_SHAARLI',
);
];
/**
* @inheritdoc
@ -76,12 +77,12 @@ class ConfigPhp implements ConfigIO
public function read($filepath)
{
if (! file_exists($filepath) || ! is_readable($filepath)) {
return array();
return [];
}
include $filepath;
$out = array();
$out = [];
foreach (self::$ROOT_KEYS as $key) {
$out[$key] = isset($GLOBALS[$key]) ? $GLOBALS[$key] : '';
}
@ -95,7 +96,7 @@ class ConfigPhp implements ConfigIO
*/
public function write($filepath, $conf)
{
$configStr = '<?php '. PHP_EOL;
$configStr = '<?php ' . PHP_EOL;
foreach (self::$ROOT_KEYS as $key) {
if (isset($conf[$key])) {
$configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL;
@ -104,21 +105,30 @@ class ConfigPhp implements ConfigIO
// Store all $conf['config']
foreach ($conf['config'] as $key => $value) {
$configStr .= '$GLOBALS[\'config\'][\''. $key .'\'] = '.var_export($conf['config'][$key], true).';'. PHP_EOL;
$configStr .= '$GLOBALS[\'config\'][\''
. $key
. '\'] = '
. var_export($conf['config'][$key], true) . ';'
. PHP_EOL;
}
if (isset($conf['plugins'])) {
foreach ($conf['plugins'] as $key => $value) {
$configStr .= '$GLOBALS[\'plugins\'][\''. $key .'\'] = '.var_export($conf['plugins'][$key], true).';'. PHP_EOL;
$configStr .= '$GLOBALS[\'plugins\'][\''
. $key
. '\'] = '
. var_export($conf['plugins'][$key], true) . ';'
. PHP_EOL;
}
}
if (!file_put_contents($filepath, $configStr)
if (
!file_put_contents($filepath, $configStr)
|| strcmp(file_get_contents($filepath), $configStr) != 0
) {
throw new \IOException(
throw new \Shaarli\Exceptions\IOException(
$filepath,
t('Shaarli could not create the config file. '.
t('Shaarli could not create the config file. ' .
'Please make sure Shaarli has the right to write in the folder is it installed in.')
);
}

View File

@ -1,6 +1,7 @@
<?php
use Shaarli\Config\Exception\PluginConfigOrderException;
use Shaarli\Plugin\PluginManager;
/**
* Plugin configuration helper functions.
@ -19,13 +20,27 @@ use Shaarli\Config\Exception\PluginConfigOrderException;
*/
function save_plugin_config($formData)
{
// We can only save existing plugins
$directories = str_replace(
PluginManager::$PLUGINS_PATH . '/',
'',
glob(PluginManager::$PLUGINS_PATH . '/*')
);
$formData = array_filter(
$formData,
function ($value, string $key) use ($directories) {
return startsWith($key, 'order') || in_array($key, $directories);
},
ARRAY_FILTER_USE_BOTH
);
// Make sure there are no duplicates in orders.
if (!validate_plugin_order($formData)) {
throw new PluginConfigOrderException();
}
$plugins = array();
$newEnabledPlugins = array();
$plugins = [];
$newEnabledPlugins = [];
foreach ($formData as $key => $data) {
if (startsWith($key, 'order')) {
continue;
@ -34,8 +49,7 @@ function save_plugin_config($formData)
// If there is no order, it means a disabled plugin has been enabled.
if (isset($formData['order_' . $key])) {
$plugins[(int) $formData['order_' . $key]] = $key;
}
else {
} else {
$newEnabledPlugins[] = $key;
}
}
@ -48,7 +62,7 @@ function save_plugin_config($formData)
throw new PluginConfigOrderException();
}
$finalPlugins = array();
$finalPlugins = [];
// Make plugins order continuous.
foreach ($plugins as $plugin) {
$finalPlugins[] = $plugin;
@ -67,10 +81,10 @@ function save_plugin_config($formData)
*/
function validate_plugin_order($formData)
{
$orders = array();
$orders = [];
foreach ($formData as $key => $value) {
// No duplicate order allowed.
if (in_array($value, $orders)) {
if (in_array($value, $orders, true)) {
return false;
}

View File

@ -1,6 +1,5 @@
<?php
namespace Shaarli\Config\Exception;
/**

View File

@ -1,6 +1,5 @@
<?php
namespace Shaarli\Config\Exception;
/**

View File

@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace Shaarli\Container;
use malkusch\lock\mutex\FlockMutex;
use Psr\Log\LoggerInterface;
use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
use Shaarli\Feed\FeedBuilder;
use Shaarli\Formatter\FormatterFactory;
use Shaarli\Front\Controller\Visitor\ErrorController;
use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
use Shaarli\History;
use Shaarli\Http\HttpAccess;
use Shaarli\Http\MetadataRetriever;
use Shaarli\Netscape\NetscapeBookmarkUtils;
use Shaarli\Plugin\PluginManager;
use Shaarli\Render\PageBuilder;
use Shaarli\Render\PageCacheManager;
use Shaarli\Security\CookieManager;
use Shaarli\Security\LoginManager;
use Shaarli\Security\SessionManager;
use Shaarli\Thumbnailer;
use Shaarli\Updater\Updater;
use Shaarli\Updater\UpdaterUtils;
/**
* Class ContainerBuilder
*
* Helper used to build a Slim container instance with Shaarli's object dependencies.
* Note that most injected objects MUST be added as closures, to let the container instantiate
* only the objects it requires during the execution.
*
* @package Container
*/
class ContainerBuilder
{
/** @var ConfigManager */
protected $conf;
/** @var SessionManager */
protected $session;
/** @var CookieManager */
protected $cookieManager;
/** @var LoginManager */
protected $login;
/** @var PluginManager */
protected $pluginManager;
/** @var LoggerInterface */
protected $logger;
/** @var string|null */
protected $basePath = null;
public function __construct(
ConfigManager $conf,
SessionManager $session,
CookieManager $cookieManager,
LoginManager $login,
PluginManager $pluginManager,
LoggerInterface $logger
) {
$this->conf = $conf;
$this->session = $session;
$this->login = $login;
$this->cookieManager = $cookieManager;
$this->pluginManager = $pluginManager;
$this->logger = $logger;
}
public function build(): ShaarliContainer
{
$container = new ShaarliContainer();
$container['conf'] = $this->conf;
$container['sessionManager'] = $this->session;
$container['cookieManager'] = $this->cookieManager;
$container['loginManager'] = $this->login;
$container['pluginManager'] = $this->pluginManager;
$container['logger'] = $this->logger;
$container['basePath'] = $this->basePath;
$container['history'] = function (ShaarliContainer $container): History {
return new History($container->conf->get('resource.history'));
};
$container['bookmarkService'] = function (ShaarliContainer $container): BookmarkServiceInterface {
return new BookmarkFileService(
$container->conf,
$container->pluginManager,
$container->history,
new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2),
$container->loginManager->isLoggedIn()
);
};
$container['metadataRetriever'] = function (ShaarliContainer $container): MetadataRetriever {
return new MetadataRetriever($container->conf, $container->httpAccess);
};
$container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder {
return new PageBuilder(
$container->conf,
$container->sessionManager->getSession(),
$container->logger,
$container->bookmarkService,
$container->sessionManager->generateToken(),
$container->loginManager->isLoggedIn()
);
};
$container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory {
return new FormatterFactory(
$container->conf,
$container->loginManager->isLoggedIn()
);
};
$container['pageCacheManager'] = function (ShaarliContainer $container): PageCacheManager {
return new PageCacheManager(
$container->conf->get('resource.page_cache'),
$container->loginManager->isLoggedIn()
);
};
$container['feedBuilder'] = function (ShaarliContainer $container): FeedBuilder {
return new FeedBuilder(
$container->bookmarkService,
$container->formatterFactory->getFormatter(),
$container->environment,
$container->loginManager->isLoggedIn()
);
};
$container['thumbnailer'] = function (ShaarliContainer $container): Thumbnailer {
return new Thumbnailer($container->conf);
};
$container['httpAccess'] = function (): HttpAccess {
return new HttpAccess();
};
$container['netscapeBookmarkUtils'] = function (ShaarliContainer $container): NetscapeBookmarkUtils {
return new NetscapeBookmarkUtils($container->bookmarkService, $container->conf, $container->history);
};
$container['updater'] = function (ShaarliContainer $container): Updater {
return new Updater(
UpdaterUtils::readUpdatesFile($container->conf->get('resource.updates')),
$container->bookmarkService,
$container->conf,
$container->loginManager->isLoggedIn()
);
};
$container['notFoundHandler'] = function (ShaarliContainer $container): ErrorNotFoundController {
return new ErrorNotFoundController($container);
};
$container['errorHandler'] = function (ShaarliContainer $container): ErrorController {
return new ErrorController($container);
};
$container['phpErrorHandler'] = function (ShaarliContainer $container): ErrorController {
return new ErrorController($container);
};
return $container;
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Shaarli\Container;
use Psr\Log\LoggerInterface;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
use Shaarli\Feed\FeedBuilder;
use Shaarli\Formatter\FormatterFactory;
use Shaarli\History;
use Shaarli\Http\HttpAccess;
use Shaarli\Http\MetadataRetriever;
use Shaarli\Netscape\NetscapeBookmarkUtils;
use Shaarli\Plugin\PluginManager;
use Shaarli\Render\PageBuilder;
use Shaarli\Render\PageCacheManager;
use Shaarli\Security\CookieManager;
use Shaarli\Security\LoginManager;
use Shaarli\Security\SessionManager;
use Shaarli\Thumbnailer;
use Shaarli\Updater\Updater;
use Slim\Container;
/**
* Extension of Slim container to document the injected objects.
*
* @property string $basePath Shaarli's instance base path (e.g. `/shaarli/`)
* @property BookmarkServiceInterface $bookmarkService
* @property CookieManager $cookieManager
* @property ConfigManager $conf
* @property mixed[] $environment $_SERVER automatically injected by Slim
* @property callable $errorHandler Overrides default Slim exception display
* @property FeedBuilder $feedBuilder
* @property FormatterFactory $formatterFactory
* @property History $history
* @property HttpAccess $httpAccess
* @property LoginManager $loginManager
* @property LoggerInterface $logger
* @property MetadataRetriever $metadataRetriever
* @property NetscapeBookmarkUtils $netscapeBookmarkUtils
* @property callable $notFoundHandler Overrides default Slim exception display
* @property PageBuilder $pageBuilder
* @property PageCacheManager $pageCacheManager
* @property callable $phpErrorHandler Overrides default Slim PHP error display
* @property PluginManager $pluginManager
* @property SessionManager $sessionManager
* @property Thumbnailer $thumbnailer
* @property Updater $updater
*/
class ShaarliContainer extends Container
{
}

View File

@ -1,5 +1,9 @@
<?php
namespace Shaarli\Exceptions;
use Exception;
/**
* Exception class thrown when a filesystem access failure happens
*/
@ -17,6 +21,6 @@ class IOException extends Exception
{
$this->path = $path;
$this->message = empty($message) ? t('Error accessing') : $message;
$this->message .= ' "' . $this->path .'"';
$this->message .= ' "' . $this->path . '"';
}
}

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Shaarli\Feed;
use DatePeriod;
/**
* Simple cache system, mainly for the RSS/ATOM feeds
*/
class CachedPage
{
/** Directory containing page caches */
protected $cacheDir;
/** Should this URL be cached (boolean)? */
protected $shouldBeCached;
/** Name of the cache file for this URL */
protected $filename;
/** @var DatePeriod|null Optionally specify a period of time for cache validity */
protected $validityPeriod;
/**
* Creates a new CachedPage
*
* @param string $cacheDir page cache directory
* @param string $url page URL
* @param bool $shouldBeCached whether this page needs to be cached
* @param ?DatePeriod $validityPeriod Optionally specify a time limit on requested cache
*/
public function __construct($cacheDir, $url, $shouldBeCached, ?DatePeriod $validityPeriod)
{
// TODO: check write access to the cache directory
$this->cacheDir = $cacheDir;
$this->filename = $this->cacheDir . '/' . sha1($url) . '.cache';
$this->shouldBeCached = $shouldBeCached;
$this->validityPeriod = $validityPeriod;
}
/**
* Returns the cached version of a page, if it exists and should be cached
*
* @return string a cached version of the page if it exists, null otherwise
*/
public function cachedVersion()
{
if (!$this->shouldBeCached) {
return null;
}
if (!is_file($this->filename)) {
return null;
}
if ($this->validityPeriod !== null) {
$cacheDate = \DateTime::createFromFormat('U', (string) filemtime($this->filename));
if (
$cacheDate < $this->validityPeriod->getStartDate()
|| $cacheDate > $this->validityPeriod->getEndDate()
) {
return null;
}
}
return file_get_contents($this->filename);
}
/**
* Puts a page in the cache
*
* @param string $pageContent XML content to cache
*/
public function cache($pageContent)
{
if (!$this->shouldBeCached) {
return;
}
file_put_contents($this->filename, $pageContent);
}
}

View File

@ -0,0 +1,286 @@
<?php
namespace Shaarli\Feed;
use DateTime;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Formatter\BookmarkFormatter;
/**
* FeedBuilder class.
*
* Used to build ATOM and RSS feeds data.
*/
class FeedBuilder
{
/**
* @var string Constant: RSS feed type.
*/
public static $FEED_RSS = 'rss';
/**
* @var string Constant: ATOM feed type.
*/
public static $FEED_ATOM = 'atom';
/**
* @var string Default language if the locale isn't set.
*/
public static $DEFAULT_LANGUAGE = 'en-en';
/**
* @var int Number of bookmarks to display in a feed by default.
*/
public static $DEFAULT_NB_LINKS = 50;
/**
* @var BookmarkServiceInterface instance.
*/
protected $linkDB;
/**
* @var BookmarkFormatter instance.
*/
protected $formatter;
/** @var mixed[] $_SERVER */
protected $serverInfo;
/**
* @var boolean True if the user is currently logged in, false otherwise.
*/
protected $isLoggedIn;
/**
* @var boolean Use permalinks instead of direct bookmarks if true.
*/
protected $usePermalinks;
/**
* @var boolean true to hide dates in feeds.
*/
protected $hideDates;
/**
* @var string server locale.
*/
protected $locale;
/**
* @var DateTime Latest item date.
*/
protected $latestDate;
/**
* Feed constructor.
*
* @param BookmarkServiceInterface $linkDB LinkDB instance.
* @param BookmarkFormatter $formatter instance.
* @param array $serverInfo $_SERVER.
* @param boolean $isLoggedIn True if the user is currently logged in, false otherwise.
*/
public function __construct($linkDB, $formatter, $serverInfo, $isLoggedIn)
{
$this->linkDB = $linkDB;
$this->formatter = $formatter;
$this->serverInfo = $serverInfo;
$this->isLoggedIn = $isLoggedIn;
}
/**
* Build data for feed templates.
*
* @param string $feedType Type of feed (RSS/ATOM).
* @param array $userInput $_GET.
*
* @return array Formatted data for feeds templates.
*/
public function buildData(string $feedType, ?array $userInput)
{
// Search for untagged bookmarks
if (isset($this->userInput['searchtags']) && empty($userInput['searchtags'])) {
$userInput['searchtags'] = false;
}
$limit = $this->getLimit($userInput);
// Optionally filter the results:
$searchResult = $this->linkDB->search($userInput ?? [], null, false, false, true, ['limit' => $limit]);
$pageaddr = escape(index_url($this->serverInfo));
$this->formatter->addContextData('index_url', $pageaddr);
$links = [];
foreach ($searchResult->getBookmarks() as $key => $bookmark) {
$links[$key] = $this->buildItem($feedType, $bookmark, $pageaddr);
}
$data['language'] = $this->getTypeLanguage($feedType);
$data['last_update'] = $this->getLatestDateFormatted($feedType);
$data['show_dates'] = !$this->hideDates || $this->isLoggedIn;
// Remove leading path from REQUEST_URI (already contained in $pageaddr).
$requestUri = preg_replace('#(.*?/)(feed.*)#', '$2', escape($this->serverInfo['REQUEST_URI']));
$data['self_link'] = $pageaddr . $requestUri;
$data['index_url'] = $pageaddr;
$data['usepermalinks'] = $this->usePermalinks === true;
$data['links'] = $links;
return $data;
}
/**
* Set this to true to use permalinks instead of direct bookmarks.
*
* @param boolean $usePermalinks true to force permalinks.
*/
public function setUsePermalinks($usePermalinks)
{
$this->usePermalinks = $usePermalinks;
}
/**
* Set this to true to hide timestamps in feeds.
*
* @param boolean $hideDates true to enable.
*/
public function setHideDates($hideDates)
{
$this->hideDates = $hideDates;
}
/**
* Set the locale. Used to show feed language.
*
* @param string $locale The locale (eg. 'fr_FR.UTF8').
*/
public function setLocale($locale)
{
$this->locale = strtolower($locale);
}
/**
* Build a feed item (one per shaare).
*
* @param string $feedType Type of feed (RSS/ATOM).
* @param Bookmark $link Single link array extracted from LinkDB.
* @param string $pageaddr Index URL.
*
* @return array Link array with feed attributes.
*/
protected function buildItem(string $feedType, $link, $pageaddr)
{
$data = $this->formatter->format($link);
$data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl'];
if ($this->usePermalinks === true) {
$permalink = '<a href="' . $data['url'] . '" title="' . t('Direct link') . '">' . t('Direct link') . '</a>';
} else {
$permalink = '<a href="' . $data['guid'] . '" title="' . t('Permalink') . '">' . t('Permalink') . '</a>';
}
$data['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink;
$data['pub_iso_date'] = $this->getIsoDate($feedType, $data['created']);
// atom:entry elements MUST contain exactly one atom:updated element.
if (!empty($link->getUpdated())) {
$data['up_iso_date'] = $this->getIsoDate($feedType, $data['updated'], DateTime::ATOM);
} else {
$data['up_iso_date'] = $this->getIsoDate($feedType, $data['created'], DateTime::ATOM);
}
// Save the more recent item.
if (empty($this->latestDate) || $this->latestDate < $data['created']) {
$this->latestDate = $data['created'];
}
if (!empty($data['updated']) && $this->latestDate < $data['updated']) {
$this->latestDate = $data['updated'];
}
return $data;
}
/**
* Get the language according to the feed type, based on the locale:
*
* - RSS format: en-us (default: 'en-en').
* - ATOM format: fr (default: 'en').
*
* @param string $feedType Type of feed (RSS/ATOM).
*
* @return string The language.
*/
protected function getTypeLanguage(string $feedType)
{
// Use the locale do define the language, if available.
if (!empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) {
$length = ($feedType === self::$FEED_RSS) ? 5 : 2;
return str_replace('_', '-', substr($this->locale, 0, $length));
}
return ($feedType === self::$FEED_RSS) ? 'en-en' : 'en';
}
/**
* Format the latest item date found according to the feed type.
*
* Return an empty string if invalid DateTime is passed.
*
* @param string $feedType Type of feed (RSS/ATOM).
*
* @return string Formatted date.
*/
protected function getLatestDateFormatted(string $feedType)
{
if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) {
return '';
}
$type = ($feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM;
return $this->latestDate->format($type);
}
/**
* Get ISO date from DateTime according to feed type.
*
* @param string $feedType Type of feed (RSS/ATOM).
* @param DateTime $date Date to format.
* @param string|bool $format Force format.
*
* @return string Formatted date.
*/
protected function getIsoDate(string $feedType, DateTime $date, $format = false)
{
if ($format !== false) {
return $date->format($format);
}
if ($feedType == self::$FEED_RSS) {
return $date->format(DateTime::RSS);
}
return $date->format(DateTime::ATOM);
}
/**
* Returns the number of link to display according to 'nb' user input parameter.
*
* If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS.
* If 'nb' is set to 'all', display all filtered bookmarks (max parameter).
*
* @param array $userInput $_GET.
*
* @return int number of bookmarks to display.
*/
protected function getLimit(?array $userInput)
{
if (empty($userInput['nb'])) {
return self::$DEFAULT_NB_LINKS;
}
if ($userInput['nb'] == 'all') {
return null;
}
$intNb = intval($userInput['nb']);
if (!is_int($intNb) || $intNb == 0) {
return self::$DEFAULT_NB_LINKS;
}
return $intNb;
}
}

View File

@ -0,0 +1,229 @@
<?php
namespace Shaarli\Formatter;
use Shaarli\Bookmark\Bookmark;
/**
* Class BookmarkDefaultFormatter
*
* Default bookmark formatter.
* Escape values for HTML display and automatically add link to URL and hashtags.
*
* @package Shaarli\Formatter
*/
class BookmarkDefaultFormatter extends BookmarkFormatter
{
public const SEARCH_HIGHLIGHT_OPEN = 'SHAARLI_O_HIGHLIGHT';
public const SEARCH_HIGHLIGHT_CLOSE = 'SHAARLI_C_HIGHLIGHT';
/**
* @inheritdoc
*/
protected function formatTitle($bookmark)
{
return escape($bookmark->getTitle());
}
/**
* @inheritdoc
*/
protected function formatTitleHtml($bookmark)
{
$title = $this->tokenizeSearchHighlightField(
$bookmark->getTitle() ?? '',
$bookmark->getAdditionalContentEntry('search_highlight')['title'] ?? []
);
return $this->replaceTokens(escape($title));
}
/**
* @inheritdoc
*/
protected function formatDescription($bookmark)
{
$indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
$description = $this->tokenizeSearchHighlightField(
$bookmark->getDescription() ?? '',
$bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
);
$description = format_description(
escape($description),
$indexUrl,
$this->conf->get('formatter_settings.autolink', true)
);
return $this->replaceTokens($description);
}
/**
* @inheritdoc
*/
protected function formatTagList($bookmark)
{
return escape(parent::formatTagList($bookmark));
}
/**
* @inheritdoc
*/
protected function formatTagListHtml($bookmark)
{
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) {
return $this->formatTagList($bookmark);
}
$tags = $this->tokenizeSearchHighlightField(
$bookmark->getTagsString($tagsSeparator),
$bookmark->getAdditionalContentEntry('search_highlight')['tags']
);
$tags = $this->filterTagList(tags_str2array($tags, $tagsSeparator));
$tags = escape($tags);
$tags = $this->replaceTokensArray($tags);
return $tags;
}
/**
* @inheritdoc
*/
protected function formatTagString($bookmark)
{
return implode($this->conf->get('general.tags_separator'), $this->formatTagList($bookmark));
}
/**
* @inheritdoc
*/
protected function formatUrl($bookmark)
{
if ($bookmark->isNote() && isset($this->contextData['index_url'])) {
return rtrim($this->contextData['index_url'], '/') . '/' . escape(ltrim($bookmark->getUrl(), '/'));
}
return escape($bookmark->getUrl());
}
/**
* @inheritdoc
*/
protected function formatRealUrl($bookmark)
{
if ($bookmark->isNote()) {
if (isset($this->contextData['index_url'])) {
$prefix = rtrim($this->contextData['index_url'], '/') . '/';
}
if (isset($this->contextData['base_path'])) {
$prefix = rtrim($this->contextData['base_path'], '/') . '/';
}
return escape($prefix ?? '') . escape(ltrim($bookmark->getUrl() ?? '', '/'));
}
return escape($bookmark->getUrl());
}
/**
* @inheritdoc
*/
protected function formatUrlHtml($bookmark)
{
$url = $this->tokenizeSearchHighlightField(
$bookmark->getUrl() ?? '',
$bookmark->getAdditionalContentEntry('search_highlight')['url'] ?? []
);
return $this->replaceTokens(escape($url));
}
/**
* @inheritdoc
*/
protected function formatThumbnail($bookmark)
{
return escape($bookmark->getThumbnail());
}
/**
* @inheritDoc
*/
protected function formatAdditionalContent(Bookmark $bookmark): array
{
$additionalContent = parent::formatAdditionalContent($bookmark);
unset($additionalContent['search_highlight']);
return $additionalContent;
}
/**
* Insert search highlight token in provided field content based on a list of search result positions
*
* @param string $fieldContent
* @param array|null $positions List of of search results with 'start' and 'end' positions.
*
* @return string Updated $fieldContent.
*/
protected function tokenizeSearchHighlightField(string $fieldContent, ?array $positions): string
{
if (empty($positions)) {
return $fieldContent;
}
$insertedTokens = 0;
$tokenLength = strlen(static::SEARCH_HIGHLIGHT_OPEN);
foreach ($positions as $position) {
$position = [
'start' => $position['start'] + ($insertedTokens * $tokenLength),
'end' => $position['end'] + ($insertedTokens * $tokenLength),
];
$content = mb_substr($fieldContent, 0, $position['start']);
$content .= static::SEARCH_HIGHLIGHT_OPEN;
$content .= mb_substr($fieldContent, $position['start'], $position['end'] - $position['start']);
$content .= static::SEARCH_HIGHLIGHT_CLOSE;
$content .= mb_substr($fieldContent, $position['end']);
$fieldContent = $content;
$insertedTokens += 2;
}
return $fieldContent;
}
/**
* Replace search highlight tokens with HTML highlighted span.
*
* @param string $fieldContent
*
* @return string updated content.
*/
protected function replaceTokens(string $fieldContent): string
{
return str_replace(
[static::SEARCH_HIGHLIGHT_OPEN, static::SEARCH_HIGHLIGHT_CLOSE],
['<span class="search-highlight">', '</span>'],
$fieldContent
);
}
/**
* Apply replaceTokens to an array of content strings.
*
* @param string[] $fieldContents
*
* @return array
*/
protected function replaceTokensArray(array $fieldContents): array
{
foreach ($fieldContents as &$entry) {
$entry = $this->replaceTokens($entry);
}
return $fieldContents;
}
}

View File

@ -0,0 +1,390 @@
<?php
namespace Shaarli\Formatter;
use DateTimeInterface;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Config\ConfigManager;
/**
* Class BookmarkFormatter
*
* Abstract class processing all bookmark attributes through methods designed to be overridden.
*
* List of available formatted fields:
* - id ID
* - shorturl Unique identifier, used in permalinks
* - url URL, can be altered in some way, e.g. passing through an HTTP reverse proxy
* - real_url (legacy) same as `url`
* - url_html URL to be displayed in HTML content (it can contain HTML tags)
* - title Title
* - title_html Title to be displayed in HTML content (it can contain HTML tags)
* - description Description content. It most likely contains HTML tags
* - thumbnail Thumbnail: path to local cache file, false if there is none, null if hasn't been retrieved
* - taglist List of tags (array)
* - taglist_urlencoded List of tags (array) URL encoded: it must be used to create a link to a URL containing a tag
* - taglist_html List of tags (array) to be displayed in HTML content (it can contain HTML tags)
* - tags Tags separated by a single whitespace
* - tags_urlencoded Tags separated by a single whitespace, URL encoded: must be used to create a link
* - sticky Is sticky (bool)
* - private Is private (bool)
* - class Additional CSS class
* - created Creation DateTime
* - updated Last edit DateTime
* - timestamp Creation timestamp
* - updated_timestamp Last edit timestamp
*
* @package Shaarli\Formatter
*/
abstract class BookmarkFormatter
{
/**
* @var ConfigManager
*/
protected $conf;
/** @var bool */
protected $isLoggedIn;
/**
* @var array Additional parameters than can be used for specific formatting
* e.g. index_url for Feed formatting
*/
protected $contextData = [];
/**
* LinkDefaultFormatter constructor.
* @param ConfigManager $conf
*/
public function __construct(ConfigManager $conf, bool $isLoggedIn)
{
$this->conf = $conf;
$this->isLoggedIn = $isLoggedIn;
}
/**
* Convert a Bookmark into an array usable by templates and plugins.
*
* All Bookmark attributes are formatted through a format method
* that can be overridden in a formatter extending this class.
*
* @param Bookmark $bookmark instance
*
* @return array formatted representation of a Bookmark
*/
public function format($bookmark)
{
$out['id'] = $this->formatId($bookmark);
$out['shorturl'] = $this->formatShortUrl($bookmark);
$out['url'] = $this->formatUrl($bookmark);
$out['real_url'] = $this->formatRealUrl($bookmark);
$out['url_html'] = $this->formatUrlHtml($bookmark);
$out['title'] = $this->formatTitle($bookmark);
$out['title_html'] = $this->formatTitleHtml($bookmark);
$out['description'] = $this->formatDescription($bookmark);
$out['thumbnail'] = $this->formatThumbnail($bookmark);
$out['taglist'] = $this->formatTagList($bookmark);
$out['taglist_urlencoded'] = $this->formatTagListUrlEncoded($bookmark);
$out['taglist_html'] = $this->formatTagListHtml($bookmark);
$out['tags'] = $this->formatTagString($bookmark);
$out['tags_urlencoded'] = $this->formatTagStringUrlEncoded($bookmark);
$out['sticky'] = $bookmark->isSticky();
$out['private'] = $bookmark->isPrivate();
$out['class'] = $this->formatClass($bookmark);
$out['created'] = $this->formatCreated($bookmark);
$out['updated'] = $this->formatUpdated($bookmark);
$out['timestamp'] = $this->formatCreatedTimestamp($bookmark);
$out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark);
$out['additional_content'] = $this->formatAdditionalContent($bookmark);
return $out;
}
/**
* Add additional data available to formatters.
* This is used for example to add `index_url` in description's links.
*
* @param string $key Context data key
* @param string $value Context data value
*/
public function addContextData($key, $value)
{
$this->contextData[$key] = $value;
return $this;
}
/**
* Format ID
*
* @param Bookmark $bookmark instance
*
* @return int formatted ID
*/
protected function formatId($bookmark)
{
return $bookmark->getId();
}
/**
* Format ShortUrl
*
* @param Bookmark $bookmark instance
*
* @return string formatted ShortUrl
*/
protected function formatShortUrl($bookmark)
{
return $bookmark->getShortUrl();
}
/**
* Format Url
*
* @param Bookmark $bookmark instance
*
* @return string formatted Url
*/
protected function formatUrl($bookmark)
{
return $bookmark->getUrl();
}
/**
* Format RealUrl
* Legacy: identical to Url
*
* @param Bookmark $bookmark instance
*
* @return string formatted RealUrl
*/
protected function formatRealUrl($bookmark)
{
return $this->formatUrl($bookmark);
}
/**
* Format Url Html: to be displayed in HTML content, it can contains HTML tags.
*
* @param Bookmark $bookmark instance
*
* @return string formatted Url HTML
*/
protected function formatUrlHtml($bookmark)
{
return $this->formatUrl($bookmark);
}
/**
* Format Title
*
* @param Bookmark $bookmark instance
*
* @return string formatted Title
*/
protected function formatTitle($bookmark)
{
return $bookmark->getTitle();
}
/**
* Format Title HTML: to be displayed in HTML content, it can contains HTML tags.
*
* @param Bookmark $bookmark instance
*
* @return string formatted Title
*/
protected function formatTitleHtml($bookmark)
{
return $bookmark->getTitle();
}
/**
* Format Description
*
* @param Bookmark $bookmark instance
*
* @return string formatted Description
*/
protected function formatDescription($bookmark)
{
return $bookmark->getDescription();
}
/**
* Format Thumbnail
*
* @param Bookmark $bookmark instance
*
* @return string formatted Thumbnail
*/
protected function formatThumbnail($bookmark)
{
return $bookmark->getThumbnail();
}
/**
* Format Tags
*
* @param Bookmark $bookmark instance
*
* @return array formatted Tags
*/
protected function formatTagList($bookmark)
{
return $this->filterTagList($bookmark->getTags());
}
/**
* Format Url Encoded Tags
*
* @param Bookmark $bookmark instance
*
* @return array formatted Tags
*/
protected function formatTagListUrlEncoded($bookmark)
{
return array_map('urlencode', $this->filterTagList($bookmark->getTags()));
}
/**
* Format Tags HTML: to be displayed in HTML content, it can contains HTML tags.
*
* @param Bookmark $bookmark instance
*
* @return array formatted Tags
*/
protected function formatTagListHtml($bookmark)
{
return $this->formatTagList($bookmark);
}
/**
* Format TagString
*
* @param Bookmark $bookmark instance
*
* @return string formatted TagString
*/
protected function formatTagString($bookmark)
{
return implode($this->conf->get('general.tags_separator', ' '), $this->formatTagList($bookmark));
}
/**
* Format TagString
*
* @param Bookmark $bookmark instance
*
* @return string formatted TagString
*/
protected function formatTagStringUrlEncoded($bookmark)
{
return implode(' ', $this->formatTagListUrlEncoded($bookmark));
}
/**
* Format Class
* Used to add specific CSS class for a link
*
* @param Bookmark $bookmark instance
*
* @return string formatted Class
*/
protected function formatClass($bookmark)
{
return $bookmark->isPrivate() ? 'private' : '';
}
/**
* Format Created
*
* @param Bookmark $bookmark instance
*
* @return DateTimeInterface instance
*/
protected function formatCreated(Bookmark $bookmark)
{
return $bookmark->getCreated();
}
/**
* Format Updated
*
* @param Bookmark $bookmark instance
*
* @return DateTimeInterface instance
*/
protected function formatUpdated(Bookmark $bookmark)
{
return $bookmark->getUpdated();
}
/**
* Format CreatedTimestamp
*
* @param Bookmark $bookmark instance
*
* @return int formatted CreatedTimestamp
*/
protected function formatCreatedTimestamp(Bookmark $bookmark)
{
if (! empty($bookmark->getCreated())) {
return $bookmark->getCreated()->getTimestamp();
}
return 0;
}
/**
* Format UpdatedTimestamp
*
* @param Bookmark $bookmark instance
*
* @return int formatted UpdatedTimestamp
*/
protected function formatUpdatedTimestamp(Bookmark $bookmark)
{
if (! empty($bookmark->getUpdated())) {
return $bookmark->getUpdated()->getTimestamp();
}
return 0;
}
/**
* Format bookmark's additional content
*
* @param Bookmark $bookmark instance
*
* @return mixed[]
*/
protected function formatAdditionalContent(Bookmark $bookmark): array
{
return $bookmark->getAdditionalContent();
}
/**
* Format tag list, e.g. remove private tags if the user is not logged in.
* TODO: this method is called multiple time to format tags, the result should be cached.
*
* @param array $tags
*
* @return array
*/
protected function filterTagList(array $tags): array
{
if ($this->isLoggedIn === true) {
return $tags;
}
$out = [];
foreach ($tags as $tag) {
if (strpos($tag, '.') === 0) {
continue;
}
$out[] = $tag;
}
return $out;
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace Shaarli\Formatter;
use Shaarli\Config\ConfigManager;
use Shaarli\Formatter\Parsedown\ShaarliParsedownExtra;
/**
* Class BookmarkMarkdownExtraFormatter
*
* Format bookmark description into MarkdownExtra format.
*
* @see https://michelf.ca/projects/php-markdown/extra/
*
* @package Shaarli\Formatter
*/
class BookmarkMarkdownExtraFormatter extends BookmarkMarkdownFormatter
{
public function __construct(ConfigManager $conf, bool $isLoggedIn)
{
parent::__construct($conf, $isLoggedIn);
$this->parsedown = new ShaarliParsedownExtra();
}
}

View File

@ -0,0 +1,221 @@
<?php
namespace Shaarli\Formatter;
use Shaarli\Config\ConfigManager;
use Shaarli\Formatter\Parsedown\ShaarliParsedown;
/**
* Class BookmarkMarkdownFormatter
*
* Format bookmark description into Markdown format.
*
* @package Shaarli\Formatter
*/
class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
{
/**
* When this tag is present in a bookmark, its description should not be processed with Markdown
*/
public const NO_MD_TAG = 'nomarkdown';
/** @var \Parsedown instance */
protected $parsedown;
/** @var bool used to escape HTML in Markdown or not.
* It MUST be set to true for shared instance as HTML content can
* introduce XSS vulnerabilities.
*/
protected $escape;
/**
* @var array List of allowed protocols for links inside bookmark's description.
*/
protected $allowedProtocols;
/**
* LinkMarkdownFormatter constructor.
*
* @param ConfigManager $conf instance
* @param bool $isLoggedIn
*/
public function __construct(ConfigManager $conf, bool $isLoggedIn)
{
parent::__construct($conf, $isLoggedIn);
$this->parsedown = new ShaarliParsedown();
$this->escape = $conf->get('security.markdown_escape', true);
$this->allowedProtocols = $conf->get('security.allowed_protocols', []);
}
/**
* @inheritdoc
*/
public function formatDescription($bookmark)
{
if (in_array(self::NO_MD_TAG, $bookmark->getTags())) {
return parent::formatDescription($bookmark);
}
$processedDescription = $this->tokenizeSearchHighlightField(
$bookmark->getDescription() ?? '',
$bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
);
$processedDescription = $this->filterProtocols($processedDescription);
$processedDescription = $this->formatHashTags($processedDescription);
$processedDescription = $this->reverseEscapedHtml($processedDescription);
$processedDescription = $this->parsedown
->setMarkupEscaped($this->escape)
->setBreaksEnabled(true)
->text($processedDescription);
$processedDescription = $this->sanitizeHtml($processedDescription);
$processedDescription = $this->replaceTokens($processedDescription);
if (!empty($processedDescription)) {
$processedDescription = '<div class="markdown">' . $processedDescription . '</div>';
}
return $processedDescription;
}
/**
* Remove the NO markdown tag if it is present
*
* @inheritdoc
*/
protected function formatTagList($bookmark)
{
$out = parent::formatTagList($bookmark);
if ($this->isLoggedIn === false && ($pos = array_search(self::NO_MD_TAG, $out)) !== false) {
unset($out[$pos]);
return array_values($out);
}
return $out;
}
/**
* Replace not whitelisted protocols with http:// in given description.
* Also adds `index_url` to relative links if it's specified
*
* @param string $description input description text.
*
* @return string $description without malicious link.
*/
protected function filterProtocols($description)
{
$allowedProtocols = $this->allowedProtocols;
$indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
return preg_replace_callback(
'#]\((.*?)\)#is',
function ($match) use ($allowedProtocols, $indexUrl) {
$link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : '';
$link .= whitelist_protocols($match[1], $allowedProtocols);
return '](' . $link . ')';
},
$description
);
}
/**
* Replace hashtag in Markdown links format
* E.g. `#hashtag` becomes `[#hashtag](./add-tag/hashtag)`
* It includes the index URL if specified.
*
* @param string $description
*
* @return string
*/
protected function formatHashTags($description)
{
$indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
$tokens = '(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN . ')' .
'(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE . ')'
;
/*
* To support unicode: http://stackoverflow.com/a/35498078/1484919
* \p{Pc} - to match underscore
* \p{N} - numeric character in any script
* \p{L} - letter from any language
* \p{Mn} - any non marking space (accents, umlauts, etc)
*/
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}' . $tokens . ']+)/mui';
$replacement = function (array $match) use ($indexUrl): string {
$cleanMatch = str_replace(
BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN,
'',
str_replace(BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE, '', $match[2])
);
return $match[1] . '[#' . $match[2] . '](' . $indexUrl . './add-tag/' . $cleanMatch . ')';
};
$descriptionLines = explode(PHP_EOL, $description);
$descriptionOut = '';
$codeBlockOn = false;
$lineCount = 0;
foreach ($descriptionLines as $descriptionLine) {
// Detect line of code: starting with 4 spaces,
// except lists which can start with +/*/- or `2.` after spaces.
$codeLineOn = preg_match('/^ +(?=[^\+\*\-])(?=(?!\d\.).)/', $descriptionLine) > 0;
// Detect and toggle block of code
if (!$codeBlockOn) {
$codeBlockOn = preg_match('/^```/', $descriptionLine) > 0;
} elseif (preg_match('/^```/', $descriptionLine) > 0) {
$codeBlockOn = false;
}
if (!$codeBlockOn && !$codeLineOn) {
$descriptionLine = preg_replace_callback($regex, $replacement, $descriptionLine);
}
$descriptionOut .= $descriptionLine;
if ($lineCount++ < count($descriptionLines) - 1) {
$descriptionOut .= PHP_EOL;
}
}
return $descriptionOut;
}
/**
* Remove dangerous HTML tags (tags, iframe, etc.).
* Doesn't affect <code> content (already escaped by Parsedown).
*
* @param string $description input description text.
*
* @return string given string escaped.
*/
protected function sanitizeHtml($description)
{
$escapeTags = [
'script',
'style',
'link',
'iframe',
'frameset',
'frame',
];
foreach ($escapeTags as $tag) {
$description = preg_replace_callback(
'#<\s*' . $tag . '[^>]*>(.*</\s*' . $tag . '[^>]*>)?#is',
function ($match) {
return escape($match[0]);
},
$description
);
}
$description = preg_replace(
'#(<[^>]+\s)on[a-z]*="?[^ "]*"?#is',
'$1',
$description
);
return $description;
}
protected function reverseEscapedHtml($description)
{
return unescape($description);
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace Shaarli\Formatter;
/**
* Class BookmarkRawFormatter
*
* Used to retrieve bookmarks as array with raw values.
* Warning: Do NOT use this for HTML content as it can introduce XSS vulnerabilities.
*
* @package Shaarli\Formatter
*/
class BookmarkRawFormatter extends BookmarkFormatter
{
}

View File

@ -0,0 +1,51 @@
<?php
namespace Shaarli\Formatter;
use Shaarli\Config\ConfigManager;
/**
* Class FormatterFactory
*
* Helper class used to instantiate the proper BookmarkFormatter.
*
* @package Shaarli\Formatter
*/
class FormatterFactory
{
/** @var ConfigManager instance */
protected $conf;
/** @var bool */
protected $isLoggedIn;
/**
* FormatterFactory constructor.
*
* @param ConfigManager $conf
* @param bool $isLoggedIn
*/
public function __construct(ConfigManager $conf, bool $isLoggedIn)
{
$this->conf = $conf;
$this->isLoggedIn = $isLoggedIn;
}
/**
* Instanciate a BookmarkFormatter depending on the configuration or provided formatter type.
*
* @param string|null $type force a specific type regardless of the configuration
*
* @return BookmarkFormatter instance.
*/
public function getFormatter(string $type = null): BookmarkFormatter
{
$type = $type ? $type : $this->conf->get('formatter', 'default');
$className = '\\Shaarli\\Formatter\\Bookmark' . ucfirst($type) . 'Formatter';
if (!class_exists($className)) {
$className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter';
}
return new $className($this->conf, $this->isLoggedIn);
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shaarli\Formatter\Parsedown;
/**
* Parsedown extension for Shaarli.
*
* Extension for both Parsedown and ParsedownExtra centralized in ShaarliParsedownTrait.
*/
class ShaarliParsedown extends \Parsedown
{
use ShaarliParsedownTrait;
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shaarli\Formatter\Parsedown;
/**
* ParsedownExtra extension for Shaarli.
*
* Extension for both Parsedown and ParsedownExtra centralized in ShaarliParsedownTrait.
*/
class ShaarliParsedownExtra extends \ParsedownExtra
{
use ShaarliParsedownTrait;
}

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Shaarli\Formatter\Parsedown;
use Shaarli\Formatter\BookmarkDefaultFormatter as Formatter;
/**
* Trait used for Parsedown and ParsedownExtra extension.
*
* Extended:
* - Format links properly in search context
*/
trait ShaarliParsedownTrait
{
/**
* @inheritDoc
*/
protected function inlineLink($excerpt)
{
return $this->shaarliFormatLink(parent::inlineLink($excerpt), true);
}
/**
* @inheritDoc
*/
protected function inlineUrl($excerpt)
{
return $this->shaarliFormatLink(parent::inlineUrl($excerpt), false);
}
/**
* Properly format markdown link:
* - remove highlight tags from HREF attribute
* - (optional) add highlight tags to link caption
*
* @param array|null $link Parsedown formatted link array.
* It can be empty.
* @param bool $fullWrap Add highlight tags the whole link caption
*
* @return array|null
*/
protected function shaarliFormatLink(?array $link, bool $fullWrap): ?array
{
// If open and clean search tokens are found in the link, process.
if (
is_array($link)
&& strpos($link['element']['attributes']['href'] ?? '', Formatter::SEARCH_HIGHLIGHT_OPEN) !== false
&& strpos($link['element']['attributes']['href'] ?? '', Formatter::SEARCH_HIGHLIGHT_CLOSE) !== false
) {
$link['element']['attributes']['href'] = $this->shaarliRemoveSearchTokens(
$link['element']['attributes']['href']
);
if ($fullWrap) {
$link['element']['text'] = Formatter::SEARCH_HIGHLIGHT_OPEN .
$link['element']['text'] .
Formatter::SEARCH_HIGHLIGHT_CLOSE
;
}
}
return $link;
}
/**
* Remove open and close tags from provided string.
*
* @param string $entry input
*
* @return string Striped input
*/
protected function shaarliRemoveSearchTokens(string $entry): string
{
$entry = str_replace(Formatter::SEARCH_HIGHLIGHT_OPEN, '', $entry);
$entry = str_replace(Formatter::SEARCH_HIGHLIGHT_CLOSE, '', $entry);
return $entry;
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Shaarli\Front;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Middleware used for controller requiring to be authenticated.
* It extends ShaarliMiddleware, and just make sure that the user is authenticated.
* Otherwise, it redirects to the login page.
*/
class ShaarliAdminMiddleware extends ShaarliMiddleware
{
public function __invoke(Request $request, Response $response, callable $next): Response
{
$this->initBasePath($request);
if (true !== $this->container->loginManager->isLoggedIn()) {
$returnUrl = urlencode($this->container->environment['REQUEST_URI']);
return $response->withRedirect($this->container->basePath . '/login?returnurl=' . $returnUrl);
}
return parent::__invoke($request, $response, $next);
}
}

View File

@ -0,0 +1,116 @@
<?php
namespace Shaarli\Front;
use Shaarli\Container\ShaarliContainer;
use Shaarli\Front\Exception\UnauthorizedException;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class ShaarliMiddleware
*
* This will be called before accessing any Shaarli controller.
*/
class ShaarliMiddleware
{
/** @var ShaarliContainer contains all Shaarli DI */
protected $container;
public function __construct(ShaarliContainer $container)
{
$this->container = $container;
}
/**
* Middleware execution:
* - run updates
* - if not logged in open shaarli, redirect to login
* - execute the controller
* - return the response
*
* In case of error, the error template will be displayed with the exception message.
*
* @param Request $request Slim request
* @param Response $response Slim response
* @param callable $next Next action
*
* @return Response response.
*/
public function __invoke(Request $request, Response $response, callable $next): Response
{
$this->initBasePath($request);
try {
if (
!is_file($this->container->conf->getConfigFileExt())
&& !in_array($next->getName(), ['displayInstall', 'saveInstall'], true)
) {
return $response->withRedirect($this->container->basePath . '/install');
}
$this->runUpdates();
$this->checkOpenShaarli($request, $response, $next);
return $next($request, $response);
} catch (UnauthorizedException $e) {
$returnUrl = urlencode($this->container->environment['REQUEST_URI']);
return $response->withRedirect($this->container->basePath . '/login?returnurl=' . $returnUrl);
}
// Other exceptions are handled by ErrorController
}
/**
* Run the updater for every requests processed while logged in.
*/
protected function runUpdates(): void
{
if ($this->container->loginManager->isLoggedIn() !== true) {
return;
}
$this->container->updater->setBasePath($this->container->basePath);
$newUpdates = $this->container->updater->update();
if (!empty($newUpdates)) {
$this->container->updater->writeUpdates(
$this->container->conf->get('resource.updates'),
$this->container->updater->getDoneUpdates()
);
$this->container->pageCacheManager->invalidateCaches();
}
}
/**
* Access is denied to most pages with `hide_public_links` + `force_login` settings.
*/
protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool
{
if (
// if the user isn't logged in
!$this->container->loginManager->isLoggedIn()
// and Shaarli doesn't have public content...
&& $this->container->conf->get('privacy.hide_public_links')
// and is configured to enforce the login
&& $this->container->conf->get('privacy.force_login')
// and the current page isn't already the login page
// and the user is not requesting a feed (which would lead to a different content-type as expected)
&& !in_array($next->getName(), ['login', 'processLogin', 'atom', 'rss'], true)
) {
throw new UnauthorizedException();
}
return true;
}
/**
* Initialize the URL base path if it hasn't been defined yet.
*/
protected function initBasePath(Request $request): void
{
if (null === $this->container->basePath) {
$this->container->basePath = rtrim($request->getUri()->getBasePath(), '/');
}
}
}

View File

@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Languages;
use Shaarli\Render\TemplatePage;
use Shaarli\Render\ThemeUtils;
use Shaarli\Thumbnailer;
use Slim\Http\Request;
use Slim\Http\Response;
use Throwable;
/**
* Class ConfigureController
*
* Slim controller used to handle Shaarli configuration page (display + save new config).
*/
class ConfigureController extends ShaarliAdminController
{
/**
* GET /admin/configure - Displays the configuration page
*/
public function index(Request $request, Response $response): Response
{
$this->assignView('title', $this->container->conf->get('general.title', 'Shaarli'));
$this->assignView('theme', $this->container->conf->get('resource.theme'));
$this->assignView(
'theme_available',
ThemeUtils::getThemes($this->container->conf->get('resource.raintpl_tpl'))
);
$this->assignView('formatter_available', ['default', 'markdown', 'markdownExtra']);
list($continents, $cities) = generateTimeZoneData(
timezone_identifiers_list(),
$this->container->conf->get('general.timezone')
);
$this->assignView('continents', $continents);
$this->assignView('cities', $cities);
$this->assignView('retrieve_description', $this->container->conf->get('general.retrieve_description', false));
$this->assignView('private_links_default', $this->container->conf->get('privacy.default_private_links', false));
$this->assignView(
'session_protection_disabled',
$this->container->conf->get('security.session_protection_disabled', false)
);
$this->assignView('enable_rss_permalinks', $this->container->conf->get('feed.rss_permalinks', false));
$this->assignView('enable_update_check', $this->container->conf->get('updates.check_updates', true));
$this->assignView('hide_public_links', $this->container->conf->get('privacy.hide_public_links', false));
$this->assignView('api_enabled', $this->container->conf->get('api.enabled', true));
$this->assignView('api_secret', $this->container->conf->get('api.secret'));
$this->assignView('languages', Languages::getAvailableLanguages());
$this->assignView('gd_enabled', extension_loaded('gd'));
$this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE));
$this->assignView(
'pagetitle',
t('Configure') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
);
return $response->write($this->render(TemplatePage::CONFIGURE));
}
/**
* POST /admin/configure - Update Shaarli's configuration
*/
public function save(Request $request, Response $response): Response
{
$this->checkToken($request);
$continent = $request->getParam('continent');
$city = $request->getParam('city');
$tz = 'UTC';
if (null !== $continent && null !== $city && isTimeZoneValid($continent, $city)) {
$tz = $continent . '/' . $city;
}
$this->container->conf->set('general.timezone', $tz);
$this->container->conf->set('general.title', escape($request->getParam('title')));
$this->container->conf->set('general.header_link', escape($request->getParam('titleLink')));
$this->container->conf->set('general.retrieve_description', !empty($request->getParam('retrieveDescription')));
$this->container->conf->set('resource.theme', escape($request->getParam('theme')));
$this->container->conf->set(
'security.session_protection_disabled',
!empty($request->getParam('disablesessionprotection'))
);
$this->container->conf->set(
'privacy.default_private_links',
!empty($request->getParam('privateLinkByDefault'))
);
$this->container->conf->set('feed.rss_permalinks', !empty($request->getParam('enableRssPermalinks')));
$this->container->conf->set('updates.check_updates', !empty($request->getParam('updateCheck')));
$this->container->conf->set('privacy.hide_public_links', !empty($request->getParam('hidePublicLinks')));
$this->container->conf->set('api.enabled', !empty($request->getParam('enableApi')));
$this->container->conf->set('api.secret', escape($request->getParam('apiSecret')));
$this->container->conf->set('formatter', escape($request->getParam('formatter')));
if (!empty($request->getParam('language'))) {
$this->container->conf->set('translation.language', escape($request->getParam('language')));
}
$thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE;
if (
$thumbnailsMode !== Thumbnailer::MODE_NONE
&& $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)
) {
$this->saveWarningMessage(
t('You have enabled or changed thumbnails mode.') .
'<a href="' . $this->container->basePath . '/admin/thumbnails">' .
t('Please synchronize them.') .
'</a>'
);
}
$this->container->conf->set('thumbnails.mode', $thumbnailsMode);
try {
$this->container->conf->write($this->container->loginManager->isLoggedIn());
$this->container->history->updateSettings();
$this->container->pageCacheManager->invalidateCaches();
} catch (Throwable $e) {
$this->assignView('message', t('Error while writing config file after configuration update.'));
if ($this->container->conf->get('dev.debug', false)) {
$this->assignView('stacktrace', $e->getMessage() . PHP_EOL . $e->getTraceAsString());
}
return $response->write($this->render('error'));
}
$this->saveSuccessMessage(t('Configuration was saved.'));
return $this->redirect($response, '/admin/configure');
}
}

View File

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use DateTime;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class ExportController
*
* Slim controller used to display Shaarli data export page,
* and process the bookmarks export as a Netscape Bookmarks file.
*/
class ExportController extends ShaarliAdminController
{
/**
* GET /admin/export - Display export page
*/
public function index(Request $request, Response $response): Response
{
$this->assignView('pagetitle', t('Export') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
return $response->write($this->render(TemplatePage::EXPORT));
}
/**
* POST /admin/export - Process export, and serve download file named
* bookmarks_(all|private|public)_datetime.html
*/
public function export(Request $request, Response $response): Response
{
$this->checkToken($request);
$selection = $request->getParam('selection');
if (empty($selection)) {
$this->saveErrorMessage(t('Please select an export mode.'));
return $this->redirect($response, '/admin/export');
}
$prependNoteUrl = filter_var($request->getParam('prepend_note_url') ?? false, FILTER_VALIDATE_BOOLEAN);
try {
$formatter = $this->container->formatterFactory->getFormatter('raw');
$this->assignView(
'links',
$this->container->netscapeBookmarkUtils->filterAndFormat(
$formatter,
$selection,
$prependNoteUrl,
index_url($this->container->environment)
)
);
} catch (\Exception $exc) {
$this->saveErrorMessage($exc->getMessage());
return $this->redirect($response, '/admin/export');
}
$now = new DateTime();
$response = $response->withHeader('Content-Type', 'text/html; charset=utf-8');
$response = $response->withHeader(
'Content-disposition',
'attachment; filename=bookmarks_' . $selection . '_' . $now->format(Bookmark::LINK_DATE_FORMAT) . '.html'
);
$this->assignView('date', $now->format(DateTime::RFC822));
$this->assignView('eol', PHP_EOL);
$this->assignView('selection', $selection);
return $response->write($this->render(TemplatePage::NETSCAPE_EXPORT_BOOKMARKS));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Admin;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class ToolsController
*
* Slim controller used to display the tools page.
*/
class ToolsController extends ShaarliAdminController
{
public function index(Request $request, Response $response): Response
{
$data = [
'pageabsaddr' => index_url($this->container->environment),
'sslenabled' => is_https($this->container->environment),
];
$this->executePageHooks('render_tools', $data, TemplatePage::TOOLS);
foreach ($data as $key => $value) {
$this->assignView($key, $value);
}
$this->assignView('pagetitle', t('Tools') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
return $response->write($this->render(TemplatePage::TOOLS));
}
}

View File

@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Visitor;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Legacy\LegacyController;
use Shaarli\Legacy\UnknowLegacyRouteException;
use Shaarli\Render\TemplatePage;
use Shaarli\Thumbnailer;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class BookmarkListController
*
* Slim controller used to render the bookmark list, the home page of Shaarli.
* It also displays permalinks, and process legacy routes based on GET parameters.
*/
class BookmarkListController extends ShaarliVisitorController
{
/**
* GET / - Displays the bookmark list, with optional filter parameters.
*/
public function index(Request $request, Response $response): Response
{
$legacyResponse = $this->processLegacyController($request, $response);
if (null !== $legacyResponse) {
return $legacyResponse;
}
$formatter = $this->container->formatterFactory->getFormatter();
$formatter->addContextData('base_path', $this->container->basePath);
$formatter->addContextData('index_url', index_url($this->container->environment));
$searchTags = normalize_spaces($request->getParam('searchtags') ?? '');
$searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));
// Filter bookmarks according search parameters.
$visibility = $this->container->sessionManager->getSessionParameter('visibility');
$search = [
'searchtags' => $searchTags,
'searchterm' => $searchTerm,
];
// Select articles according to paging.
$page = (int) ($request->getParam('page') ?? 1);
$page = $page < 1 ? 1 : $page;
$linksPerPage = $this->container->sessionManager->getSessionParameter('LINKS_PER_PAGE', 20) ?: 20;
$searchResult = $this->container->bookmarkService->search(
$search,
$visibility,
false,
!!$this->container->sessionManager->getSessionParameter('untaggedonly'),
false,
['offset' => $linksPerPage * ($page - 1), 'limit' => $linksPerPage]
) ?? [];
$save = false;
$links = [];
foreach ($searchResult->getBookmarks() as $key => $bookmark) {
$save = $this->updateThumbnail($bookmark, false) || $save;
$links[$key] = $formatter->format($bookmark);
}
if ($save) {
$this->container->bookmarkService->save();
}
// Compute paging navigation
$searchtagsUrl = $searchTags === '' ? '' : '&searchtags=' . urlencode($searchTags);
$searchtermUrl = $searchTerm === '' ? '' : '&searchterm=' . urlencode($searchTerm);
$page = $searchResult->getPage();
$previousPageUrl = !$searchResult->isLastPage() ? '?page=' . ($page + 1) . $searchtermUrl . $searchtagsUrl : '';
$nextPageUrl = !$searchResult->isFirstPage() ? '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl : '';
$tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
$searchTagsUrlEncoded = array_map('urlencode', tags_str2array($searchTags, $tagsSeparator));
$searchTags = !empty($searchTags) ? trim($searchTags, $tagsSeparator) . $tagsSeparator : '';
// Fill all template fields.
$data = array_merge(
$this->initializeTemplateVars(),
[
'previous_page_url' => $previousPageUrl,
'next_page_url' => $nextPageUrl,
'page_current' => $page,
'page_max' => $searchResult->getLastPage(),
'result_count' => $searchResult->getTotalCount(),
'search_term' => escape($searchTerm),
'search_tags' => escape($searchTags),
'search_tags_url' => $searchTagsUrlEncoded,
'visibility' => $visibility,
'links' => $links,
]
);
if (!empty($searchTerm) || !empty($searchTags)) {
$data['pagetitle'] = t('Search: ');
$data['pagetitle'] .= ! empty($searchTerm) ? $searchTerm . ' ' : '';
$bracketWrap = function ($tag) {
return '[' . $tag . ']';
};
$data['pagetitle'] .= ! empty($searchTags)
? implode(' ', array_map($bracketWrap, tags_str2array($searchTags, $tagsSeparator))) . ' '
: ''
;
$data['pagetitle'] .= '- ';
}
$data['pagetitle'] = ($data['pagetitle'] ?? '') . $this->container->conf->get('general.title', 'Shaarli');
$this->executePageHooks('render_linklist', $data, TemplatePage::LINKLIST);
$this->assignAllView($data);
return $response->write($this->render(TemplatePage::LINKLIST));
}
/**
* GET /shaare/{hash} - Display a single shaare
*/
public function permalink(Request $request, Response $response, array $args): Response
{
$privateKey = $request->getParam('key');
try {
$bookmark = $this->container->bookmarkService->findByHash($args['hash'], $privateKey);
} catch (BookmarkNotFoundException $e) {
$this->assignView('error_message', $e->getMessage());
return $response->write($this->render(TemplatePage::ERROR_404));
}
$this->updateThumbnail($bookmark);
$formatter = $this->container->formatterFactory->getFormatter();
$formatter->addContextData('base_path', $this->container->basePath);
$formatter->addContextData('index_url', index_url($this->container->environment));
$data = array_merge(
$this->initializeTemplateVars(),
[
'pagetitle' => $bookmark->getTitle() . ' - ' . $this->container->conf->get('general.title', 'Shaarli'),
'links' => [$formatter->format($bookmark)],
]
);
$this->executePageHooks('render_linklist', $data, TemplatePage::LINKLIST);
$this->assignAllView($data);
return $response->write($this->render(TemplatePage::LINKLIST));
}
/**
* Update the thumbnail of a single bookmark if necessary.
*/
protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool
{
if (false === $this->container->loginManager->isLoggedIn()) {
return false;
}
// If thumbnail should be updated, we reset it to null
if ($bookmark->shouldUpdateThumbnail()) {
$bookmark->setThumbnail(null);
// Requires an update, not async retrieval, thumbnails enabled
if (
$bookmark->shouldUpdateThumbnail()
&& true !== $this->container->conf->get('general.enable_async_metadata', true)
&& $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
) {
$bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
$this->container->bookmarkService->set($bookmark, $writeDatastore);
return true;
}
}
return false;
}
/**
* @return string[] Default template variables without values.
*/
protected function initializeTemplateVars(): array
{
return [
'previous_page_url' => '',
'next_page_url' => '',
'page_max' => '',
'search_tags' => '',
'result_count' => '',
'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true)
];
}
/**
* Process legacy routes if necessary. They used query parameters.
* If no legacy routes is passed, return null.
*/
protected function processLegacyController(Request $request, Response $response): ?Response
{
// Legacy smallhash filter
$queryString = $this->container->environment['QUERY_STRING'] ?? null;
if (null !== $queryString && 1 === preg_match('/^([a-zA-Z0-9-_@]{6})($|&|#)/', $queryString, $match)) {
return $this->redirect($response, '/shaare/' . $match[1]);
}
// Legacy controllers (mostly used for redirections)
if (null !== $request->getQueryParam('do')) {
$legacyController = new LegacyController($this->container);
try {
return $legacyController->process($request, $response, $request->getQueryParam('do'));
} catch (UnknowLegacyRouteException $e) {
// We ignore legacy 404
return null;
}
}
// Legacy GET admin routes
$legacyGetRoutes = array_intersect(
LegacyController::LEGACY_GET_ROUTES,
array_keys($request->getQueryParams() ?? [])
);
if (1 === count($legacyGetRoutes)) {
$legacyController = new LegacyController($this->container);
return $legacyController->process($request, $response, $legacyGetRoutes[0]);
}
return null;
}
}

View File

@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Visitor;
use DateTime;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Helper\DailyPageHelper;
use Shaarli\Render\TemplatePage;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class DailyController
*
* Slim controller used to render the daily page.
*/
class DailyController extends ShaarliVisitorController
{
public static $DAILY_RSS_NB_DAYS = 8;
/**
* Controller displaying all bookmarks published in a single day.
* It take a `day` date query parameter (format YYYYMMDD).
*/
public function index(Request $request, Response $response): Response
{
$type = DailyPageHelper::extractRequestedType($request);
$format = DailyPageHelper::getFormatByType($type);
$latestBookmark = $this->container->bookmarkService->getLatest();
$dateTime = DailyPageHelper::extractRequestedDateTime($type, $request->getQueryParam($type), $latestBookmark);
$start = DailyPageHelper::getStartDateTimeByType($type, $dateTime);
$end = DailyPageHelper::getEndDateTimeByType($type, $dateTime);
$dailyDesc = DailyPageHelper::getDescriptionByType($type, $dateTime);
$linksToDisplay = $this->container->bookmarkService->findByDate(
$start,
$end,
$previousDay,
$nextDay
);
$formatter = $this->container->formatterFactory->getFormatter();
$formatter->addContextData('base_path', $this->container->basePath);
// We pre-format some fields for proper output.
foreach ($linksToDisplay as $key => $bookmark) {
$linksToDisplay[$key] = $formatter->format($bookmark);
// This page is a bit specific, we need raw description to calculate the length
$linksToDisplay[$key]['formatedDescription'] = $linksToDisplay[$key]['description'];
$linksToDisplay[$key]['description'] = $bookmark->getDescription();
}
$data = [
'linksToDisplay' => $linksToDisplay,
'dayDate' => $start,
'day' => $start->getTimestamp(),
'previousday' => $previousDay ? $previousDay->format($format) : '',
'nextday' => $nextDay ? $nextDay->format($format) : '',
'dayDesc' => $dailyDesc,
'type' => $type,
'localizedType' => $this->translateType($type),
];
// Hooks are called before column construction so that plugins don't have to deal with columns.
$this->executePageHooks('render_daily', $data, TemplatePage::DAILY);
$data['cols'] = $this->calculateColumns($data['linksToDisplay']);
$this->assignAllView($data);
$mainTitle = $this->container->conf->get('general.title', 'Shaarli');
$this->assignView(
'pagetitle',
$data['localizedType'] . ' - ' . $data['dayDesc'] . ' - ' . $mainTitle
);
return $response->write($this->render(TemplatePage::DAILY));
}
/**
* Daily RSS feed: 1 RSS entry per day giving all the bookmarks on that day.
* Gives the last 7 days (which have bookmarks).
* This RSS feed cannot be filtered and does not trigger plugins yet.
*/
public function rss(Request $request, Response $response): Response
{
$response = $response->withHeader('Content-Type', 'application/rss+xml; charset=utf-8');
$type = DailyPageHelper::extractRequestedType($request);
$cacheDuration = DailyPageHelper::getCacheDatePeriodByType($type);
$pageUrl = page_url($this->container->environment);
$cache = $this->container->pageCacheManager->getCachePage($pageUrl, $cacheDuration);
$cached = $cache->cachedVersion();
if (!empty($cached)) {
return $response->write($cached);
}
$days = [];
$format = DailyPageHelper::getFormatByType($type);
$length = DailyPageHelper::getRssLengthByType($type);
foreach ($this->container->bookmarkService->search()->getBookmarks() as $bookmark) {
$day = $bookmark->getCreated()->format($format);
// Stop iterating after DAILY_RSS_NB_DAYS entries
if (count($days) === $length && !isset($days[$day])) {
break;
}
$days[$day][] = $bookmark;
}
// Build the RSS feed.
$indexUrl = escape(index_url($this->container->environment));
$formatter = $this->container->formatterFactory->getFormatter();
$formatter->addContextData('index_url', $indexUrl);
$dataPerDay = [];
/** @var Bookmark[] $bookmarks */
foreach ($days as $day => $bookmarks) {
$dayDateTime = DailyPageHelper::extractRequestedDateTime($type, (string) $day);
$endDateTime = DailyPageHelper::getEndDateTimeByType($type, $dayDateTime);
// We only want the RSS entry to be published when the period is over.
if (new DateTime() < $endDateTime) {
continue;
}
$dataPerDay[$day] = [
'date' => $endDateTime,
'date_rss' => $endDateTime->format(DateTime::RSS),
'date_human' => DailyPageHelper::getDescriptionByType($type, $dayDateTime, false),
'absolute_url' => $indexUrl . 'daily?' . $type . '=' . $day,
'links' => [],
];
foreach ($bookmarks as $key => $bookmark) {
$dataPerDay[$day]['links'][$key] = $formatter->format($bookmark);
// Make permalink URL absolute
if ($bookmark->isNote()) {
$dataPerDay[$day]['links'][$key]['url'] = rtrim($indexUrl, '/') . $bookmark->getUrl();
}
}
}
$this->assignAllView([
'title' => $this->container->conf->get('general.title', 'Shaarli'),
'index_url' => $indexUrl,
'page_url' => $pageUrl,
'hide_timestamps' => $this->container->conf->get('privacy.hide_timestamps', false),
'days' => $dataPerDay,
'type' => $type,
'localizedType' => $this->translateType($type),
]);
$rssContent = $this->render(TemplatePage::DAILY_RSS);
$cache->cache($rssContent);
return $response->write($rssContent);
}
/**
* We need to spread the articles on 3 columns.
* did not want to use a JavaScript lib like http://masonry.desandro.com/
* so I manually spread entries with a simple method: I roughly evaluate the
* height of a div according to title and description length.
*/
protected function calculateColumns(array $links): array
{
// Entries to display, for each column.
$columns = [[], [], []];
// Rough estimate of columns fill.
$fill = [0, 0, 0];
foreach ($links as $link) {
// Roughly estimate length of entry (by counting characters)
// Title: 30 chars = 1 line. 1 line is 30 pixels height.
// Description: 836 characters gives roughly 342 pixel height.
// This is not perfect, but it's usually OK.
$length = strlen($link['title'] ?? '') + (342 * strlen($link['description'] ?? '')) / 836;
if (! empty($link['thumbnail'])) {
$length += 100; // 1 thumbnails roughly takes 100 pixels height.
}
// Then put in column which is the less filled:
$smallest = min($fill); // find smallest value in array.
$index = array_search($smallest, $fill); // find index of this smallest value.
array_push($columns[$index], $link); // Put entry in this column.
$fill[$index] += $length;
}
return $columns;
}
protected function translateType($type): string
{
return [
t('day') => t('Daily'),
t('week') => t('Weekly'),
t('month') => t('Monthly'),
][t($type)] ?? t('Daily');
}
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Visitor;
use Shaarli\Front\Exception\ShaarliFrontException;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Controller used to render the error page, with a provided exception.
* It is actually used as a Slim error handler.
*/
class ErrorController extends ShaarliVisitorController
{
public function __invoke(Request $request, Response $response, \Throwable $throwable): Response
{
// Unknown error encountered
$this->container->pageBuilder->reset();
if ($throwable instanceof ShaarliFrontException) {
// Functional error
$this->assignView('message', nl2br($throwable->getMessage()));
$response = $response->withStatus($throwable->getCode());
} else {
// Internal error (any other Throwable)
if ($this->container->conf->get('dev.debug', false) || $this->container->loginManager->isLoggedIn()) {
$this->assignView('message', t('Error: ') . $throwable->getMessage());
$this->assignView(
'text',
'<a href="https://github.com/shaarli/Shaarli/issues/new">'
. t('Please report it on Github.')
. '</a>'
);
$this->assignView('stacktrace', exception2text($throwable));
} else {
$this->assignView('message', t('An unexpected error occurred.'));
}
$response = $response->withStatus(500);
}
return $response->write($this->render('error'));
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Visitor;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Controller used to render the 404 error page.
*/
class ErrorNotFoundController extends ShaarliVisitorController
{
public function __invoke(Request $request, Response $response): Response
{
// Request from the API
if (false !== strpos($request->getRequestTarget(), '/api/v1')) {
return $response->withStatus(404);
}
// This is required because the middleware is ignored if the route is not found.
$this->container->basePath = rtrim($request->getUri()->getBasePath(), '/');
$this->assignView('error_message', t('Requested page could not be found.'));
return $response->withStatus(404)->write($this->render('404'));
}
}

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Shaarli\Front\Controller\Visitor;
use Shaarli\Feed\FeedBuilder;
use Slim\Http\Request;
use Slim\Http\Response;
/**
* Class FeedController
*
* Slim controller handling ATOM and RSS feed.
*/
class FeedController extends ShaarliVisitorController
{
public function atom(Request $request, Response $response): Response
{
return $this->processRequest(FeedBuilder::$FEED_ATOM, $request, $response);
}
public function rss(Request $request, Response $response): Response
{
return $this->processRequest(FeedBuilder::$FEED_RSS, $request, $response);
}
protected function processRequest(string $feedType, Request $request, Response $response): Response
{
$response = $response->withHeader('Content-Type', 'application/' . $feedType . '+xml; charset=utf-8');
$pageUrl = page_url($this->container->environment);
$cache = $this->container->pageCacheManager->getCachePage($pageUrl);
$cached = $cache->cachedVersion();
if (!empty($cached)) {
return $response->write($cached);
}
// Generate data.
$this->container->feedBuilder->setLocale(strtolower(setlocale(LC_COLLATE, 0)));
$this->container->feedBuilder->setHideDates($this->container->conf->get('privacy.hide_timestamps', false));
$this->container->feedBuilder->setUsePermalinks(
null !== $request->getParam('permalinks') || !$this->container->conf->get('feed.rss_permalinks')
);
$data = $this->container->feedBuilder->buildData($feedType, $request->getParams());
$this->executePageHooks('render_feed', $data, 'feed.' . $feedType);
$this->assignAllView($data);
$content = $this->render('feed.' . $feedType);
$cache->cache($content);
return $response->write($content);
}
}

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