Compare commits
1916 commits
theme_mana
...
myShaarli_
Author | SHA1 | Date | |
---|---|---|---|
9603ff163c | |||
be68590e2e | |||
6e400c221e | |||
59416eec4d | |||
|
df121586c1 | ||
|
00a9ddd013 | ||
|
326870f216 | ||
|
ca07f265f1 | ||
|
4557abb981 | ||
|
c88bc1d760 | ||
|
bd3e71cacb | ||
|
1697e7a4bd | ||
|
db71d2fcd4 | ||
|
2caa586a4d | ||
|
c60a42890b | ||
|
beb683e463 | ||
|
408b7e7d0a | ||
|
646f63e413 | ||
|
a98553b601 | ||
|
2ef90ec791 | ||
|
0222e62ea4 | ||
|
5b21103d76 | ||
|
728169f185 | ||
|
78f7a338c1 | ||
|
bfdbb62cd1 | ||
|
ba21aa60d9 | ||
|
484ee2bbb0 | ||
|
e5545436b0 | ||
|
00db4f6bcc | ||
|
e7a2d06a63 | ||
|
00ccae495c | ||
|
1e7419eca9 | ||
|
2a88f7a526 | ||
|
8d3e224936 | ||
|
4e653e1e97 | ||
|
00264cb4a1 | ||
|
ee5ee294b2 | ||
|
81cfed8be4 | ||
|
0992c86f16 | ||
|
256cffb289 | ||
|
62552cc413 | ||
|
a4b3430078 | ||
|
5dd9bc93e1 | ||
|
9230af464d | ||
|
b7719cdf0a | ||
|
db1532214e | ||
|
c4d3e2d7ec | ||
|
5db074fdef | ||
|
ea3cda543c | ||
|
280e6138fc | ||
|
4b94854200 | ||
|
775fcb44f9 | ||
|
25d2a9b744 | ||
|
572c55f0b1 | ||
|
add670b8ab | ||
|
5a6515c988 | ||
|
ea57088177 | ||
|
638a7c5141 | ||
|
c937d3e184 | ||
|
4cbba2fe64 | ||
|
949e03ca1f | ||
|
e91984b507 | ||
|
f812581234 | ||
be6a3e28ca | |||
|
5af1c9dbae | ||
|
e7ff18c864 | ||
|
66982b6f79 | ||
|
6d16b6c8a8 | ||
|
910b695d4d | ||
|
d24fc65695 | ||
|
c44a0d200d | ||
|
9fd6739a1f | ||
|
3b5923b7e1 | ||
2c1f0981d9 | |||
23a5fc1eef | |||
|
467b28c237 | ||
|
0eee6a2ba1 | ||
|
27db81ab07 | ||
|
f64b4666ea | ||
|
a1c6460077 | ||
|
a8c7c5c4bf | ||
|
eb340c7eb6 | ||
|
22b4044986 | ||
|
d48e06f438 | ||
|
88b76c44f7 | ||
|
1cd642619f | ||
|
5f69e17310 | ||
|
e0a8f961ba | ||
|
739776a754 | ||
|
08d9347e9a | ||
|
050bb50cdf | ||
|
ef9d019ccd | ||
|
a7e361e22a | ||
|
9a1ad45e90 | ||
|
fd4379992d | ||
|
9c9d6298bf | ||
|
625235787e | ||
|
062698c123 | ||
|
0a47d89193 | ||
|
6624f00c05 | ||
|
c0c743f9c6 | ||
|
9e24bb317c | ||
|
ebe9417981 | ||
|
8cd369aee7 | ||
|
b858332f9f | ||
|
8457001294 | ||
|
f15ac5957a | ||
|
721c090198 | ||
|
9195ce0378 | ||
|
4b88cbb56c | ||
|
e4ee672404 | ||
|
169755c6a9 | ||
|
4c76d4eea9 | ||
|
99485504d0 | ||
|
cc2ea94d06 | ||
|
1222aa62c5 | ||
|
50c27108c7 | ||
|
0d70e5e44f | ||
|
a9acad9e1a | ||
|
f3316d707a | ||
|
b9938cf084 | ||
|
8226308d67 | ||
|
1ba35135a1 | ||
|
de895c73b7 | ||
|
3c39cea735 | ||
|
ab16f6a826 | ||
|
c0ace02c3a | ||
|
6d4e397805 | ||
|
965f8948ee | ||
|
bc470ef8c7 | ||
|
ff4d8b5198 | ||
|
8f8abf2025 | ||
|
7bcf4255a7 | ||
|
733b40446e | ||
|
e957f8fb23 | ||
|
c971344ddb | ||
|
f28b9d550e | ||
|
00cce1f8c7 | ||
|
cd618bd8be | ||
|
84b37c7baa | ||
|
4242f6955a | ||
|
611b794034 | ||
|
5abf19faf2 | ||
|
ac8966566e | ||
|
0a058bba45 | ||
|
864e0bf775 | ||
|
75e659e3a2 | ||
|
15fb1bdeef | ||
|
89a945a1c9 | ||
|
bd0d639c60 | ||
|
75fe26dbdb | ||
|
a54795f8eb | ||
|
6775e2a2af | ||
|
a265865124 | ||
|
ea1810d4da | ||
|
a66f036ece | ||
|
288b25ae61 | ||
|
93a826f0a4 | ||
|
dc589b7490 | ||
|
5177e036fa | ||
|
74883e373e | ||
|
8b1f9115ad | ||
|
e0a9d66d99 | ||
|
bd81f94287 | ||
|
b5988ce296 | ||
|
1da6caedfb | ||
|
db33cd79b5 | ||
|
6180c859d3 | ||
|
dbd99f310f | ||
|
221a2534b2 | ||
|
8c4c408072 | ||
|
51603fe265 | ||
|
0f896033fa | ||
|
dc08e17996 | ||
|
7c6df1c80f | ||
|
3665594d36 | ||
|
8a7a09df90 | ||
|
bde1fdcb48 | ||
|
10b460fbd2 | ||
|
130008da03 | ||
|
b59cdb3871 | ||
|
d5b218eed4 | ||
|
22d43c303b | ||
|
8b10110c2b | ||
|
32777d9fd2 | ||
|
46a104e433 | ||
|
11cf6e9b49 | ||
|
39c89d84f5 | ||
|
bf8bec322b | ||
|
91dbe63e00 | ||
|
fdfc09f73f | ||
|
0abe2f0dcc | ||
|
40a26241de | ||
|
e22c550cca | ||
|
3813b39a4c | ||
|
21af53f795 | ||
|
026ed40d90 | ||
|
7e39b5ee36 | ||
|
febe0a6487 | ||
|
803f6a7e36 | ||
|
1387e059eb | ||
|
9d99925615 | ||
|
6ecc4745f4 | ||
|
f6076a9275 | ||
|
abf5a0fb9f | ||
|
c2c8990f23 | ||
|
f2cd2215f0 | ||
|
441b755712 | ||
|
cbb140a9af | ||
|
30cf6bf92e | ||
|
6067508bb1 | ||
|
39a3749f3b | ||
|
cf9031da22 | ||
|
aeda845b3c | ||
|
7ae4b2f8b0 | ||
|
df0ca43873 | ||
|
21550cda7d | ||
|
e0f4fd4537 | ||
|
163cd8ce2d | ||
|
d2019d3a09 | ||
|
c6f27b1ade | ||
|
6202038c65 | ||
|
a7d43caccb | ||
|
6e0f92acb4 | ||
|
6ca3980812 | ||
|
a276e0c4e6 | ||
|
b1f026b674 | ||
|
a908ed6866 | ||
|
6ca8ab31b2 | ||
|
f33af48b59 | ||
|
dea58d5abd | ||
|
36e16b3d17 | ||
|
3b1818dfa6 | ||
|
9a62827452 | ||
|
dba8975f85 | ||
|
4dfabbf8a7 | ||
|
65d9afa9b8 | ||
|
142370f0a1 | ||
|
a0eed0546b | ||
|
ab6a4c9f7b | ||
|
63fe4881cc | ||
|
a79707258f | ||
|
1a36e314ba | ||
|
4cfaa67e07 | ||
|
338648093f | ||
|
d1fed70609 | ||
|
4a63c2bbb4 | ||
|
f841121a6e | ||
|
2a569f1e93 | ||
|
bf63992617 | ||
|
eeaabc05a7 | ||
|
11c6fc418d | ||
|
4f6a4f6107 | ||
|
a047b62c2b | ||
|
fbedbd8ee2 | ||
|
0b97c4279e | ||
|
8887f9826a | ||
|
2ce2ef3708 | ||
|
d8fe0e7276 | ||
|
3d27ecbb37 | ||
|
fbb33c723e | ||
|
bbb2dd30ac | ||
|
62c9b3a6d9 | ||
|
bbcb18931c | ||
|
d4da270289 | ||
|
832739e58f | ||
|
0f4cd55599 | ||
|
e660450a2b | ||
|
45b3c7cb7a | ||
|
723e93f439 | ||
|
6225cc9c86 | ||
|
824f3c6dff | ||
|
4ca1b33b0e | ||
|
c273f511aa | ||
|
a5482cf9af | ||
|
060e6d9498 | ||
|
e00b9dc099 | ||
|
0ed6614981 | ||
|
9700d9ea3e | ||
|
9f87e82cdb | ||
|
5bf3deb815 | ||
|
ba4fa9460a | ||
|
8b428dabea | ||
|
af764dfd4e | ||
|
44b0825860 | ||
|
830a73dcf6 | ||
|
0681511699 | ||
|
9665870b39 | ||
|
b2a43bc861 | ||
|
bd7ed438fa | ||
|
b29f14d67e | ||
|
0a47426f88 | ||
|
2c2c349e8a | ||
|
2ea2c99dcb | ||
|
4543717881 | ||
|
fe170cb571 | ||
|
755c094bdd | ||
|
8b8ed23ae1 | ||
|
28123e7283 | ||
|
546c776acd | ||
|
e1847ae5a7 | ||
|
fb0d610c80 | ||
|
c5cd2f16f4 | ||
|
3bb6205e7f | ||
|
94dadd85a0 | ||
|
9db1ccdf2c | ||
|
bcba6bd353 | ||
|
8997ae6c8e | ||
|
11edc143b4 | ||
|
a1cd7a3b2f | ||
|
83b4eb1795 | ||
|
d496fd857d | ||
|
e570cc8b1a | ||
|
9b8c0a4560 | ||
|
055d97f9a9 | ||
|
9ef8555ad2 | ||
|
dafd3f081a | ||
|
6b76ce6f62 | ||
|
baac4388b1 | ||
|
5b5d22a3df | ||
|
3d6278e86f | ||
|
93175b6e9d | ||
|
47ac77adbb | ||
|
ffa39719a1 | ||
|
31c4be2b60 | ||
|
2fbdb7d657 | ||
|
8fbc29de02 | ||
|
9e55beebfd | ||
|
ccd1862d5f | ||
|
544bbdaf83 | ||
|
bf02f8ba8e | ||
|
e6754f2154 | ||
|
ed4ee8f029 | ||
|
20ba77a2dc | ||
|
0640c1a6db | ||
|
035a002edc | ||
|
fe58bdcd9e | ||
|
8ed5fbef8f | ||
|
b01b3b83a7 | ||
|
f2e309b67d | ||
|
151fa1e450 | ||
|
f00600a283 | ||
|
88a8e284b2 | ||
|
ab4c170672 | ||
|
bd11879018 | ||
|
8f423eb11c | ||
|
b1d78519a8 | ||
|
3d5f05052f | ||
|
51dea3b87e | ||
|
6a3a78d023 | ||
|
5b74c67461 | ||
|
2a20e67f76 | ||
|
ee07df357b | ||
|
495545f2f0 | ||
|
8a6b7e96b7 | ||
|
e4b8330e45 | ||
|
05c616f7a0 | ||
|
70507b8603 | ||
|
b2eb77e1f7 | ||
|
51580efbff | ||
|
da950241f3 | ||
|
a6e9c08499 | ||
|
2883c6d0a7 | ||
|
1595d0e2b3 | ||
|
150f2a0f24 | ||
|
831e974ea5 | ||
|
6f9e0609f4 | ||
|
a6935feb22 | ||
|
5a09b5fffd | ||
|
302662797c | ||
|
c94c32d1a3 | ||
|
39b75ea983 | ||
|
7e78237fc9 | ||
|
325cc8adad | ||
|
8affa22431 | ||
|
80c8889bfe | ||
|
85c09fe379 | ||
|
336f15e8ba | ||
|
1e49a65a2a | ||
|
2f4df75304 | ||
|
5c856a6923 | ||
|
b99e00f7cd | ||
|
53054b2bf6 | ||
|
b7c50a58de | ||
|
e09bb93e18 | ||
|
d9d71b10c3 | ||
|
c51d65238b | ||
|
8d8fa898ab | ||
|
00d3dd91ef | ||
|
8a9796014c | ||
|
9952de2fe0 | ||
|
ce901a5828 | ||
|
8c5f6c786d | ||
|
cfdd209440 | ||
|
8a1ce1da15 | ||
|
df9aac5b64 | ||
|
67339338af | ||
|
b3bd8c3e8d | ||
|
48df9f45b8 | ||
|
c61d8a85b7 | ||
|
a5a4fb1793 | ||
|
5f987a64d8 | ||
|
8bbf57a2d0 | ||
|
47d1581850 | ||
|
a4a59e183e | ||
|
330ac859fb | ||
|
740b32b520 | ||
|
1a94978e44 | ||
|
38b55fbf3d | ||
|
b7ec15790e | ||
|
b862705947 | ||
|
dff039092d | ||
|
b37ca79072 | ||
|
14c9370b4f | ||
|
114a43b20e | ||
|
1ca7ddd76b | ||
|
d3f6d52525 | ||
|
d2bb40cc7c | ||
|
156061d445 | ||
|
06734af130 | ||
|
ff9686066e | ||
|
358cb20bcb | ||
|
b2b5ef3122 | ||
|
34c8f558e5 | ||
|
6a71675887 | ||
|
c609944cb9 | ||
|
25e90d8d75 | ||
|
5d8de7587d | ||
|
b8e5a253ab | ||
|
54afb1d6f6 | ||
|
36e6d88dbf | ||
|
c2cd15dac2 | ||
|
977db7eabc | ||
|
9c04921a8c | ||
|
e6215a2ad9 | ||
|
034c1ce526 | ||
|
e69e3fef7b | ||
|
42a72c02fa | ||
|
820cae27cf | ||
|
49da2ffbce | ||
|
8f6e3d51cc | ||
|
2f87bfdc69 | ||
|
0cf76ccb47 | ||
|
3445443349 | ||
|
5c06c0870f | ||
|
b38a1b0209 | ||
|
ca5e98da48 | ||
|
d8030c8155 | ||
|
21e72da9ee | ||
|
9b3c1270bc | ||
|
552c3b942a | ||
|
5256b42870 | ||
|
52e27a96a4 | ||
|
6866ed766f | ||
|
f1a148ab92 | ||
|
4e3875c0ce | ||
|
64cac25626 | ||
|
3cb4e8a44c | ||
|
81c9df1363 | ||
|
7836ed9b2e | ||
|
3adbdc2a83 | ||
|
7f5250421b | ||
|
cd2878edee | ||
|
4b3aca6623 | ||
|
5334090be0 | ||
|
4cf3564d28 | ||
|
f34554c6c2 | ||
|
ec45749187 | ||
|
4a26974a4b | ||
|
efb7d21b52 | ||
|
29c31b7ec6 | ||
|
fd1ddad98d | ||
|
458b6b9918 | ||
|
543b16b4f4 | ||
|
8f269b49d7 | ||
|
8fabcd0224 | ||
|
84045ffbb1 | ||
|
64152387d6 | ||
|
2d015d79b7 | ||
|
3020310dd0 | ||
|
b028f0869f | ||
|
7f1bb5553b | ||
|
72fbbcd679 | ||
|
df25b28dcd | ||
|
fc4d1b6796 | ||
|
bb176441cb | ||
|
7b18876361 | ||
|
ee07b7283f | ||
|
f4ea7cd563 | ||
|
1db2ebbd79 | ||
|
255b2264a1 | ||
|
80a3efe116 | ||
|
25cb75552b | ||
|
d8ef4a893f | ||
|
95158e7565 | ||
|
22e75f062d | ||
|
c3fca560b6 | ||
|
769a28833b | ||
|
1ea09a1b8b | ||
|
d246e2c512 | ||
|
3a49307c3d | ||
|
d018755b45 | ||
|
f447edb73b | ||
|
ab58f25420 | ||
|
a5a9cf23ac | ||
|
2b7a7bc928 | ||
|
676571dab9 | ||
|
6cdca9562c | ||
|
b1baca99f2 | ||
|
8f60e1206e | ||
|
24225f6332 | ||
|
e011be0170 | ||
|
d0ae1ba273 | ||
|
1cb5be5d0c | ||
|
585fc700fa | ||
|
85b972baf6 | ||
|
71eb87353c | ||
|
0f686afe11 | ||
|
6ec24b3605 | ||
|
cdb96276c1 | ||
|
9192a48be3 | ||
|
98325d646e | ||
|
96746d7165 | ||
|
abe033be85 | ||
|
5baafe5001 | ||
|
98e7a59ca2 | ||
|
2785d85e0a | ||
|
76fe68d924 | ||
|
6f199ee489 | ||
|
4488ea4bb9 | ||
|
0d930454a2 | ||
|
4af591ff3c | ||
|
b93cfeba7b | ||
|
650a5f09cb | ||
|
e809908f9e | ||
|
97870f3512 | ||
|
68855686db | ||
|
a5e9f2d6c9 | ||
|
61f0c4b679 | ||
|
f682f1b899 | ||
|
083b28021a | ||
|
19489e92d7 | ||
|
5eece37b0a | ||
|
d8847936d4 | ||
|
9417f1337e | ||
|
1a19c921a9 | ||
|
78b5b44d8f | ||
|
ff2b5f5bd8 | ||
|
48b19a7014 | ||
|
02117f7ea3 | ||
|
e21df1e729 | ||
|
f3ab261631 | ||
|
38d66e1a40 | ||
|
ecdae2237f | ||
|
45203c0bca | ||
|
46e019a132 | ||
|
78f319fa6b | ||
|
6c4cae378e | ||
|
1aeefe1088 | ||
|
e0fe33f90b | ||
|
b6c9a2db30 | ||
|
5cacf290f0 | ||
|
56ae25f11f | ||
|
f5afa87c38 | ||
|
dfe14f264b | ||
|
74c2ae4088 | ||
|
328c215a8a | ||
|
dfed9b2dd5 | ||
|
881bd96f15 | ||
|
778add2c9c | ||
|
538fb324a8 | ||
|
c84d143047 | ||
|
30255b794a | ||
|
41b93897f3 | ||
|
6384447d1d | ||
|
a32e6665d0 | ||
|
fe007f94e4 | ||
|
91a21c2729 | ||
|
4ff703e369 | ||
|
d52ab0b1e9 | ||
|
6128ab6a55 | ||
|
da7acb9830 | ||
|
e2dff28b44 | ||
|
949a095310 | ||
|
27ddfec3c3 | ||
|
a5dd7d58d2 | ||
|
707a1d237a | ||
|
a3cb851d0c | ||
|
7b05dd71f3 | ||
|
e2a2441d3b | ||
|
2ba51040c7 | ||
|
d33cffdb2e | ||
|
2cd0509b50 | ||
|
80b708a878 | ||
|
46d3f8162b | ||
|
0386a84d82 | ||
|
ce7918386a | ||
|
21163a3329 | ||
|
865f0a0e01 | ||
|
9e6371a6fd | ||
|
0a286f6946 | ||
|
2835ac7cbe | ||
|
ca636b898c | ||
|
d95624add4 | ||
|
43582975dc | ||
|
82fcace8fc | ||
|
14fcfb5213 | ||
|
cd10bc23e7 | ||
|
11aa4a7a29 | ||
|
9e2d47e519 | ||
|
aca995e09c | ||
|
0e60b7f174 | ||
|
4479aff18f | ||
|
63b0059ed5 | ||
|
06f05c923a | ||
|
a975d97a8d | ||
|
e813934ae1 | ||
|
816ffba74b | ||
|
b06fc28aa3 | ||
|
a8e210faa6 | ||
|
972daa4513 | ||
|
8af1d2da60 | ||
|
ebc027ec0a | ||
|
3eba6bd318 | ||
|
bea062149e | ||
|
2d8a0a71a8 | ||
|
46237c9788 | ||
|
6152a26790 | ||
|
109ebf318f | ||
|
7e3dc0ba98 | ||
|
af41d5ab5d | ||
|
0c6fdbe12b | ||
|
bedbb845ee | ||
|
1a68ae5a29 | ||
|
d6e5f04d39 | ||
|
af074f9030 | ||
|
f7f08ceec1 | ||
|
624123177f | ||
|
301c7ab1a0 | ||
|
b725eb047d | ||
|
a285668ec4 | ||
|
9fbc42294e | ||
|
bc583903ad | ||
|
204035bd3c | ||
|
87ae3c4f08 | ||
|
8e9169ceba | ||
|
3ee8351e43 | ||
|
fabff3835d | ||
|
a8c11451e8 | ||
|
c4ad3d4f06 | ||
|
1a8ac737e5 | ||
|
6132d64748 | ||
|
764d34a7d3 | ||
|
1b8620b1ad | ||
|
78657347c5 | ||
|
c70ff64a61 | ||
|
e8a10f312a | ||
|
3447d888d7 | ||
|
7b8a6f2858 | ||
|
1ab675445e | ||
|
baa6979194 | ||
|
9c75f87793 | ||
|
818b3193ff | ||
|
c22fa57a55 | ||
|
8eac2e5488 | ||
|
66063ed1a1 | ||
|
465033230d | ||
|
fdedbfd4a7 | ||
|
ef00f9d203 | ||
|
ba43064ddb | ||
|
2899ebb5b5 | ||
|
af290059d1 | ||
|
893f5159c6 | ||
|
dd09ec52b2 | ||
|
5ec4708ced | ||
|
7b2ba6ef82 | ||
|
f4929b1188 | ||
|
c56a540c6e | ||
|
029ada5a07 | ||
|
c4d5be53c2 | ||
|
e3d28be967 | ||
|
07f99432b7 | ||
|
69e29ff65e | ||
|
60ae241251 | ||
|
3772298ee7 | ||
|
c79473bd84 | ||
|
72caf4e84c | ||
|
c266a89d0f | ||
|
03340c18ea | ||
|
8e47af2b36 | ||
|
b0428aa9b0 | ||
|
485b168a96 | ||
|
bee33239ed | ||
|
b8e3630f2e | ||
|
c909f5d5cc | ||
|
2a891ca6b1 | ||
|
df46c28208 | ||
|
7e884740f1 | ||
|
78c2f122e0 | ||
|
8694e8411b | ||
|
e1231265bc | ||
|
a69cfe0dd2 | ||
|
9ba6982ea3 | ||
|
21e5df5ee8 | ||
|
c0d750b9e5 | ||
|
8f80821820 | ||
|
50c9543f7b | ||
|
8f6202deb0 | ||
|
cf01113cad | ||
|
752bc4c5e6 | ||
|
2dd6ecb126 | ||
|
dbbcb0c6cf | ||
|
273453900a | ||
|
1ea8aeef76 | ||
|
46846fd4fc | ||
|
cc2ded54e1 | ||
|
424530d9af | ||
|
810f0f6c96 | ||
|
df7c286d91 | ||
|
db206aaaca | ||
|
1f02ae8076 | ||
|
922341aaa7 | ||
|
82e7b56f29 | ||
|
52964ec873 | ||
|
f6637392a9 | ||
|
b495d5c92a | ||
|
c653ae3bfb | ||
|
27ceea2aee | ||
|
0498b209b5 | ||
|
9e4cc28e29 | ||
|
dd51f653d0 | ||
|
6c50a6ccce | ||
|
1410dce2db | ||
|
20433ea72b | ||
|
529fc750b3 | ||
|
0d42b21200 | ||
|
fc6c701774 | ||
|
09390a50cd | ||
|
04a816f648 | ||
|
3b04d19a62 | ||
|
54ab5636e3 | ||
|
a8a38401f0 | ||
|
7a7a523782 | ||
|
6fa3c87d32 | ||
|
57bd9780c8 | ||
|
dd452c5691 | ||
|
1001cc108f | ||
|
12523aea34 | ||
|
fecfc73b3f | ||
|
a39acb2518 | ||
|
7e3648ad87 | ||
|
4869d535b5 | ||
|
3fb29fdda0 | ||
|
e26e2060f5 | ||
|
cf92b4dd15 | ||
|
336a28fa4a | ||
|
796c4c57d0 | ||
|
def39d0dd7 | ||
|
ef02885753 | ||
|
7d0db8b567 | ||
|
d9bfceaddf | ||
|
74c1d02079 | ||
|
5256f83d02 | ||
|
eb0a0f77cc | ||
|
af8a03d1ab | ||
|
ac2214bdb5 | ||
|
72539044fb | ||
|
99c7d66384 | ||
|
3575fe5bcf | ||
|
54b065c253 | ||
|
4b15c49198 | ||
|
83ef0ff176 | ||
|
f4c6625962 | ||
|
b4665de89b | ||
|
5ed0d9f54d | ||
|
bda0d35a51 | ||
|
31d691649a | ||
|
0b631e69d1 | ||
|
954b3c81ce | ||
|
1df5e9ca86 | ||
|
c139a6e9ed | ||
|
b04ff6cf16 | ||
|
7eb196723e | ||
|
fc66e61ca9 | ||
|
0baa658130 | ||
|
51837fe8ba | ||
|
e0e24335f7 | ||
|
3b0f03770b | ||
|
90ea2cb62e | ||
|
8d4cada793 | ||
|
424ae4b001 | ||
|
188a99db99 | ||
|
354fb98cc9 | ||
|
5669f474f3 | ||
|
b5e2b23c99 | ||
984073a980 | |||
|
0a4bc5a17d | ||
|
b405a44f29 | ||
|
5f1617a480 | ||
|
bd1adc8df6 | ||
|
7ff3ed1d63 | ||
|
14a7d73c2d | ||
|
edcfe54c45 | ||
|
9f9627059a | ||
|
8ba951640c | ||
|
1a6d61766a | ||
|
3a52dfcc5c | ||
|
f400ba291d | ||
|
31c788ddcf | ||
|
e29a9a73b4 | ||
|
df40879739 | ||
|
91af3e6b19 | ||
|
5e61546a32 | ||
|
211d93307b | ||
|
58c2701e54 | ||
|
419eef1f56 | ||
|
fa7625219d | ||
|
67d4029fee | ||
|
9f3bdf5895 | ||
|
f9b99c7217 | ||
|
06a8992737 | ||
|
ad8099a7af | ||
|
ed3365325d | ||
|
d7dead5644 | ||
|
81cae5f5dd | ||
|
c49b999001 | ||
|
525069ea7a | ||
|
37686457f0 | ||
|
4c029779c8 | ||
|
a9633359d1 | ||
|
a8fb97a0c3 | ||
|
e503d26f0b | ||
|
15a61e5974 | ||
|
b550735054 | ||
|
d1bcf28db3 | ||
|
719ef8e896 | ||
|
01ba8a0700 | ||
|
b15d065905 | ||
|
c088ae99bf | ||
|
19ba060669 | ||
|
f2d00d95a0 | ||
|
84b8426c31 | ||
|
cadf4d5bd6 | ||
|
6177da0c30 | ||
|
d91719ab97 | ||
|
b43c98fecb | ||
|
852872930f | ||
7fe2910525 | |||
|
a5a0c0399b | ||
|
c03c90a13e | ||
|
6a4872520c | ||
|
5d8a958d5d | ||
|
bd231539e9 | ||
|
a47656a28e | ||
|
0b0694064c | ||
|
e14d47cc55 | ||
|
86aa248654 | ||
|
5c003824a3 | ||
|
5321f704b5 | ||
|
c3a04e328f | ||
|
8ed59f107e | ||
|
5f8f6134bc | ||
|
b2143ff480 | ||
|
590c34dec1 | ||
|
dbbea38c7a | ||
|
374f89e721 | ||
|
06783e8f1a | ||
|
c5e96f594b | ||
|
160d9a7741 | ||
|
51c5de1105 | ||
|
786f35f270 | ||
|
d3defcac1c | ||
|
8fc0a984f0 | ||
|
e7ffbb7ed1 | ||
|
e92676ace2 | ||
|
1e77e0448b | ||
|
1cc5eaf9de | ||
|
0ed9396bfa | ||
|
0d4c7a9fe3 | ||
|
18d2d3ae15 | ||
|
b7aad51e8a | ||
|
6e76474c4d | ||
|
d3c813fc15 | ||
|
008b0f82b6 | ||
|
1004fd7d59 | ||
|
d3bbf9ee4d | ||
|
da815e3f2e | ||
|
de07aad18f | ||
|
c31dd67c5d | ||
|
90e048594a | ||
|
cc69aad4a9 | ||
272b07627b | |||
|
5bb384cd27 | ||
|
86dcb9048f | ||
|
f87dd90f7b | ||
|
a8e7da0114 | ||
|
c21dcc8199 | ||
|
015314f3c6 | ||
|
0ee11e9390 | ||
|
c85b9758a6 | ||
|
1c03b65e2e | ||
|
2c16e8e9a8 | ||
|
43c77f658a | ||
|
3dc80d69ab | ||
|
8d03f705eb | ||
|
899d041137 | ||
|
54ee240878 | ||
|
b49a04f796 | ||
|
cb974e4747 | ||
|
b790f900c9 | ||
|
520d29578c | ||
|
5bd62b5d53 | ||
|
905f8675a7 | ||
|
7417e8ac4a | ||
|
9f0c719c53 | ||
|
8d1509e8a6 | ||
|
7c13054038 | ||
|
0d41c8584c | ||
|
1173f8c87a | ||
|
4d55e4f075 | ||
|
913c70d8e7 | ||
|
e664865e2e | ||
|
92423ce58a | ||
|
586a9e0065 | ||
|
49106a5d8c | ||
|
9eb6055abb | ||
|
7be2a2d5f4 | ||
|
1c53591a43 | ||
|
8f4e9624e6 | ||
|
ff3b5dc554 | ||
|
dea72c711f | ||
|
a43e7842e4 | ||
|
1a55fc8d63 | ||
|
9585441734 | ||
|
e185038834 | ||
|
349b014401 | ||
|
a932f486f2 | ||
|
9778a1551c | ||
|
bcf056c9d9 | ||
|
92c6439dbc | ||
|
fe3713d2e5 | ||
|
6696729b88 | ||
|
f24896b237 | ||
|
a0c4dbd91c | ||
|
8c0f19c797 | ||
|
51753e403f | ||
|
fb1b182fbf | ||
|
00af48d9d2 | ||
|
dfc650aa23 | ||
|
f3d2f25794 | ||
|
bdc5152d48 | ||
|
1826e383ec | ||
|
a7c98a07d1 | ||
|
02c70f624e | ||
|
7062ef4ddd | ||
|
1004742f09 | ||
|
9d9f6d75b9 | ||
|
067c2dd8f5 | ||
|
93bf0918fa | ||
|
a0ab3c3f68 | ||
|
f211e417bf | ||
|
04ec8fedd9 | ||
|
37c9c6b4e6 | ||
|
5e0a898bb1 | ||
|
027ff329a1 | ||
|
0c42c5e359 | ||
|
db06c261f6 | ||
|
d53d9d01f7 | ||
|
f6380409ac | ||
|
a605982fa9 | ||
|
6fd287a0a2 | ||
|
d37348efe2 | ||
|
d3734b0652 | ||
|
afe4377e4d | ||
|
37bbfb5f65 | ||
|
1a9515ff6f | ||
|
8b2afee16b | ||
|
b41c5ab04c | ||
|
e95247d41d | ||
|
a062416918 | ||
|
8cac122086 | ||
|
fa8100c088 | ||
|
86e1bc713f | ||
|
d9bf5b31ff | ||
|
4154c25b5f | ||
71071f144a | |||
ee610d4505 | |||
3801d999a0 | |||
|
10a7b5cee9 | ||
94716fb2ba | |||
|
4adeffd7f4 | ||
|
a4fbe88b6d | ||
|
bede8e1b63 | ||
|
6c44d604a1 | ||
|
2b4f391559 | ||
|
b817fb0d95 | ||
|
4fa9a3c5d8 | ||
|
0e54e1059f | ||
|
cb7940e2de | ||
|
8c75c43e7e | ||
|
f28b73b21f | ||
|
b54faf4fd9 | ||
|
fc574e6454 | ||
|
83eab29ef8 | ||
|
5d9bc40d7e | ||
|
a120fb2977 | ||
|
d94e6e69dd | ||
|
14077272f4 | ||
|
5de61c2ca7 | ||
|
630ebca2b6 | ||
|
2b12812e77 | ||
|
dd8de81ee3 | ||
|
382869ad54 | ||
|
5190466414 | ||
|
5e66ba1882 | ||
|
2302347524 | ||
|
f9bc4f9e79 | ||
|
69a15872d0 | ||
|
6a7815951c | ||
|
dc5e094483 | ||
|
2c4170553f | ||
|
1c88a7b33e | ||
|
62f5a75813 | ||
|
dccd62cbd6 | ||
|
8aca613b07 | ||
|
b5c368b858 | ||
|
fa5012cb04 | ||
|
be5db0a5cf | ||
|
667963435f | ||
|
a71e1aa73e | ||
|
d0e8ca9224 | ||
|
a71d6641f6 | ||
|
9c91a17ba8 | ||
48ab8cd53d | |||
|
75c4b0d03b | ||
|
bf3c9934d2 | ||
|
1412d2c245 | ||
|
a136a427ae | ||
|
a4f0509a77 | ||
|
e87f57c758 | ||
|
ab6c848c86 | ||
|
31d160d3dc | ||
|
1df447b262 | ||
|
ad5f47adba | ||
|
8fdd65b884 | ||
|
d8e4bf1535 | ||
|
aeb8586be4 | ||
|
e3af34d06d | ||
|
9618e45f4b | ||
|
d6e392a9cb | ||
|
d9ba1cdd44 | ||
|
40f0ff2236 | ||
|
7b4fea0e39 | ||
|
5d32c50ad7 | ||
|
6ecc4664b1 | ||
|
be53fa40ff | ||
|
a0c34a4976 | ||
|
738b1873c3 | ||
|
9cc6ea6560 | ||
|
1cafacfedd | ||
|
81c801300b | ||
|
c9fcaaee93 | ||
|
c2c2338f9a | ||
|
f39b1242a8 | ||
|
3028a84c13 | ||
|
5045585f24 | ||
|
e7f4a03d24 | ||
|
6410bf9670 | ||
|
7c57bd9538 | ||
|
b302b3c584 | ||
|
fcba541e2f | ||
|
28f2652460 | ||
|
787faa42f3 | ||
|
8b5b7dcc83 | ||
|
e85b7a05a1 | ||
|
a3724717ec | ||
|
1b93137e16 | ||
|
edb4a4d9c9 | ||
|
186d9eaa57 | ||
|
5dc4b8ab69 | ||
|
508397a88e | ||
|
d9a0b52276 | ||
|
87f1431247 | ||
|
972cd80085 | ||
|
fd2e8fad79 | ||
|
c1503307ce | ||
|
c429f28ad4 | ||
|
52731281bc | ||
|
ab752eaf15 | ||
|
359696dcbb | ||
|
de15ed1def | ||
|
969ed87fb1 | ||
|
7519388dd6 | ||
|
6e1df6013e | ||
|
47ddfc57a0 | ||
|
6325e74caa | ||
|
658988f3ae | ||
|
5420c87e22 | ||
|
bdfb967ca2 | ||
|
c064d3179e | ||
|
decae8c119 | ||
|
2a3fe990dd | ||
|
7cf436cea4 | ||
|
1168abb484 | ||
|
865d57b84a | ||
|
47095cb333 | ||
|
26b0b20228 | ||
|
cad4251ad7 | ||
|
ea700dd89f | ||
|
ee93a09387 | ||
|
0deaedeeae | ||
|
f6b3295d28 | ||
|
d3f42ca487 | ||
|
17e45b2e9c | ||
|
d9cd27322a | ||
|
8edd7f1588 | ||
|
704637bfeb | ||
|
ebf6151738 | ||
|
c689e10863 | ||
|
51f0128cdb | ||
|
fab87c2696 | ||
|
68dcaccfa4 | ||
|
89ccc83ba4 | ||
|
8474208474 | ||
|
c7721487b2 | ||
|
1b28c66cc7 | ||
|
63ea23c2a6 | ||
|
49f1832316 | ||
|
db45a36a53 | ||
|
88110550b8 | ||
|
f8c5660df8 | ||
|
8f816d8ddf | ||
|
cdebf7f9b4 | ||
|
f28396a2f8 | ||
|
dd6794cff8 | ||
|
73da3a269b | ||
|
4de024d7c3 | ||
|
03b483aa45 | ||
|
9d0fc86250 | ||
|
c69585f303 | ||
|
73c5af594c | ||
|
16d35cf77e | ||
|
3e35fc10e5 | ||
|
a1b727efb7 | ||
|
8d2cac1be6 | ||
|
3c0e27eec7 | ||
|
7ca124079e | ||
|
67a5c6d6f3 | ||
|
2e47af897e | ||
|
630790a1aa | ||
|
bf7993dceb | ||
|
6af9363aa5 | ||
|
5991f7a993 | ||
|
80786e150d | ||
|
14dd77ad7e | ||
|
66d37a4fb4 | ||
|
d811e4fda6 | ||
|
237e7836c0 | ||
|
aec5a76b67 | ||
|
d66b5acb24 | ||
|
7cf23badeb | ||
|
e42031e037 | ||
|
9fb22af6b3 | ||
|
80ec7b234c | ||
|
c5ee13181e | ||
|
b66769fec5 | ||
|
ed6d1a7b80 | ||
|
c81f1afc0a | ||
|
d7eb06bd7c | ||
|
47978e8772 | ||
|
7e9bd977ee | ||
|
a33c565365 | ||
|
b3375c7f86 | ||
|
94abe0a653 | ||
1a129ca266 | |||
|
9b2bd66fb6 | ||
|
a1a15ac37b | ||
|
7ff458bc43 | ||
|
758fe7201e | ||
|
d42da54350 | ||
|
d78c23e00d | ||
|
68c6afc56f | ||
|
838ef8a6ec | ||
|
faa5b2ce61 | ||
|
ee242ae321 | ||
|
76004d331b | ||
|
e36479d9ff | ||
|
d1e8f152f6 | ||
|
4c2f51256f | ||
|
1f43529fd0 | ||
|
cdaf414c98 | ||
|
b0f39c6654 | ||
|
6c4cc14e00 | ||
|
adf409716b | ||
|
460cf03d67 | ||
|
e54cb1bbe7 | ||
|
b525810c14 | ||
|
60a94dab22 | ||
|
15410df113 | ||
|
4294bc7b98 | ||
cdc426d560 | |||
|
017baf57d5 | ||
|
4ff3ed1c47 | ||
|
39ee93925b | ||
|
a58a8856a8 | ||
|
ed2de76840 | ||
|
d2d4f993e1 | ||
|
b70436373b | ||
|
ddd3c19f43 | ||
|
bc4a0a672c | ||
|
e746c237cd | ||
|
980efd6cf8 | ||
|
3ff1ce47bc | ||
|
ba2cff1549 | ||
|
b9c6589363 | ||
|
afaaee7be6 | ||
|
2e6b9ed3b9 | ||
|
3c51135f9a | ||
|
48679a159e | ||
|
4c1bcd8b25 | ||
c111704f8c | |||
7a4ff2cd78 | |||
d923d1db2f | |||
ba04c60849 | |||
|
8b48e36594 | ||
|
cabf1b6bec | ||
|
91f17fc92a | ||
|
44acf70681 | ||
|
a381c373b3 | ||
|
bc3ce7ec2a | ||
|
17b4baedec | ||
|
28df9fa4f7 | ||
|
5617dcf9d2 | ||
|
402f58e0ba | ||
|
2c6e9ce465 | ||
|
91813a3634 | ||
|
06ca7c102b | ||
|
5a6161162d | ||
|
5bb7f37139 | ||
|
033276a8cf | ||
|
5c6a45ec94 | ||
|
e6faed3477 | ||
|
658573678b | ||
|
a3b9b8c4ff | ||
|
715ad9bd6b | ||
|
40e816e379 | ||
|
50142efd1b | ||
|
499bd43c37 | ||
|
b7c412d4d0 | ||
|
44c818cebd | ||
|
2cbf4acdde | ||
|
a74184e1b0 | ||
|
5d924cba64 | ||
|
99a5549044 | ||
|
22a30186a5 | ||
|
468b03a644 | ||
|
91531e4604 | ||
|
1feafbe5b6 | ||
|
af0cd8ec3d | ||
|
0fa18d4c5d | ||
|
b49a25d33c | ||
|
f211618f20 | ||
|
cb4ddbe4e7 | ||
|
d2f6d909e5 | ||
|
d449f79a0d | ||
|
5f8c3f532e | ||
|
bc55e94795 | ||
|
26c5b1bca6 | ||
|
dafb386524 | ||
|
a52d39dafb | ||
|
5cb4c0d5bd | ||
|
5c6c82db19 | ||
|
a3f83c15f4 | ||
|
bf4faba9ca | ||
|
9b6df5c91c | ||
|
3056afac2d | ||
|
310f17203d | ||
|
42884868a3 | ||
|
8d9d4cc1ee | ||
|
c8f7ba36ce | ||
|
b7ca2eb2f6 | ||
|
fdb4fee433 | ||
|
2fadf88068 | ||
|
f452d3c4df | ||
|
57e4a974f7 | ||
|
cb9b87eb1c | ||
|
5ec90c7155 | ||
|
17dee65651 | ||
|
65c002ca18 | ||
95d55e9ea2 | |||
a31f09001f | |||
6b0a76373c | |||
0e94b7b7f8 | |||
e37a7ab9ec | |||
aa228207a0 | |||
|
b6b53143fc | ||
|
fcbc67edf0 | ||
|
d799554259 | ||
|
d77bdb432a | ||
|
12db590e8f | ||
|
310e36d19c | ||
|
36fa51d996 | ||
|
3d4c1b8af2 | ||
|
0ecfca3b9d | ||
|
e2a2dc35c2 | ||
|
31cefb9ac1 | ||
|
ebee8d9372 | ||
|
5faa0697dd | ||
|
236a8e48b6 | ||
|
286757ab29 | ||
|
f6f8b2563c | ||
|
9d4736a3e9 | ||
|
25d0cfa5a8 | ||
8ead0f9219 | |||
22ee4c71a3 | |||
|
ebc7ec7c1c | ||
|
60ca6354bd | ||
|
32488257ee | ||
|
7a205fb21c | ||
|
bd6de61c52 | ||
|
ceb3f9174b | ||
|
4ada0d313a | ||
|
2887879173 | ||
|
d0b8ffb952 | ||
|
aa714fa51a | ||
|
03809dbb4d | ||
|
101b935de4 | ||
|
8e9fc6f6e6 | ||
|
877491b4ad | ||
|
d9514becc4 | ||
|
4e58d2edf7 | ||
|
76c3a4dbed | ||
|
3ec25cc00f | ||
|
9d245de6e6 | ||
|
844be5d556 | ||
|
7d0be731c9 | ||
|
270da70532 | ||
|
91c807d275 | ||
|
ece7db113a | ||
|
488786d3e4 | ||
|
dd883aaf09 | ||
|
055ce4bd19 | ||
|
b14d34d2c7 | ||
|
e813f97b1a | ||
|
a89f423dfa | ||
|
fd08b50a80 | ||
8732a436eb | |||
|
d12b2a08c8 | ||
|
f8623666aa | ||
|
b7fdbfa789 | ||
|
94c1756562 | ||
|
d65342e304 | ||
|
0926d26390 | ||
|
88d38cb290 | ||
|
ae7c954b12 | ||
|
6bc7afab91 | ||
|
fc2beb8c6a | ||
|
fd7d84616d | ||
|
ebd650c06c | ||
|
e648f62b4f | ||
|
1a2c5ddeb5 | ||
|
2e6314af31 | ||
|
d8acf85504 | ||
|
1a47014f99 | ||
|
6a65bc5798 | ||
|
f39580c6fd | ||
|
efd3a6405a | ||
|
d637976329 | ||
|
40ec173e68 | ||
|
12266213d0 | ||
|
cfcc38192a | ||
|
fab0f4e576 | ||
|
72cfe44436 | ||
|
919c980344 | ||
|
1f40141a69 | ||
|
710291b164 | ||
|
a93b620a35 | ||
|
839566500c | ||
|
e9619cc4f8 | ||
|
9f32160c32 | ||
|
0a496258af | ||
|
b14dfc23dd | ||
|
66e74d50d3 | ||
|
ba6245670d | ||
|
78865393a6 | ||
|
ecccb14e2a | ||
|
80b15f5d2d | ||
|
a01437f9e1 | ||
|
6770135b0a | ||
|
6f2c02a0ce | ||
|
be9ddff2fb | ||
|
d14555a3df | ||
|
c8d96b4729 | ||
|
b3e39bf57e | ||
|
f5bdd8edc8 | ||
|
df8becac4f | ||
|
e3a3cc0da8 | ||
|
1a216faecb | ||
|
2f69b6d04e | ||
|
2f65b3dd53 | ||
|
62a8b0ff6e | ||
|
60ed9b8f41 | ||
|
22a30602cb | ||
|
02ff7897c0 | ||
|
722caa2090 | ||
|
9c46b347b8 | ||
|
b3e1f92e9c | ||
|
bfe4f536bb | ||
|
3512f44617 | ||
|
7c670b39a2 | ||
|
601faf9751 | ||
|
a59bbf50d7 | ||
|
8c322aaba1 | ||
|
e4325b1517 | ||
|
0cba184cf8 | ||
|
b5c33d702a | ||
|
dfc2c3353d | ||
|
6aae4bd0a1 | ||
|
fc1c1b8869 | ||
|
d691604080 | ||
|
ceb738c591 | ||
|
27e21231e1 | ||
|
9ec0a61156 | ||
|
96a1c79456 | ||
|
206c45bd05 | ||
|
a3130d2c2f | ||
|
ea71536ed7 | ||
|
87d019986e | ||
|
c5f5365ae6 | ||
|
341527bae9 | ||
|
a74f52a8d2 | ||
|
5a0045be79 | ||
|
dc37a482ed | ||
|
e4ed3a46b7 | ||
|
2e07e77573 | ||
|
fc27141cf6 | ||
|
e8cef3ac43 | ||
|
5941c4216d | ||
|
a544b113f2 | ||
|
94c035ff71 | ||
|
cc8f572bc0 | ||
|
c27f2f36f2 | ||
|
de901736a6 | ||
|
f32ec5fb3c | ||
|
c4ac70acbb | ||
|
958fc15fec | ||
|
2a1292359b | ||
|
2c049b673a | ||
|
d3fee4f40b | ||
|
1ea88ae7d1 | ||
|
9d7a02afce | ||
|
7c2460c856 | ||
|
d600040ebc | ||
|
92ccaea470 | ||
|
da95579da7 | ||
|
87fc4fedd6 | ||
|
5e58e6e2cf | ||
|
ebc6dc3e8d | ||
|
2fa2f57fd5 | ||
|
c4925c1f66 | ||
|
ecda1e0ace | ||
|
57c628e195 | ||
|
154682d9a2 | ||
|
d1b69e6af1 | ||
|
c7fcea1347 | ||
|
f320efd689 | ||
|
23daed648c | ||
|
61f63d1086 | ||
|
e62486dd6a | ||
|
7f876cf62b | ||
|
28439d63b8 | ||
|
43ad7c8e82 | ||
|
4758c18164 | ||
|
8911863019 | ||
|
b4ff0afb24 | ||
|
1fdb40fc16 | ||
|
3b67b22225 | ||
|
f09e1e318e | ||
|
f5568f87b1 | ||
|
22760c5422 | ||
|
29712e905b | ||
|
1093ddeea2 | ||
|
fcd900f7ce | ||
|
9ff77362fe | ||
|
c71c7e2bd4 | ||
|
eaed9ce88e | ||
|
ce6bdab3a0 | ||
|
64d748bbe4 | ||
|
f210d94f71 | ||
|
fccfa09df8 | ||
|
3a6f91a9cc | ||
|
84d0632a2d | ||
|
57ee53d6c6 | ||
|
6d074b4c90 | ||
|
96f70777f7 | ||
|
2fee2f425d | ||
|
0b51ea7251 | ||
|
a5fbc689f8 | ||
|
b80315e238 | ||
|
70cb883547 | ||
|
5b25a9635f | ||
|
8bf94136e1 | ||
|
366247c84c | ||
|
f47aa40c12 | ||
|
2f9c1ecf88 | ||
|
a6192bdd52 | ||
|
081a73486f | ||
|
12e1877917 | ||
|
0433c688b9 | ||
|
460ce50115 | ||
|
53ed6d7d1e | ||
|
d5d22a6d07 | ||
|
8eb6bac137 | ||
|
49cc8e5d74 | ||
|
88535f20a9 | ||
|
9bf82f4fa1 | ||
|
d99aef535f | ||
|
4c970f099f | ||
|
5c6fac0bfc | ||
|
ac94db1e36 | ||
|
807cade64c | ||
|
268309df5d | ||
|
96b12e55f0 | ||
|
e2bcb9d915 | ||
|
d6aec9e60b | ||
|
acadb0801f | ||
|
3e395a6bc6 | ||
|
b2e2aa42e2 | ||
|
7d86f40bdb | ||
|
bb8cf6d362 | ||
|
81a91579ba | ||
|
82e3bb5f06 | ||
|
aa4797ba36 | ||
|
bc988eb042 | ||
|
5893529cf4 | ||
|
986a521067 | ||
|
8b27824338 | ||
|
86ceea054f | ||
|
7481dd6e66 | ||
|
6ccd0b218f | ||
|
61c15aa555 | ||
|
3aa7aa0ef0 | ||
|
6b32719faf | ||
|
105fb7a2a0 | ||
|
033cf2a1e5 | ||
|
845810a8d3 | ||
|
bf82dcfeb3 | ||
|
c318096c7a | ||
|
638364987a | ||
|
29a837f347 | ||
|
fcf141926d | ||
|
bf67ac345f | ||
|
54c8e8d299 | ||
|
510f723300 | ||
|
b230bf207d | ||
|
f501caed21 | ||
|
a9fe41a818 | ||
|
3108f2a800 | ||
|
5177d8e574 | ||
|
22ff7414e9 | ||
|
73c8962654 | ||
|
28794b69cb | ||
|
b4189928f8 | ||
|
eb5548e853 | ||
|
b86aeccf6a | ||
|
6bc90f50af | ||
|
57ce6dae5d | ||
|
813849e521 | ||
|
a4af59f471 | ||
|
61d406933e | ||
|
b8fcb7d440 | ||
|
0843848c1d | ||
|
77de24876f | ||
|
cf9181dddf | ||
|
eb6e729808 | ||
|
f9ff7f1b69 | ||
|
6177f3206e | ||
|
a271c5f34f | ||
|
4c7045229c | ||
|
0934f795d3 | ||
|
504c9df4e7 | ||
|
6a19124a09 | ||
|
c8cb5c2824 | ||
|
bc5f1597eb | ||
|
49bc541d79 | ||
|
a07373135e | ||
|
ae3aa96898 | ||
|
2109bb9d9a | ||
|
b68134ac1d | ||
|
84315a3bad | ||
|
d9d49b687a | ||
|
8e33d0e767 | ||
|
4b385d6c34 | ||
|
e96be632f5 | ||
|
f9c179ce07 | ||
|
935222b8b2 | ||
|
81bd104daa | ||
|
b64d83cd2b | ||
|
0040058da6 | ||
|
68016e3798 | ||
|
b320c860f5 | ||
|
06cb3300f3 | ||
|
0b04f7970c | ||
|
ad5c757066 | ||
|
bae74cb292 | ||
|
4296080c8e | ||
|
76be95e199 | ||
|
b712ab0ac4 | ||
|
c843794786 | ||
|
5e4a83bb98 | ||
|
dcc85ea619 | ||
|
64c34078e4 | ||
|
36eb71fb48 | ||
|
d16ca2e22f | ||
|
4306b184c4 | ||
|
b2306b0c78 | ||
|
1c070fa812 | ||
|
c4c655d9bf | ||
|
b786c8836f | ||
|
4bad4bde5a | ||
|
643b631281 | ||
|
5c0e68c071 | ||
|
c6a4c2882d | ||
|
bbc6b844c1 | ||
|
b897c81f8c | ||
|
8294195677 | ||
|
7fc5f07492 | ||
|
89284d554d | ||
|
6e0a1310a2 | ||
|
f8a8973f42 | ||
|
7cea7c7a9a | ||
|
15162272f4 | ||
|
5eca4ea11e | ||
|
b9b41d25e3 | ||
|
cffc5ce3d1 | ||
|
3252fbb3cc | ||
|
196808e14f | ||
|
c4f8360240 | ||
|
c904bccce1 | ||
|
6fcb443787 | ||
|
2ea89aba4f | ||
|
1739d6b314 | ||
|
792b26789f | ||
|
fe83d45c46 | ||
|
87e9631e4a | ||
|
c31f3ce048 | ||
|
48417aed1d | ||
|
844021ab4c | ||
|
16a2ef6b5a | ||
|
07b57cfef9 | ||
|
5ba55f0cf2 | ||
|
9c5daad19c | ||
|
1e38df6606 | ||
|
2008098574 | ||
|
5b750090c7 | ||
|
e6cd773f5a | ||
|
7c26f6626a | ||
|
2dd698fd79 | ||
|
db0caba3c4 | ||
|
03b9cb600a | ||
|
9971f7c82c | ||
|
36c8fb1ef8 | ||
|
6c7d686454 | ||
|
52b503105d | ||
|
1255a42cfe | ||
|
236239be75 | ||
|
cc30d749ab | ||
|
3c66e56435 | ||
|
94cddf7be4 | ||
|
8868f3ca46 | ||
|
6b7ddb4871 | ||
|
9ff17ae20e | ||
|
74198dcdf6 | ||
|
e037610115 | ||
|
5978588578 | ||
|
7dcbfde5ff | ||
|
7040169069 | ||
|
430ff07102 | ||
|
246d72e143 | ||
|
147f4df843 | ||
|
402b034648 | ||
|
009ce93581 | ||
|
9e5a37cc7f | ||
|
b848615c52 | ||
|
b9eb50c099 | ||
|
16e3d006e9 | ||
|
65e56cbe49 | ||
|
5f3f19f1c0 | ||
|
c03455af11 | ||
|
6f566b69ba | ||
|
03cadbe220 | ||
|
90d4ed9850 | ||
|
63bddaad4b | ||
|
faf8bdda50 | ||
|
848939b7ba | ||
|
c37a6f820b | ||
|
89dcbe5277 | ||
|
679b6b40db | ||
|
078fcb56ad | ||
|
7f96d9ec21 | ||
|
b87442f216 | ||
|
95e5add4be | ||
|
d029cf67f8 | ||
|
ae7f6b9d09 | ||
|
fcb0d86b90 | ||
|
4d9fd16ddf | ||
|
514185e14b | ||
|
d7d240f136 | ||
|
36dcf997e4 | ||
|
3947bbb043 | ||
|
8bbf02e0db | ||
|
053673cb71 | ||
|
d6327389fc | ||
|
9977c418d6 | ||
|
5fbab3edb3 | ||
|
c3b00963fe | ||
|
63ef549749 | ||
|
37ab940599 | ||
|
7282418baa | ||
|
3ee5c69777 | ||
|
ee6f4b64a9 | ||
|
066333c03c | ||
|
7418f7cb60 | ||
|
93b1fe54fb | ||
|
724f1e3229 | ||
|
01c6e32a02 | ||
|
04a0e8ea34 | ||
|
a0df06517b | ||
|
69173356cd | ||
|
383cbaf2c5 | ||
adc4aee80f | |||
|
7a9daac56d | ||
|
fc11ab2f29 | ||
|
061f04fba0 | ||
|
2d3a9be73d | ||
|
eaf2524887 | ||
|
67a1d5d823 | ||
|
f3ca027d3a | ||
|
e3a430babb | ||
|
8e4be77368 | ||
|
436479c58f | ||
|
64497fb302 | ||
|
af815f771c | ||
|
b3051a6aae | ||
|
465b1c4090 | ||
|
e0177549c7 | ||
|
db90dfcbbc | ||
|
085efc33cc | ||
|
80677a23e2 | ||
|
e350aa750f | ||
|
f4ebd5fed2 | ||
|
e3ffc8fdee | ||
|
1022c59df8 | ||
|
455f776a3d | ||
|
5036cffade | ||
|
00be9941f3 | ||
|
c0d96ce590 | ||
|
826c6af7c0 | ||
|
4cfe8d3303 | ||
|
18e6796726 | ||
|
423ab02846 | ||
|
cbfdcff261 | ||
|
624f999fb7 | ||
|
ab18fe06d6 | ||
|
3cc8c89830 | ||
|
75f7adee19 | ||
|
00670b2071 | ||
|
622d7864e9 | ||
|
6c1be5bcec | ||
|
9cf93bcfc5 | ||
|
a0d079141e | ||
|
d592daea83 | ||
|
c3dfd89959 | ||
|
01878a75b9 | ||
|
1dc37f9cf8 | ||
|
29d108820f | ||
|
ba0fd80732 | ||
da3abc7591 | |||
645557480c | |||
e68e261882 | |||
0605188d4e | |||
12180ef604 | |||
9a3783ed20 | |||
86894a7261 | |||
6658463e9e | |||
e9cda12d81 | |||
c29027dd1a | |||
9a49486707 | |||
5ac350359c | |||
34b2678fd9 | |||
118f40d21e | |||
a076447c7c | |||
|
35cc3582f0 | ||
b7b0894720 | |||
ad03ee9f5f | |||
|
20d859380a | ||
|
167066f4bb | ||
|
a1c3e68e7a | ||
f981ab8a17 | |||
042095ae7a | |||
72944a7234 | |||
8b2d826eb1 | |||
c6a6780a89 | |||
cfc25f73e7 | |||
7f51ca3b37 | |||
0db6fbd935 | |||
b7538c4a1b | |||
4a4046e25c | |||
51f119e569 | |||
927e67a6a9 | |||
1c0853cd04 | |||
1edbcb4f38 | |||
00c968f830 | |||
cef0816903 | |||
|
d1be6766f3 | ||
|
31fc9518a3 | ||
1f3a7f78a0 | |||
17699d82dc | |||
e89182bacf | |||
b6d9d9b37a | |||
d02bf19916 | |||
7bae9485fd | |||
|
4c6847df8b | ||
132acc4e95 | |||
3c20b1071e | |||
f89abe02e8 | |||
0b7c7fc069 | |||
88f2ebadca | |||
62c55f9c8c | |||
c5eeb78c3c | |||
8afd5016af | |||
b74a59fd49 | |||
|
e267bf2772 | ||
|
194cd1cd16 | ||
01342dd5a4 | |||
1d1bc6ebe3 | |||
799c92d786 | |||
d541bf3514 | |||
a044da320e | |||
d15d267369 | |||
7708afcc78 | |||
b741e823c7 | |||
7d0661086e | |||
ff50f9c69e | |||
9047fb2fd5 | |||
1f28497fff | |||
cd635a0857 | |||
1f0cf0c35e | |||
|
17c45348fe | ||
5bc8d56ae8 | |||
75d92a11f6 | |||
3a6dad3bc4 | |||
b69f64e3fa | |||
8a93529664 | |||
3737a64ff3 | |||
2e05b32a32 | |||
33502774af | |||
7f8cde80f7 | |||
09fb269e37 | |||
|
ade1b1365b | ||
|
83a86d2d39 | ||
|
1687756741 | ||
|
3e361b0394 | ||
|
f2391a5793 | ||
27c05d1885 | |||
a90f15a5c2 | |||
|
e76cb042fa | ||
6f4fd910a9 | |||
086adcd4a9 | |||
f0bec991d0 | |||
268682859a | |||
f457180534 | |||
f945bb9b05 | |||
bb2103a4a2 | |||
|
0396d42bba | ||
|
020df22d1e | ||
921e7020c9 | |||
f1a8ca9cc8 | |||
4123658eae | |||
8e2b06fd78 | |||
b55c95e172 | |||
5f9bf1b96e | |||
a9821c6fcd | |||
7a8068a787 | |||
06d803e78e | |||
e8633c6bbe | |||
f80a51a9bf | |||
3c49d5a29a | |||
040eb18ec8 | |||
55ade1a969 | |||
6cb22b63c5 | |||
6f5933d23f | |||
1f9886dc51 | |||
bd5d37d0ba | |||
|
b607a4c503 | ||
|
fb57aab74d | ||
7e929771eb | |||
ba36c44c5c | |||
64f4f387a0 | |||
588c4e4be4 | |||
256545b392 | |||
12e74779c4 | |||
c26d0303ee | |||
c2d24b7827 | |||
5b82e59b33 | |||
|
1db7867707 | ||
|
6888cc6f90 | ||
ed5a80e732 | |||
01f59ddf63 | |||
4c02d06d57 | |||
9550bfe181 | |||
dc420191df | |||
b28f3129ef | |||
e4501035c3 | |||
c98a5f2205 | |||
8f2c12ce6a |
753 changed files with 90173 additions and 24313 deletions
12
.dev/.eslintrc.js
Normal file
12
.dev/.eslintrc.js
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
module.exports = {
|
||||||
|
"extends": "airbnb-base",
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-param-reassign": 0, // manipulate DOM style properties
|
||||||
|
"no-restricted-globals": 0, // currently Shaarli uses alert/confirm, could be be improved later
|
||||||
|
"no-alert": 0, // currently Shaarli uses alert/confirm, could be be improved later
|
||||||
|
"no-cond-assign": [2, "except-parens"], // assignment in while loops is readable and avoid assignment duplication
|
||||||
|
}
|
||||||
|
};
|
15
.dev/.stylelintrc.js
Normal file
15
.dev/.stylelintrc.js
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
module.exports = {
|
||||||
|
extends: 'stylelint-config-standard',
|
||||||
|
plugins: [
|
||||||
|
"stylelint-scss"
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
"indentation": [2],
|
||||||
|
"number-leading-zero": null,
|
||||||
|
// Replace CSS @ with SASS ones
|
||||||
|
"at-rule-no-unknown": null,
|
||||||
|
"scss/at-rule-no-unknown": true,
|
||||||
|
// not compatible with SASS apparently
|
||||||
|
"no-descending-specificity": null
|
||||||
|
},
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
user www-data www-data;
|
user nginx nginx;
|
||||||
daemon off;
|
daemon off;
|
||||||
worker_processes 4;
|
worker_processes 4;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
events {
|
events {
|
||||||
worker_connections 768;
|
worker_connections 768;
|
||||||
|
@ -17,26 +18,13 @@ http {
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
root /var/www/shaarli;
|
root /var/www/shaarli;
|
||||||
|
|
||||||
access_log /var/log/nginx/shaarli.access.log;
|
access_log /var/log/nginx/shaarli.access.log;
|
||||||
error_log /var/log/nginx/shaarli.error.log;
|
error_log /var/log/nginx/shaarli.error.log;
|
||||||
|
|
||||||
location ~ /\. {
|
location ~* \.(?:ico|css|js|gif|jpe?g|png|ttf|oet|woff2?)$ {
|
||||||
# deny access to dotfiles
|
|
||||||
access_log off;
|
|
||||||
log_not_found off;
|
|
||||||
deny all;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ~$ {
|
|
||||||
# deny access to temp editor files, e.g. "script.php~"
|
|
||||||
access_log off;
|
|
||||||
log_not_found off;
|
|
||||||
deny all;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~* \.(?:ico|css|js|gif|jpe?g|png)$ {
|
|
||||||
# cache static assets
|
# cache static assets
|
||||||
expires max;
|
expires max;
|
||||||
add_header Pragma public;
|
add_header Pragma public;
|
||||||
|
@ -48,16 +36,25 @@ http {
|
||||||
alias /var/www/shaarli/images/favicon.ico;
|
alias /var/www/shaarli/images/favicon.ico;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ (index)\.php$ {
|
location /doc/html/ {
|
||||||
|
default_type "text/html";
|
||||||
|
try_files $uri $uri/ $uri.html =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
# Slim - rewrite URLs & do NOT serve static files through this location
|
||||||
|
try_files _ /index.php$is_args$args;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ index\.php$ {
|
||||||
|
# Slim - split URL path into (script_filename, path_info)
|
||||||
|
try_files $uri =404;
|
||||||
|
fastcgi_split_path_info ^(index.php)(/.+)$;
|
||||||
|
|
||||||
# filter and proxy PHP requests to PHP-FPM
|
# filter and proxy PHP requests to PHP-FPM
|
||||||
fastcgi_pass unix:/var/run/php5-fpm.sock;
|
fastcgi_pass unix:/var/run/php-fpm.sock;
|
||||||
fastcgi_index index.php;
|
fastcgi_index index.php;
|
||||||
include fastcgi.conf;
|
include fastcgi.conf;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ \.php$ {
|
|
||||||
# deny access to all other PHP scripts
|
|
||||||
deny all;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
16
.docker/php-fpm.conf
Normal file
16
.docker/php-fpm.conf
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
[global]
|
||||||
|
daemonize = no
|
||||||
|
|
||||||
|
[www]
|
||||||
|
user = nginx
|
||||||
|
group = nginx
|
||||||
|
listen.owner = nginx
|
||||||
|
listen.group = nginx
|
||||||
|
catch_workers_output = yes
|
||||||
|
listen = /var/run/php-fpm.sock
|
||||||
|
pm = dynamic
|
||||||
|
pm.max_children = 20
|
||||||
|
pm.start_servers = 1
|
||||||
|
pm.min_spare_servers = 1
|
||||||
|
pm.max_spare_servers = 3
|
||||||
|
pm.max_requests = 2048
|
2
.docker/services.d/.s6-svscan/finish
Executable file
2
.docker/services.d/.s6-svscan/finish
Executable file
|
@ -0,0 +1,2 @@
|
||||||
|
#!/bin/sh
|
||||||
|
/bin/true
|
2
.docker/services.d/nginx/run
Executable file
2
.docker/services.d/nginx/run
Executable file
|
@ -0,0 +1,2 @@
|
||||||
|
#!/bin/execlineb -P
|
||||||
|
nginx
|
2
.docker/services.d/php-fpm/run
Executable file
2
.docker/services.d/php-fpm/run
Executable file
|
@ -0,0 +1,2 @@
|
||||||
|
#!/bin/execlineb -P
|
||||||
|
php-fpm8 -F
|
64
.dockerignore
Normal file
64
.dockerignore
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
# Docker-ignore
|
||||||
|
.dev
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.gitattributes
|
||||||
|
.gitignore
|
||||||
|
tests
|
||||||
|
|
||||||
|
# Docker related resources are not needed inside the container
|
||||||
|
.dockerignore
|
||||||
|
Dockerfile
|
||||||
|
Dockerfile.armhf
|
||||||
|
|
||||||
|
# Docker Compose resources
|
||||||
|
docker-compose.yml
|
||||||
|
|
||||||
|
# Shaarli runtime resources
|
||||||
|
cache/*
|
||||||
|
data/*
|
||||||
|
pagecache/*
|
||||||
|
tmp/*
|
||||||
|
|
||||||
|
# Shaarli's docs are created during the build
|
||||||
|
doc/html/
|
||||||
|
|
||||||
|
# Eclipse project files
|
||||||
|
.settings
|
||||||
|
.buildpath
|
||||||
|
.project
|
||||||
|
|
||||||
|
# Raintpl generated pages
|
||||||
|
*.rtpl.php
|
||||||
|
|
||||||
|
# 3rd-party dependencies
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Release archives
|
||||||
|
*.tar.gz
|
||||||
|
*.zip
|
||||||
|
inc/languages/*/LC_MESSAGES/shaarli.mo
|
||||||
|
|
||||||
|
# Development and test resources
|
||||||
|
coverage
|
||||||
|
doxygen
|
||||||
|
sandbox
|
||||||
|
phpmd.html
|
||||||
|
|
||||||
|
# User plugin configuration
|
||||||
|
plugins/*/config.php
|
||||||
|
|
||||||
|
# 3rd party themes
|
||||||
|
tpl/*
|
||||||
|
!tpl/default
|
||||||
|
!tpl/vintage
|
||||||
|
|
||||||
|
# Front end
|
||||||
|
node_modules
|
||||||
|
tpl/default/js
|
||||||
|
tpl/default/css
|
||||||
|
tpl/default/fonts
|
||||||
|
tpl/default/img
|
||||||
|
tpl/vintage/js
|
||||||
|
tpl/vintage/css
|
||||||
|
tpl/vintage/img
|
23
.editorconfig
Normal file
23
.editorconfig
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# EditorConfig: http://EditorConfig.org
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{htaccess,html,scss,js,json,xml,yml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.php]
|
||||||
|
max_line_length = 120
|
||||||
|
|
||||||
|
[Dockerfile]
|
||||||
|
max_line_length = 80
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
21
.gitattributes
vendored
21
.gitattributes
vendored
|
@ -10,21 +10,38 @@
|
||||||
*.php text diff=php
|
*.php text diff=php
|
||||||
Dockerfile text
|
Dockerfile text
|
||||||
|
|
||||||
# Do not alter images nor minified scripts
|
# Do not alter images nor minified scripts nor fonts
|
||||||
*.ico binary
|
*.ico binary
|
||||||
*.jpg binary
|
*.jpg binary
|
||||||
*.png binary
|
*.png binary
|
||||||
|
*.svg binary
|
||||||
|
*.otf binary
|
||||||
|
*.eot binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
*.min.css binary
|
*.min.css binary
|
||||||
*.min.js binary
|
*.min.js binary
|
||||||
|
*.mo binary
|
||||||
|
|
||||||
# Exclude from Git archives
|
# Exclude from Git archives
|
||||||
|
.editorconfig export-ignore
|
||||||
|
.dev export-ignore
|
||||||
.gitattributes export-ignore
|
.gitattributes export-ignore
|
||||||
|
.github export-ignore
|
||||||
.gitignore export-ignore
|
.gitignore export-ignore
|
||||||
.travis.yml export-ignore
|
.travis.yml export-ignore
|
||||||
doc/**/*.json export-ignore
|
doc/**/*.json export-ignore
|
||||||
doc/**/*.md export-ignore
|
doc/**/*.md export-ignore
|
||||||
docker/ export-ignore
|
.docker/ export-ignore
|
||||||
|
.dockerignore export-ignore
|
||||||
|
docker-compose.* export-ignore
|
||||||
|
Dockerfile* export-ignore
|
||||||
Doxyfile export-ignore
|
Doxyfile export-ignore
|
||||||
Makefile export-ignore
|
Makefile export-ignore
|
||||||
|
node_modules/ export-ignore
|
||||||
|
doc/conf.py export-ignore
|
||||||
|
doc/requirements.txt export-ignore
|
||||||
|
doc/html/.doctrees/ export-ignore
|
||||||
phpunit.xml export-ignore
|
phpunit.xml export-ignore
|
||||||
tests/ export-ignore
|
tests/ export-ignore
|
||||||
|
|
22
.github/mailmap
vendored
Normal file
22
.github/mailmap
vendored
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
ArthurHoaro <arthur@hoa.ro> <arthur.hoareau@wizacha.com>
|
||||||
|
ArthurHoaro <arthur@hoa.ro> Arthur
|
||||||
|
Florian Eula <eula.florian@gmail.com> feula
|
||||||
|
Florian Eula <eula.florian@gmail.com> <mr.pikzen@gmail.com>
|
||||||
|
Immánuel Fodor <immanuelfactor+github@gmail.com>
|
||||||
|
Immánuel Fodor <immanuelfactor+github@gmail.com> Immánuel! <21174107+immanuelfodor@users.noreply.github.com>
|
||||||
|
kalvn <kalvnthereal@gmail.com> <kalvn@users.noreply.github.com>
|
||||||
|
kalvn <kalvnthereal@gmail.com> <kalvn@pm.me>
|
||||||
|
Neros <contact@neros.fr> <NerosTie@users.noreply.github.com>
|
||||||
|
Nicolas Danelon <hi@nicolasmd.com.ar> nicolasm
|
||||||
|
Nicolas Danelon <hi@nicolasmd.com.ar> <nda@3818.com.ar>
|
||||||
|
Nicolas Danelon <hi@nicolasmd.com.ar> <nicolasdanelon@gmail.com>
|
||||||
|
Nicolas Danelon <hi@nicolasmd.com.ar> <nicolasdanelon@users.noreply.github.com>
|
||||||
|
Sébastien Sauvage <sebsauvage@sebsauvage.net>
|
||||||
|
Sébastien NOBILI <code@pipoprods.org> <s-code-github@pipoprods.org>
|
||||||
|
Timo Van Neerden <fire@lehollandaisvolant.net>
|
||||||
|
Timo Van Neerden <fire@lehollandaisvolant.net> lehollandaisvolant <levoltigeurhollandais@gmail.com>
|
||||||
|
VirtualTam <virtualtam@flibidi.net> <tamisier.aurelien@gmail.com>
|
||||||
|
VirtualTam <virtualtam@flibidi.net> <virtualtam+github@flibidi.net>
|
||||||
|
VirtualTam <virtualtam@flibidi.net> <virtualtam@flibidi.org>
|
||||||
|
Willi Eggeling <thewilli@gmail.com> <mail@wje-online.de>
|
||||||
|
Willi Eggeling <thewilli@gmail.com> <thewilli@users.noreply.github.com>
|
106
.github/workflows/ci.yml
vendored
Normal file
106
.github/workflows/ci.yml
vendored
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
name: Shaarli CI
|
||||||
|
on: [push, pull_request]
|
||||||
|
jobs:
|
||||||
|
php:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
php-versions: ['7.4', '8.0', '8.1', '8.2']
|
||||||
|
name: PHP ${{ matrix.php-versions }}
|
||||||
|
steps:
|
||||||
|
- name: Set locales
|
||||||
|
run: |
|
||||||
|
sudo locale-gen de_DE.utf8 && \
|
||||||
|
sudo locale-gen en_US.utf8 && \
|
||||||
|
sudo locale-gen fr_FR.utf8 && \
|
||||||
|
sudo dpkg-reconfigure --frontend=noninteractive locales
|
||||||
|
|
||||||
|
- name: Install Gettext
|
||||||
|
run: sudo apt-get install gettext
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: ${{ matrix.php-versions }}
|
||||||
|
extensions: gd, xml, curl, mbstring, intl, gettext
|
||||||
|
tools: composer:v2
|
||||||
|
|
||||||
|
- name: Check PHP version
|
||||||
|
run: php -v
|
||||||
|
|
||||||
|
- name: Setup Composer from PHP version + update
|
||||||
|
run: composer config --unset platform && composer config platform.php ${{ matrix.php-versions }}
|
||||||
|
|
||||||
|
- name: Update dependencies for PHP 8.x
|
||||||
|
if: ${{ matrix.php-versions == '8.0' || matrix.php-versions == '8.1' }}
|
||||||
|
run: |
|
||||||
|
composer update && \
|
||||||
|
composer remove --dev phpunit/phpunit && \
|
||||||
|
composer require --dev phpunit/php-text-template ^2.0 && \
|
||||||
|
composer require --dev phpunit/phpunit ^9.0
|
||||||
|
|
||||||
|
- name: Update dependencies for PHP 7.x
|
||||||
|
if: ${{ matrix.php-versions != '8.0' && matrix.php-versions != '8.1' }}
|
||||||
|
run: composer update
|
||||||
|
|
||||||
|
- name: Clean up
|
||||||
|
run: make clean
|
||||||
|
|
||||||
|
- name: Check permissions
|
||||||
|
run: make check_permissions
|
||||||
|
|
||||||
|
- name: Run PHPCS
|
||||||
|
run: make code_sniffer
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: make all_tests
|
||||||
|
|
||||||
|
node:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: '14.x'
|
||||||
|
|
||||||
|
- name: Yarn install
|
||||||
|
run: yarnpkg install
|
||||||
|
|
||||||
|
- name: Verify successful frontend builds
|
||||||
|
run: yarnpkg run build
|
||||||
|
|
||||||
|
- name: JS static analysis
|
||||||
|
run: make eslint
|
||||||
|
|
||||||
|
- name: Linter for SASS syntax
|
||||||
|
run: make sasslint
|
||||||
|
|
||||||
|
python:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: 3.8
|
||||||
|
|
||||||
|
- name: Build documentation
|
||||||
|
run: make htmldoc
|
||||||
|
|
||||||
|
trivy-repo:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Run trivy scanner on repository (non-blocking)
|
||||||
|
run: make test_trivy_repo TRIVY_EXIT_CODE=0
|
45
.github/workflows/docker-latest.yml
vendored
Normal file
45
.github/workflows/docker-latest.yml
vendored
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
name: Build/push Docker image (master/latest)
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
jobs:
|
||||||
|
docker-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set shaarli version to the latest commit hash
|
||||||
|
run: sed -i "s/dev/$(git rev-parse --short HEAD)/" shaarli_version.php
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
id: docker_build
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||||
|
tags: |
|
||||||
|
${{ secrets.DOCKER_IMAGE }}:latest
|
||||||
|
ghcr.io/${{ secrets.DOCKER_IMAGE }}:latest
|
||||||
|
- name: Image digest
|
||||||
|
run: echo ${{ steps.docker_build.outputs.digest }}
|
||||||
|
- name: Run trivy scanner on latest docker image
|
||||||
|
run: make test_trivy_docker TRIVY_TARGET_DOCKER_IMAGE=ghcr.io/${{ secrets.DOCKER_IMAGE }}:latest
|
21
.github/workflows/docker-pr.yml
vendored
Normal file
21
.github/workflows/docker-pr.yml
vendored
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
name: Build Docker image (Pull Request)
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
- name: Build Docker image
|
||||||
|
id: docker_build
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
push: false
|
||||||
|
tags: shaarli/shaarli:pr-${{ github.event.number }}
|
||||||
|
- name: Image digest
|
||||||
|
run: echo ${{ steps.docker_build.outputs.digest }}
|
43
.github/workflows/docker-tags.yml
vendored
Normal file
43
.github/workflows/docker-tags.yml
vendored
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
name: Build/push Docker image (tags/releases)
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*.*.*"
|
||||||
|
branches:
|
||||||
|
- "v*.*"
|
||||||
|
- release
|
||||||
|
jobs:
|
||||||
|
docker-build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Get the tag name
|
||||||
|
run: echo "REF=${GITHUB_REF##*/}" >> $GITHUB_ENV
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
-
|
||||||
|
name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
id: docker_build
|
||||||
|
uses: docker/build-push-action@v3
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm/v7
|
||||||
|
tags: |
|
||||||
|
${{ secrets.DOCKER_IMAGE }}:${{ env.REF }}
|
||||||
|
ghcr.io/${{ secrets.DOCKER_IMAGE }}:${{ env.REF }}
|
||||||
|
- name: Image digest
|
||||||
|
run: echo ${{ steps.docker_build.outputs.digest }}
|
49
.gitignore
vendored
49
.gitignore
vendored
|
@ -13,18 +13,61 @@ pagecache
|
||||||
*.rtpl.php
|
*.rtpl.php
|
||||||
|
|
||||||
# 3rd-party dependencies
|
# 3rd-party dependencies
|
||||||
composer.lock
|
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
# Release archives
|
# Release archives
|
||||||
*.tar
|
*.tar.gz
|
||||||
*.zip
|
*.zip
|
||||||
|
inc/languages/*/LC_MESSAGES/shaarli.mo
|
||||||
|
|
||||||
# Development and test resources
|
# Development and test resources
|
||||||
coverage
|
coverage
|
||||||
doxygen
|
|
||||||
sandbox
|
sandbox
|
||||||
phpmd.html
|
phpmd.html
|
||||||
|
phpdoc.xml
|
||||||
|
.phpunit.result.cache
|
||||||
|
trivy
|
||||||
|
|
||||||
# User plugin configuration
|
# User plugin configuration
|
||||||
|
plugins/*
|
||||||
|
!addlink_toolbar
|
||||||
|
!archiveorg
|
||||||
|
!default_colors
|
||||||
|
!demo_plugin
|
||||||
|
!isso
|
||||||
|
!myShaarli
|
||||||
|
!piwik
|
||||||
|
!playvideos
|
||||||
|
!pubsubhubbub
|
||||||
|
!qrcode
|
||||||
|
!wallabag
|
||||||
plugins/*/config.php
|
plugins/*/config.php
|
||||||
|
plugins/default_colors/default_colors.css
|
||||||
|
|
||||||
|
# HTML documentation
|
||||||
|
doc/html/
|
||||||
|
doc/phpdoc/
|
||||||
|
|
||||||
|
# 3rd party themes
|
||||||
|
tpl/*
|
||||||
|
!tpl/default
|
||||||
|
!tpl/vintage
|
||||||
|
!tpl/myShaarli
|
||||||
|
|
||||||
|
contact.php
|
||||||
|
formStyle.css
|
||||||
|
|
||||||
|
# Front end
|
||||||
|
node_modules
|
||||||
|
tpl/default/js
|
||||||
|
tpl/default/css
|
||||||
|
tpl/default/fonts
|
||||||
|
tpl/default/img
|
||||||
|
tpl/vintage/js
|
||||||
|
tpl/vintage/css
|
||||||
|
tpl/vintage/img
|
||||||
|
|
||||||
|
.composer.lock
|
||||||
|
|
||||||
|
# Documented scripts
|
||||||
|
generate_templates.php
|
||||||
|
|
37
.htaccess
Normal file
37
.htaccess
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
# Disable directory listing
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Prevent accessing subdirectories not managed by SCM
|
||||||
|
RewriteRule ^(.git|doxygen|vendor) - [F]
|
||||||
|
|
||||||
|
# Forward the "Authorization" HTTP header
|
||||||
|
# fixes JWT token not correctly forwarded on some Apache/FastCGI setups
|
||||||
|
RewriteCond %{HTTP:Authorization} ^(.*)
|
||||||
|
RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]
|
||||||
|
# Alternative (if the 2 lines above don't work)
|
||||||
|
# SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0
|
||||||
|
|
||||||
|
# Slim URL Redirection
|
||||||
|
# Ionos Hosting needs RewriteBase /
|
||||||
|
# RewriteBase /
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule ^ index.php [QSA,L]
|
||||||
|
|
||||||
|
<LimitExcept GET POST PUT DELETE PATCH OPTIONS>
|
||||||
|
<IfModule version_module>
|
||||||
|
<IfVersion >= 2.4>
|
||||||
|
Require all denied
|
||||||
|
</IfVersion>
|
||||||
|
<IfVersion < 2.4>
|
||||||
|
Allow from none
|
||||||
|
Deny from all
|
||||||
|
</IfVersion>
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<IfModule !version_module>
|
||||||
|
Require all denied
|
||||||
|
</IfModule>
|
||||||
|
</LimitExcept>
|
23
.readthedocs.yml
Normal file
23
.readthedocs.yml
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# .readthedocs.yml
|
||||||
|
# Read the Docs configuration file
|
||||||
|
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||||
|
|
||||||
|
# Required
|
||||||
|
version: 2
|
||||||
|
|
||||||
|
# Build documentation in the "docs/" directory with Sphinx
|
||||||
|
sphinx:
|
||||||
|
configuration: doc/conf.py
|
||||||
|
builder: html
|
||||||
|
|
||||||
|
build:
|
||||||
|
os: ubuntu-22.04
|
||||||
|
tools:
|
||||||
|
python: "3.11"
|
||||||
|
commands:
|
||||||
|
- pip install sphinx==7.1.0 furo==2023.7.26 myst-parser sphinx-design
|
||||||
|
- sphinx-build -b html -c doc/ doc/md/ _readthedocs/html/
|
||||||
|
|
||||||
|
python:
|
||||||
|
install:
|
||||||
|
- requirements: doc/requirements.txt
|
18
.travis.yml
18
.travis.yml
|
@ -1,18 +0,0 @@
|
||||||
sudo: false
|
|
||||||
language: php
|
|
||||||
cache:
|
|
||||||
directories:
|
|
||||||
- $HOME/.composer/cache
|
|
||||||
php:
|
|
||||||
- 7.0
|
|
||||||
- 5.6
|
|
||||||
- 5.5
|
|
||||||
- 5.4
|
|
||||||
- 5.3
|
|
||||||
install:
|
|
||||||
- composer self-update
|
|
||||||
- composer install --prefer-dist
|
|
||||||
script:
|
|
||||||
- make clean
|
|
||||||
- make check_permissions
|
|
||||||
- make test
|
|
123
AUTHORS
Normal file
123
AUTHORS
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
1216 ArthurHoaro <arthur@hoa.ro>
|
||||||
|
456 nodiscc <nodiscc@gmail.com>
|
||||||
|
405 VirtualTam <virtualtam@flibidi.net>
|
||||||
|
56 Sébastien Sauvage <sebsauvage@sebsauvage.net>
|
||||||
|
27 dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
|
||||||
|
19 Keith Carangelo <mail@kcaran.com>
|
||||||
|
16 Luce Carević <lcarevic@access42.net>
|
||||||
|
15 Florian Eula <eula.florian@gmail.com>
|
||||||
|
14 Emilien Klein <emilien@klein.st>
|
||||||
|
12 Nicolas Danelon <hi@nicolasmd.com.ar>
|
||||||
|
9 Lucas Cimon <lucas.cimon@gmail.com>
|
||||||
|
9 Willi Eggeling <thewilli@gmail.com>
|
||||||
|
8 Christophe HENRY <christophe.henry@sbgodin.fr>
|
||||||
|
6 Immánuel Fodor <immanuelfactor+github@gmail.com>
|
||||||
|
6 YFdyh000 <yfdyh000@gmail.com>
|
||||||
|
6 kalvn <kalvnthereal@gmail.com>
|
||||||
|
6 B. van Berkum <dev@dotmpe.com>
|
||||||
|
6 Immánuel Fodor <immanuelfactor+github@gmail.com>
|
||||||
|
6 YFdyh000 <yfdyh000@gmail.com>
|
||||||
|
6 kalvn <kalvnthereal@gmail.com>
|
||||||
|
6 llune <llune@users.noreply.github.com>
|
||||||
|
5 Mark Schmitz <kramred@gmail.com>
|
||||||
|
5 Sébastien NOBILI <code@pipoprods.org>
|
||||||
|
4 Alexandre Alapetite <alexandre@alapetite.fr>
|
||||||
|
4 yude <yudesleepy@gmail.com>
|
||||||
|
4 David Sferruzza <david.sferruzza@gmail.com>
|
||||||
|
4 yude <yudesleepy@gmail.com>
|
||||||
|
3 Agurato <mail.vmonot@gmail.com>
|
||||||
|
3 Christoph Stoettner <christoph.stoettner@stoeps.de>
|
||||||
|
3 Olivier <bourreauolivier@gmail.com>
|
||||||
|
3 Teromene <teromene@teromene.fr>
|
||||||
|
3 yudete <yu@yude.moe>
|
||||||
|
2 Alexander Railean <alexandr.railean@arculus.de>
|
||||||
|
2 Alexandre G.-Raymond <alex@ndre.gr>
|
||||||
|
2 Chris Kuethe <chris.kuethe@gmail.com>
|
||||||
|
2 Doug Breaux <25640850+dougbreaux@users.noreply.github.com>
|
||||||
|
2 Felix Bartels <felix@host-consultants.de>
|
||||||
|
2 Ganesh Kandu <kanduganesh@gmail.com>
|
||||||
|
2 Gregory <gregory@nosheep.fr>
|
||||||
|
2 Guillaume Virlet <github@virlet.org>
|
||||||
|
2 Knah Tsaeb <Knah-Tsaeb@knah-tsaeb.org>
|
||||||
|
2 Mathieu Chabanon <git@matchab.fr>
|
||||||
|
2 Miloš Jovanović <mjovanovic@gmail.com>
|
||||||
|
2 Neros <contact@neros.fr>
|
||||||
|
2 Qwerty <champlywood@free.fr>
|
||||||
|
2 Sebastien Wains <sebw@users.noreply.github.com>
|
||||||
|
2 Stephen Muth <smuth4@gmail.com>
|
||||||
|
2 Timo Van Neerden <fire@lehollandaisvolant.net>
|
||||||
|
2 flow.gunso <flow.gunso@gmail.com>
|
||||||
|
2 julienCXX <software@chmodplusx.eu>
|
||||||
|
2 Knah Tsaeb <Knah-Tsaeb@knah-tsaeb.org>
|
||||||
|
2 philipp-r <philipp-r@users.noreply.github.com>
|
||||||
|
2 pips <pips@e5150.fr>
|
||||||
|
2 prog-it <pash.vld@gmail.com>
|
||||||
|
2 trailjeep <trailjeep@gmail.com>
|
||||||
|
1 leyrer <gitlab@leyrer.priv.at>
|
||||||
|
1 locness3 <37651007+locness3@users.noreply.github.com>
|
||||||
|
1 owen bell <66233223+xfnw@users.noreply.github.com>
|
||||||
|
1 philipp <philipp@philipp.PC.Ubuntu>
|
||||||
|
1 rfolo9li <50079896+rfolo9li@users.noreply.github.com>
|
||||||
|
1 sprak3000 <sprak3000+github@gmail.com>
|
||||||
|
1 yudejp <i@yude.jp>
|
||||||
|
1 Rajat Hans <rajathans9@gmail.com>
|
||||||
|
1 Adrien le Maire <adrien@alemaire.be>
|
||||||
|
1 Ajabep <ajabep@users.noreply.github.com>
|
||||||
|
1 Alexis J <alexis@effingo.be>
|
||||||
|
1 Alistair Young <avatar@arkane-systems.net>
|
||||||
|
1 Amadeous <amadeous@users.noreply.github.com>
|
||||||
|
1 Angristan <angristan@users.noreply.github.com>
|
||||||
|
1 Bish Erbas <42714627+bisherbas@users.noreply.github.com>
|
||||||
|
1 BoboTiG <bobotig@gmail.com>
|
||||||
|
1 Brendan M. Sleight <bms.git@barwap.com>
|
||||||
|
1 Bronco <bronco@warriordudimanche.net>
|
||||||
|
1 Buster One <37770318+buster-one@users.noreply.github.com>
|
||||||
|
1 D Low <daniellowtw@gmail.com>
|
||||||
|
1 Daniel Jakots <vigdis@chown.me>
|
||||||
|
1 David <dajare@gmail.com>
|
||||||
|
1 David Foucher <dev@tyjak.net>
|
||||||
|
1 Denis Renning <denis@devtty.de>
|
||||||
|
1 Dennis Verspuij <dennisverspuij@users.noreply.github.com>
|
||||||
|
1 Dimtion <zizou.xena@gmail.com>
|
||||||
|
1 Fanch <fanch-github@qth.fr>
|
||||||
|
1 Felix Kästner <github.com-fpunktk@fpunktk.de>
|
||||||
|
1 Florian Voigt <flvoigt@me.com>
|
||||||
|
1 Franck Kerbiriou <FranckKe@users.noreply.github.com>
|
||||||
|
1 Gary Marigliano <gmarigliano93@gmail.com>
|
||||||
|
1 Hazhar Galeh <78073762+hazhargaleh@users.noreply.github.com>
|
||||||
|
1 Hg <dev@indigo.re>
|
||||||
|
1 Jens Kubieziel <github@kubieziel.de>
|
||||||
|
1 Jonathan Amiez <jonathan.amiez@gmail.com>
|
||||||
|
1 Jonathan Druart <jonathan.druart@gmail.com>
|
||||||
|
1 Julien Pivotto <roidelapluie@inuits.eu>
|
||||||
|
1 Kevin Canévet <kevin@streamroot.io>
|
||||||
|
1 Kevin Masson <kevin.masson@methodinthemadness.eu>
|
||||||
|
1 Knah Tsaeb <knah-tsaeb@knah-tsaeb.org>
|
||||||
|
1 Lionel Martin <renarddesmers@gmail.com>
|
||||||
|
1 Loïc Carr <zizou.xena@gmail.com>
|
||||||
|
1 Mark Gerarts <mark.gerarts@gmail.com>
|
||||||
|
1 Marsup <marsup@gmail.com>
|
||||||
|
1 Nicolas Friedli <nicolas@theologique.ch>
|
||||||
|
1 Nicolas Le Gaillart <nicolas@legaillart.fr>
|
||||||
|
1 Paul van den Burg <github@paulvandenburg.nl>
|
||||||
|
1 Rajat Hans <rajathans9@gmail.com>
|
||||||
|
1 Sbgodin <Sbgodin@users.noreply.github.com>
|
||||||
|
1 ToM <tom@leloop.org>
|
||||||
|
1 TsT <tst2005@gmail.com>
|
||||||
|
1 agentcobra <agentcobra@free.fr>
|
||||||
|
1 aguy <aguytech@users.noreply.github.com>
|
||||||
|
1 bschwede <bschwede@users.noreply.github.com>
|
||||||
|
1 bschwede <gummibando@gmx.net>
|
||||||
|
1 clach04 <clach04@gmail.com>
|
||||||
|
1 dimtion <zizou.xena@gmail.com>
|
||||||
|
1 durcheinandr <jochen@durcheinandr.de>
|
||||||
|
1 heimpogo <hypertexthome@googlemail.com>
|
||||||
|
1 jalr <mail@jalr.de>
|
||||||
|
1 lapineige <lapineige@users.noreply.github.com>
|
||||||
|
1 leyrer <gitlab@leyrer.priv.at>
|
||||||
|
1 locness3 <37651007+locness3@users.noreply.github.com>
|
||||||
|
1 owen bell <66233223+xfnw@users.noreply.github.com>
|
||||||
|
1 philipp <philipp@philipp.PC.Ubuntu>
|
||||||
|
1 rfolo9li <50079896+rfolo9li@users.noreply.github.com>
|
||||||
|
1 sprak3000 <sprak3000+github@gmail.com>
|
||||||
|
1 yudejp <i@yude.jp>
|
713
CHANGELOG.md
713
CHANGELOG.md
|
@ -4,8 +4,705 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
|
## [v0.13.0](https://github.com/shaarli/Shaarli/releases/tag/v0.12.3) - 2023-11-22
|
||||||
|
|
||||||
|
> Major changes:
|
||||||
|
> - Security: Fix XSS vulnerability in tag search
|
||||||
|
> - Drop support for PHP 7.1, 7.2 and 7.3
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Docker build: add ARM64 platform and bump Github action version by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/1965
|
||||||
|
* github actions: build OCI images that contain both amd64 and armv7 by @nodiscc in https://github.com/shaarli/Shaarli/pull/1962
|
||||||
|
* Expose tags_separator config through /info API by @amadeous in https://github.com/shaarli/Shaarli/pull/1997
|
||||||
|
* tools: github actions: build docker images on pull requests by @nodiscc in https://github.com/shaarli/Shaarli/pull/2014
|
||||||
|
* doc: server configuration: add PHP 8.2 to PHP compatibility table by @nodiscc in https://github.com/shaarli/Shaarli/pull/2021
|
||||||
|
* Add shaarli-stack theme to Community-and-related-software.md by @dajare in https://github.com/shaarli/Shaarli/pull/2028
|
||||||
|
* doc: document general.download_max_size/timeout configuration settings by @nodiscc in https://github.com/shaarli/Shaarli/pull/2036
|
||||||
|
* doc: troubleshooting: automatic title retrieval fails when it is set by javascript by @nodiscc in https://github.com/shaarli/Shaarli/pull/2037
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* doc: update release procedure (merge the latest release to the release branch) + use the release branch for latest release version detection by @nodiscc in https://github.com/shaarli/Shaarli/pull/1960
|
||||||
|
* Update german translation by @bschwede in https://github.com/shaarli/Shaarli/pull/1969
|
||||||
|
* Update Server-configuration.md by @reinboldg in https://github.com/shaarli/Shaarli/pull/1973
|
||||||
|
* Update Community-and-related-software.md by @nlegaillart in https://github.com/shaarli/Shaarli/pull/1984
|
||||||
|
* doc: improve docs on usage of OR operator in tags search by @nodiscc in https://github.com/shaarli/Shaarli/pull/1987
|
||||||
|
* docker: nginx: listen on IPv6 in addition to IPv4 by @cerebrate in https://github.com/shaarli/Shaarli/pull/1983
|
||||||
|
* Doc update, WebSub (formerly PubSubHubbub) plugin by @clach04 in https://github.com/shaarli/Shaarli/pull/2008
|
||||||
|
* doc: community/related software/integration with other platforms: add link to shaarli debian package by @nodiscc in https://github.com/shaarli/Shaarli/pull/2018
|
||||||
|
* replace mkdocs with sphinx/myst-parser for HTML documentation generation, documentation improvements by @nodiscc in https://github.com/shaarli/Shaarli/pull/2025
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Makefile: Use GNU tar if available by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/1957
|
||||||
|
* Support: ignore disk_free_space if the function is unavailable by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/1970
|
||||||
|
* Documentation: fix broken link to 3rd party plugins by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/1975
|
||||||
|
* Fix autofocus: load bulk action input on linklist only by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/1976
|
||||||
|
* doc: fix mkdocs build warnings/relative links by @nodiscc in https://github.com/shaarli/Shaarli/pull/2015
|
||||||
|
* correct usage of hyphens in all occurences of 'super fast, database-free' by @nodiscc in https://github.com/shaarli/Shaarli/pull/2003
|
||||||
|
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* Drop support for PHP 7.1, 7.2 and 7.3 by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/1958
|
||||||
|
* doc: themes: remove unmaintained themes by @nodiscc in https://github.com/shaarli/Shaarli/pull/2030
|
||||||
|
* doc: remove bountysource badge by @nodiscc in https://github.com/shaarli/Shaarli/pull/2035
|
||||||
|
|
||||||
|
### Security
|
||||||
|
* Fix XSS vulnerability in tag search by @ArthurHoaro in https://github.com/shaarli/Shaarli/pull/2039
|
||||||
|
* tools: run trivy vulnerability scanner on the 'latest' docker image by @nodiscc in https://github.com/shaarli/Shaarli/pull/1980
|
||||||
|
* github actions: fix value of TRIVY_TARGET_DOCKER_IMAGE by @nodiscc in https://github.com/shaarli/Shaarli/pull/1989
|
||||||
|
* tools/CI: scan repository with trivy security scanner (yarn.lock, composer.lock) by @nodiscc in https://github.com/shaarli/Shaarli/pull/1998
|
||||||
|
* tools/tests: update trivy to v0.44.0 by @nodiscc in https://github.com/shaarli/Shaarli/pull/2012
|
||||||
|
* docker: update base alpine docker image to 3.16.7 by @nodiscc in https://github.com/shaarli/Shaarli/pull/2024
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/shaarli/Shaarli/compare/v0.12.2...v0.12.3
|
||||||
|
|
||||||
|
## [v0.12.2](https://github.com/shaarli/Shaarli/releases/tag/v0.12.2) - 2023-03-18
|
||||||
|
|
||||||
|
> Docker: use `ghcr.io/shaarli/shaarli` as Docker image instead of `shaarli/shaarli`.
|
||||||
|
> The `:master` Docker image has been removed, please use `:latest` instead.
|
||||||
|
> The `:stable` Docker image has been removed, please use `:release` instead.
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- Bulk action: add or delete tag to multiple bookmarks
|
||||||
|
- New Core Plugin: ReadItLater
|
||||||
|
- Plugin system: allow plugins to provide custom routes
|
||||||
|
- Support search highlights when matching URL content
|
||||||
|
- Support for OR (~) and optional AND (+) operators for tag search
|
||||||
|
- Russian translation
|
||||||
|
- Chinese translation
|
||||||
|
- Export:
|
||||||
|
- Export: set a bookmark's LAST_MODIFIED attribute to its update timestamp
|
||||||
|
- Export: set a bookmark's PRIVATE attribute using an integer value
|
||||||
|
- Add an additional free disk space check before saving the datastore
|
||||||
|
- curl: support HTTP/2 response code header
|
||||||
|
- CI:
|
||||||
|
- Build and push Docker images through Github Actions
|
||||||
|
- push container images to github registry in addition to dockerhub
|
||||||
|
- Documentation:
|
||||||
|
- Add '206 not acceptable' to the Troubleshooting section
|
||||||
|
- Add mention to Shaarli Archiver
|
||||||
|
- doc: add note to adjust proxy timeouts or PHP max execution time
|
||||||
|
- doc: shaarli configuration: mention file:/// URIs
|
||||||
|
- add "formatter" key to example config.json.php
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- docker latest: replace dev in shaarli_version.php with the latest commit
|
||||||
|
- Daily RSS Cache: invalidate cache base on the date
|
||||||
|
- Update Japanese translations
|
||||||
|
- Update German translations
|
||||||
|
- Templates: Inject current template name
|
||||||
|
- format_date: include timezone in IntlDateFormatter object
|
||||||
|
- Handle pagination through BookmarkService
|
||||||
|
- autocapitalize off for username input
|
||||||
|
- More intuitive label for plugin checkboxes
|
||||||
|
- Simple and uniform localized website title
|
||||||
|
- Use rewrited version of Netscape Bookmark Parser
|
||||||
|
- tests/makefile: rewrite translate target to be compatible with busybox
|
||||||
|
- PubSubHub Plugin: make 1 external call per request
|
||||||
|
- Docker:
|
||||||
|
- newer alpine (for newer PHP) and apk upgrade
|
||||||
|
- Dockerfile.armhf: upgrade python2 -> python3
|
||||||
|
- Dockerfile: add php8-gettext package
|
||||||
|
- update s6 service definition to use php-fpm8
|
||||||
|
- install php8-ldap in Docker images
|
||||||
|
- CI:
|
||||||
|
- use Github Action instead of Travis CI
|
||||||
|
- use the yarnpkg command instead of yarn
|
||||||
|
- tools: github actions: fix PHP 8.0 tests
|
||||||
|
- github actions: add tests for PHP 8.2
|
||||||
|
- Documentation:
|
||||||
|
- apache: explicitely ste index.php as DirectoryIndex
|
||||||
|
- bookmarklet is now working on github.com
|
||||||
|
- LDAP login support, update php requirements list
|
||||||
|
- installation/tests: clarify build tools installation procedure
|
||||||
|
- doc: PHP extensions are also required for development
|
||||||
|
- doc: move OCI images hosting to ghcr.io
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Error handling if the datastore mutex is not working
|
||||||
|
- Synchronous metadata retrieval is failing in strict mode
|
||||||
|
- Improve metadata extraction
|
||||||
|
- Typo: 'Authentication' ->
|
||||||
|
- default_colors plugin: update CSS file on color change
|
||||||
|
- API: POST/PUT Link - properly parse tags string
|
||||||
|
- Error when using bulk shaare with a single URL
|
||||||
|
- Bulk Shaare:
|
||||||
|
- use unique HTML ID
|
||||||
|
- error with a single URL
|
||||||
|
- redirection with ending slash
|
||||||
|
- Bug when trying to access ATOM feed without bookmarks
|
||||||
|
- Documentation build
|
||||||
|
- pubsubhubbub hub link in RSS / Atom.
|
||||||
|
- Monthly views previous/next month links during month
|
||||||
|
- Resolve PHP 8.1 deprecation warnings
|
||||||
|
- Fix PHP 8 incompatibility with debug mode enabled
|
||||||
|
- Fixed Roboto-Regular and Roboto-Bold font declarations
|
||||||
|
- template/vintage: fix typo in visibility selection link
|
||||||
|
- Do not display deprecated warnings by default
|
||||||
|
- Fix a bug when using '/' as a tag separator
|
||||||
|
- Fix Logger exception: gracefully handle permission issue
|
||||||
|
- Documentation:
|
||||||
|
- plugins.md: fix link casing
|
||||||
|
|
||||||
|
## Removed
|
||||||
|
|
||||||
|
- Daily RSS: Remove relative description (today, yesterday)
|
||||||
|
- Documentation:
|
||||||
|
- remove the markdown plugin from the plugins list
|
||||||
|
- remove duplicate "general" key in example config.php.json
|
||||||
|
|
||||||
|
## [v0.12.1](https://github.com/shaarli/Shaarli/releases/tag/v0.12.1) - 2020-11-12
|
||||||
|
|
||||||
|
> nginx ([#1628](https://github.com/shaarli/Shaarli/pull/1628)) and Apache ([#1630](https://github.com/shaarli/Shaarli/pull/1630)) configurations have been reviewed. It is recommended that you
|
||||||
|
> update yours using [the documentation](https://shaarli.readthedocs.io/en/master/Server-configuration/).
|
||||||
|
> Users using official Docker image will receive updated configuration automatically.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Bulk creation of bookmarks
|
||||||
|
- Server administration tool page (and install page requirements)
|
||||||
|
- Support any tag separator, not just whitespaces
|
||||||
|
- Share a private bookmark using a URL with a token
|
||||||
|
- Add a setting to retrieve bookmark metadata asynchronously (enabled by default)
|
||||||
|
- Highlight fulltext search results
|
||||||
|
- Weekly and monthly view/RSS feed for daily page
|
||||||
|
- MarkdownExtra formatter
|
||||||
|
- Default formatter: add a setting to disable auto-linkification
|
||||||
|
- Add mutex on datastore I/O operations to prevent data loss
|
||||||
|
- PHP 8.0 support
|
||||||
|
- REST API: allow override of creation and update dates
|
||||||
|
- Add strict types for bookmarks management
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Improve regex and performances to extract HTML metadata (title, description, etc.)
|
||||||
|
- Support using Shaarli without URL rewriting (prefix URL with `/index.php/`)
|
||||||
|
- Improve the "Manage tags" tools page
|
||||||
|
- Use PSR-3 logger for login attempts
|
||||||
|
- Move utils classes to Shaarli\Helper namespace and folder
|
||||||
|
- Include php-simplexml in Docker image
|
||||||
|
- Raise 404 error instead of 500 if permalink access is denied
|
||||||
|
- Display error details even with dev.debug set to false
|
||||||
|
- Reviewed nginx configuration
|
||||||
|
- Reviewed Apache configuration
|
||||||
|
- Replace vimeo link in demo bookmarks due to IP ban on the demo instance
|
||||||
|
- Apply PSR-12 on code base, and add CI check using PHPCS
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Compatiliby issue on login with PHP 7.1
|
||||||
|
- Japanese translations update
|
||||||
|
- Redirect to referrer after bookmark deletion
|
||||||
|
- Inject ROOT_PATH in plugin instead of regenerating it everywhere
|
||||||
|
- Wallabag plugin: minor improvements
|
||||||
|
- REST API postLink: change relative path to absolute path
|
||||||
|
- Webpack: fix vintage theme images include
|
||||||
|
- Docker-compose: fix SSL certificate + add parameter for Docker tag
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- `config.json.php` new lines in prefix/suffix to prevent issues with Windows PHP
|
||||||
|
|
||||||
|
## [v0.12.0](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0) - 2020-10-13
|
||||||
|
|
||||||
|
**Save you `data/` folder before updating!**
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Thumbnailer: add soundcloud.com to list of common media domains
|
||||||
|
- Markdown rendering is now integrated into Shaarli core
|
||||||
|
- Add autofocus on tag cloud filter input
|
||||||
|
- Japanese translations
|
||||||
|
- Japanese translation: add language to admin configuration page
|
||||||
|
- Support for PHP 8.0
|
||||||
|
- Support for local anchor URL (starting with `#`)
|
||||||
|
- LDAP authentication
|
||||||
|
- Encapsulated PageCacheManager
|
||||||
|
- Docs:
|
||||||
|
- add screenshots of all pages
|
||||||
|
- section about mkdocs
|
||||||
|
- Ulauncher extension
|
||||||
|
- CI: run against PHP 7.4
|
||||||
|
- Added $links_per_page variable to template and display on default
|
||||||
|
- Inject BookmarkServiceInterface in plugins data
|
||||||
|
- Add manual configuration for root URL
|
||||||
|
- Added PATCH to the allowed Apache request methods.
|
||||||
|
- REST API: compatibility with ionos Apache's headers
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Introduce Bookmark object and Service layer
|
||||||
|
- Save bookmark as objects in the datastore
|
||||||
|
- Handle bookmark as objects across the whole codebase (except templates and plugins)
|
||||||
|
- Process all Shaarli page through Slim controller, with proper URL rewriting (see #1516)
|
||||||
|
- Docs: the entire documentation has been reviewed, updated and improved, thanks to @nodiscc!
|
||||||
|
- ATOM feed: use instance name as author name instead of URL
|
||||||
|
- Updated French translation
|
||||||
|
- Default colors plugin: generate CSS file during initialization
|
||||||
|
- Improve default bookmarks after install
|
||||||
|
- Upgrade all front end dependencies and webpack build
|
||||||
|
- Default theme: Make tag cloud/list views buttons more obvious
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Undefined index: thumbnail in daily page
|
||||||
|
- Undefined index: thumbnail on OpenGraph headers
|
||||||
|
- Undefined index: updated on linklist
|
||||||
|
- Make sure that bookmark sort is consistent, even with equal timestamps
|
||||||
|
- Code PHP version check as requirement bumped to PHP 7.1
|
||||||
|
- Thumbnail images lazy loading
|
||||||
|
- Markdown plugin: fix RSS feed direct link reverse
|
||||||
|
- Fix RSS permalink included in Markdown bloc
|
||||||
|
- Demo plugin: multiple typos
|
||||||
|
- Makefile target for releases
|
||||||
|
- Makefile target for html documentation
|
||||||
|
- Session cookie setting being set while session is active
|
||||||
|
- Deprecated use of implode
|
||||||
|
- Division by zero in tag cloud
|
||||||
|
- CI: deprecated linux distribution and sudo directive
|
||||||
|
- Docker build: gcc is no longer included in python alpine image
|
||||||
|
- Default template: display pin button in mobile view
|
||||||
|
- Pinned bookmarks are not longer displayed first in ATOM/RSS feeds
|
||||||
|
- Docs:
|
||||||
|
- Outdated Docker documentation for stable branch
|
||||||
|
- Outdated links
|
||||||
|
- Plugin description in meta files
|
||||||
|
- docker-compose.yml: pin traefik image to 1.7-alpine
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Markdown plugin
|
||||||
|
- Docs:
|
||||||
|
- emojione & twemoji removed
|
||||||
|
- Makefile: remove static_analysis_summary from all: target
|
||||||
|
- doc/Makefile: remove references to composer update
|
||||||
|
|
||||||
|
## [v0.11.1](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1) - 2019-08-03
|
||||||
|
|
||||||
|
Release to fix broken Docker build on the latest version.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed Docker build
|
||||||
|
- Fixed a few documentation broken links
|
||||||
|
- Fixed broken label in configuration page
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- More accessibility improvements
|
||||||
|
|
||||||
|
## [v0.11.0](https://github.com/shaarli/Shaarli/releases/tag/v0.11.0) - 2019-07-27
|
||||||
|
|
||||||
|
**Shaarli no longer officially support PHP 5.6 and PHP 7.0 as they've reached end of life.**
|
||||||
|
|
||||||
|
**Shaarli classes now use namespace, third party plugins need to update.**
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Add optional PHP extension to composer suggestions.
|
||||||
|
- composer: enforce PHP security advisories
|
||||||
|
- phpDocumentor configuration and make target
|
||||||
|
- Run unit tests against PHP 7.3
|
||||||
|
- Bunch of accessibility improvements to the default template, thanks to @llune
|
||||||
|
- Bulk actions: set visibility
|
||||||
|
- Display sticky label in linklist
|
||||||
|
- Add print CSS rules to the default template
|
||||||
|
- New setting to automatically retrieve description for new bookmarks
|
||||||
|
- Plugin to override default template colors
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Shaarli now uses namespaces for its classes.
|
||||||
|
- Rewrite IP ban management
|
||||||
|
- Default template: slightly lighten visited link color
|
||||||
|
- Hide select all button on mobile view
|
||||||
|
- Switch from FontAwesome v4.x to ForkAwesome
|
||||||
|
- Daily - display the current day instead of the previous one
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Do not check the IP address with session protection disabled
|
||||||
|
- API: update test regexes to comply with PCRE2
|
||||||
|
- Optimize and cleanup imports
|
||||||
|
- ensure HTML tags are stripped from OpenGraph description
|
||||||
|
- Documentation invalid links
|
||||||
|
- Thumbnails disabling if PHP GD is not installed
|
||||||
|
- Warning if links sticky status isn't set
|
||||||
|
- Fix button overlapping on mobile in linklist
|
||||||
|
- Do not try to retrieve thumbnails for internal link
|
||||||
|
- Update node-sass to fix a vulnerability in node tar dependency
|
||||||
|
- armhf Dockerfile
|
||||||
|
- Default template: Responsive issue with delete button fix
|
||||||
|
- Persist sticky status on bookmark update
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Doxygen configuration
|
||||||
|
- redirector setting
|
||||||
|
- QRCode link to an external service
|
||||||
|
|
||||||
|
## [v0.10.4](https://github.com/shaarli/Shaarli/releases/tag/v0.10.4) - 2019-04-16
|
||||||
|
### Fixed
|
||||||
|
- Fix thumbnails disabling if PHP GD is not installed
|
||||||
|
- Fix a warning if links sticky status isn't set
|
||||||
|
|
||||||
|
## [v0.10.3](https://github.com/shaarli/Shaarli/releases/tag/v0.10.3) - 2019-02-23
|
||||||
|
### Added
|
||||||
|
- Add OpenGraph metadata tags on permalink page
|
||||||
|
- Add CORS headers to REST API reponses
|
||||||
|
- Add a button to toggle checkboxes of displayed links
|
||||||
|
- Add an icon to the link list when the Isso plugin is enabled
|
||||||
|
- Add noindex, nofollow to documentation pages
|
||||||
|
- Document usage of robots.txt
|
||||||
|
- Add a button to set links as sticky
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Update French translation
|
||||||
|
- Refactor the documentation homepage
|
||||||
|
- Bump netscape-bookmark-parser
|
||||||
|
- Update session_start condition
|
||||||
|
- Improve accessibility
|
||||||
|
- Cleanup and refactor lint tooling
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fix input size for dropdown search form
|
||||||
|
- Fix history for bulk link deletion
|
||||||
|
- Fix thumbnail requests
|
||||||
|
- Fix hashtag rendering when markdown escaping is enabled
|
||||||
|
- Fix AJAX tag deletion
|
||||||
|
- Fix lint errors and improve PSR-1 and PSR-2 compliance
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Remove Firefox Share documentation
|
||||||
|
|
||||||
|
## [v0.10.2](https://github.com/shaarli/Shaarli/releases/tag/v0.10.2) - 2018-08-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Docker build
|
||||||
|
|
||||||
|
## [v0.10.1](https://github.com/shaarli/Shaarli/releases/tag/v0.10.1) - 2018-08-11
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Accessibility:
|
||||||
|
- Remove alt text on the logo
|
||||||
|
- Remove redundant title in tools page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an error on the daily page and daily RSS
|
||||||
|
- Fixed an issue causing 'You are not authorized to add a link' error while logged out
|
||||||
|
- Fixed thumbnail path when Shaarli's path uses symbolic links
|
||||||
|
- Add a `mod_version` check in Shaarli's root `.htaccess` file for Apache 2.2 syntax
|
||||||
|
- Include assets in the release Makefile target
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Firefox Social API shaare has been removed
|
||||||
|
|
||||||
|
## [v0.10.0](https://github.com/shaarli/Shaarli/releases/tag/v0.10.0) - 2018-07-28
|
||||||
|
**PHP 5.5 compatibility has been dropped.** Shaarli now requires at least PHP 5.6.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Add a filter to display public links only
|
||||||
|
- Add PHP 7.2 support
|
||||||
|
- Add German translation
|
||||||
|
- Resolve front-end dependencies from NPM
|
||||||
|
- Build front-end bundles with Yarn and Webpack
|
||||||
|
- Lint Javascript code with ESLint
|
||||||
|
- Lint SASS code with SASSLint
|
||||||
|
- Support redirection in cURL download callback
|
||||||
|
- Introduce multi-stage builds for Docker images
|
||||||
|
- Use Travis matrix and stages to run Javascript tests in a dedicated environment
|
||||||
|
- Add tag endpoint in the REST API
|
||||||
|
- Build the documentation in Travis builds
|
||||||
|
- Provide a Docker Compose example
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Use web-thumbnailer to retrieve thumbnails (see #687)
|
||||||
|
- Use a specific page title in all pages
|
||||||
|
- Daily: run hooks before creating the columns
|
||||||
|
- Load theme translations files automatically
|
||||||
|
- Make max download size and timeout configurable
|
||||||
|
- Make Nginx logs accessible as stdout/stderr for Docker images
|
||||||
|
- Update buttons used to toggle link visibility filters
|
||||||
|
- Rewrite Javascript code for ES6 compliance
|
||||||
|
- Refactor IP ban management
|
||||||
|
- Refactor user login management
|
||||||
|
- Refactor server-side session management
|
||||||
|
- Update Doxygen configuration
|
||||||
|
- Update Parsedown
|
||||||
|
- Improve documentation
|
||||||
|
- Docker: build the images from the local sources
|
||||||
|
- Docker: bump alpine version to 3.7
|
||||||
|
- Docker: expose a volume for the thumbnail cache
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Drop support for PHP 5.5
|
||||||
|
- Remove vendored front-end libraries
|
||||||
|
- Remove environment specific .gitignore entries
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Ignore the case while checking DOCTYPE during the file import
|
||||||
|
- Fix removal of on=... attributes from html generated from Markdown
|
||||||
|
- httpd: always forward the 'Authorization' header
|
||||||
|
- Ensure user-specific CSS file is loaded
|
||||||
|
- Fix feed permalink rendering when Markdown escaping is enabled
|
||||||
|
- Fix order of tags with the same number of occurrences
|
||||||
|
- Fixed the referrer meta tag in default template
|
||||||
|
- Disable MkDocs' strict mode for ReadTheDocs builds to pass
|
||||||
|
- fix and simplify Dockerfile for armhf
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Update `.htaccess` to prevent accessing Git metadata when using a Git-based installation
|
||||||
|
|
||||||
|
|
||||||
|
## [v0.9.7](https://github.com/shaarli/Shaarli/releases/tag/v0.9.7) - 2018-06-20
|
||||||
|
### Changed
|
||||||
|
- Build the Docker images from the local Git sources
|
||||||
|
|
||||||
|
|
||||||
|
## [v0.9.6](https://github.com/shaarli/Shaarli/releases/tag/v0.9.6) - 2018-03-25
|
||||||
|
### Changed
|
||||||
|
- htaccess: prevent accessing resources not managed by SCM
|
||||||
|
- htaccess: always forward the 'Authorization' HTTP header
|
||||||
|
|
||||||
|
|
||||||
|
## [v0.9.5](https://github.com/shaarli/Shaarli/releases/tag/v0.9.5) - 2018-02-02
|
||||||
|
### Fixed
|
||||||
|
- Fix a warning happening when `php-intl` is not installed on the system
|
||||||
|
- Fix warnings happening when updating from legacy SebSauvage version
|
||||||
|
|
||||||
|
## [v0.9.4](https://github.com/shaarli/Shaarli/releases/tag/v0.9.4) - 2018-01-30
|
||||||
|
### Added
|
||||||
|
- Enable translations: Shaarli is now also available in French. Other language translations are welcome!
|
||||||
|
- Add EditorConfig configuration
|
||||||
|
- Add favicons for mobile devices
|
||||||
|
- Add Alpine Linux arm32v7 Dockerfiles (master, latest)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Do not write bookmark edition history during file imports (performance)
|
||||||
|
- Migrate Docker images (master, latest) to Alpine Linux
|
||||||
|
- Improve unitary tests and code coverage
|
||||||
|
- Improve thumbnail display
|
||||||
|
- Improve theme ergonomics
|
||||||
|
- Improve messages if there is no plugin or parameter available in the admin page
|
||||||
|
- Increase buffer size for cURL download
|
||||||
|
- Force HTTPS if the original port is 443 behind a reverse proxy (workaround)
|
||||||
|
- Improve page title retrieval performances
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Remove redirector setting from Configure page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fix broken links in the documentation
|
||||||
|
- Enable access to `data/user.css` (Apache 2.2 & 2.4)
|
||||||
|
- Don't URL encode description links if parameter `redirector.encode_url` is set to false
|
||||||
|
- Fix an issue preventing the Save button to appear for plugin parameters
|
||||||
|
|
||||||
|
|
||||||
|
## [v0.9.3](https://github.com/shaarli/Shaarli/releases/tag/v0.9.3) - 2018-01-04
|
||||||
|
**XSS vulnerability fixed. Please update.**
|
||||||
|
|
||||||
|
## Security
|
||||||
|
- Fix an XSS (cross-site-scripting) vulnerability in `index.php` -
|
||||||
|
[CVE-2018-5249](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-5249)
|
||||||
|
|
||||||
|
|
||||||
|
## [v0.9.2](https://github.com/shaarli/Shaarli/releases/tag/v0.9.2) - 2017-10-07
|
||||||
|
|
||||||
|
**Major security issue fixed. Please update.**
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Tag search now supports wildcards `*`
|
||||||
|
- New setting `privacy.force_login` which can be used with `privacy.hide_public_links` to redirect anonymous users to the login page.
|
||||||
|
- New setting `general.default_note_title` used to override default `Note:` title prefix for notes.
|
||||||
|
- Add a version hash for asset loading to prevent browser's cache issue
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- The "Remember me" checkbox is unchecked by default
|
||||||
|
- The default value of the "Remember me" checkbox can be configured under `data/config.json.php`
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Remove obsolete PHP magic quote support
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Generates a permalink URL if the URL is set to blank
|
||||||
|
- Replace links to the old GitHub wiki with ReadTheDocs URIs
|
||||||
|
- Use single quotes in the note bookmarklet
|
||||||
|
- Daily page if there is no link
|
||||||
|
- Bulk link deletion with a single link
|
||||||
|
- HTTPS detection behind a reverse proxy
|
||||||
|
- Travis tests environment and localization
|
||||||
|
- Improve template paths robustness (trailing slash)
|
||||||
|
- Robustness: safer gzinflate/zlib usage
|
||||||
|
- Description links parsing with parenthesis (without Markdown)
|
||||||
|
- Templates:
|
||||||
|
- Sort the tag cloud alphabetically
|
||||||
|
- Firefox social title
|
||||||
|
- Improved visited link color
|
||||||
|
- Fix jumpy textarea with long content in post edit
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Fixed reflected XSS vulnerability introduced in v0.9.1, discovered by @chb9 ([CVE-2017-15215](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-15215)).
|
||||||
|
|
||||||
|
|
||||||
|
## [v0.9.1](https://github.com/shaarli/Shaarli/releases/tag/v0.9.1) - 2017-08-23
|
||||||
|
|
||||||
|
The documentation has been migrated to ReadTheDocs:
|
||||||
|
- https://shaarli.readthedocs.io/
|
||||||
|
- edits are submitted as pull requests
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Allow bulk link deletion
|
||||||
|
- Display subtags in the tag cloud
|
||||||
|
- Add an endpoint to refresh the token
|
||||||
|
- Add a token on every page
|
||||||
|
- Add a tag list view for management
|
||||||
|
- Add Note bookmarklet
|
||||||
|
- Add creation date when editing a link
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Documentation:
|
||||||
|
- Generate static HTML documentation with [mkdocs](http://www.mkdocs.org/)
|
||||||
|
- Host documentation on [ReadTheDocs](http://www.mkdocs.org/)
|
||||||
|
- Update documentation structure
|
||||||
|
- Update Makefile targets to:
|
||||||
|
- Build the docs locally
|
||||||
|
- Include the generated docs in the release archives
|
||||||
|
- Theme:
|
||||||
|
- Use the new theme as the default
|
||||||
|
- Rename the tag cloud template to `tag.cloud.html`
|
||||||
|
- Display visited links in grey
|
||||||
|
- Use only one search form in `linklist.html`
|
||||||
|
- Hide the "search links with these tags" option when an empty `searchtags` is passed to `tag.list.html`
|
||||||
|
- Improve HTTP header handling when hosting Shaarli with Docker behind a reverse proxy
|
||||||
|
- Searching for tags with an empty value returns untagged links only
|
||||||
|
- Set Travis environment to `precise` until the new `trusty` environment is ready
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Remove dead Pubsubhubbub code
|
||||||
|
- Disable the GitHub wiki (see changed/documentation)
|
||||||
|
- Remove Docker `dev` image and resources
|
||||||
|
- Theme:
|
||||||
|
- Remove the bottom "Sort by" menu in `tag.list.html`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fix file existence check for `user.css`
|
||||||
|
- Limit selection to 2k characters when using the bookmarklet
|
||||||
|
- Fix JS error `uncaught type error`
|
||||||
|
- Fix Firefox Social button
|
||||||
|
- Use pinned PHP dependencies when generating release archives
|
||||||
|
- Make sure that the tag exists before altering/removing it
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Add a whitelist for protocols for URLs
|
||||||
|
|
||||||
|
|
||||||
|
## [v0.9.0](https://github.com/shaarli/Shaarli/releases/tag/v0.9.0) - 2017-05-07
|
||||||
|
|
||||||
|
This release introduces the REST API, and requires updating HTTP server
|
||||||
|
configuration to enable URL rewriting, see:
|
||||||
|
- https://shaarli.github.io/api-documentation/
|
||||||
|
- https://shaarli.readthedocs.io/en/master/Server-configuration/
|
||||||
|
|
||||||
|
**WARNING**: Shaarli now requires PHP 5.5+.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- REST API v1
|
||||||
|
- [Slim](https://www.slimframework.com/) framework
|
||||||
|
- [JSON Web Token](https://jwt.io/introduction/) (JWT) authentication
|
||||||
|
- versioned API endpoints:
|
||||||
|
- `/api/v1/info`: get general information on the Shaarli instance
|
||||||
|
- `/api/v1/links`: get a list of shaared links
|
||||||
|
- `/api/v1/history`: get a list of latest actions
|
||||||
|
- Theming:
|
||||||
|
- Introduce a new theme
|
||||||
|
- Allow selecting themes/templates from the configuration page
|
||||||
|
- New/Edit link form can be submitted using CTRL+Enter in the textarea
|
||||||
|
- Shaarli version is displayed in the footer when logged in
|
||||||
|
- Add plugin placeholders to Atom/RSS feed templates
|
||||||
|
- Add OpenSearch to feed templates
|
||||||
|
- Add `campaign_` to the URL cleanup pattern list
|
||||||
|
- Add an AUTHORS file and Makefile target to list authors from Git commit data
|
||||||
|
- Link imports are now logged in `data/` folder, and can be debug using `dev.debug=true` setting.
|
||||||
|
- `composer.lock` is now included in git file to allow proper `composer install`
|
||||||
|
- History mechanism which logs link addition/modification/deletion
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Docker: enable nginx URL rewriting for the REST API
|
||||||
|
- Theming:
|
||||||
|
- Move `user.css` to the `data` folder
|
||||||
|
- Move default template files to a subfolder (`default`)
|
||||||
|
- Rename the legacy theme to `vintage`
|
||||||
|
- Private only filter is now displayed as a search parameter
|
||||||
|
- Autocomplete: pre-select the first element
|
||||||
|
- Display daily date in the page title (browser title)
|
||||||
|
- Timezone lists are now passed as an array instead of raw HTML
|
||||||
|
- Move PubSubHub to a dedicated plugin
|
||||||
|
- Coding style:
|
||||||
|
- explicit method visibility
|
||||||
|
- safe boolean comparisons
|
||||||
|
- remove unused variables
|
||||||
|
- The updater now keeps custom theme preferences
|
||||||
|
- Simplify the COPYING information
|
||||||
|
- Improved client locale detection
|
||||||
|
- Improved date time display depending on the locale
|
||||||
|
- Partial namespace support for Shaarli classes
|
||||||
|
- Shaarli version is now only present in `shaarli_version.php`
|
||||||
|
- Human readable maximum file size upload
|
||||||
|
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- PHP < 5.5 compatibility
|
||||||
|
- ReadItYourself plugin
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Ignore generated release tarballs
|
||||||
|
- Hide default port when behind a reverse proxy
|
||||||
|
- Fix a typo in the Markdown plugin description
|
||||||
|
- Fix the presence of empty tags for private tags and in search results
|
||||||
|
- Fix a fatal error during the install
|
||||||
|
- Fix permalink image alignment in daily page
|
||||||
|
- Fix the delete button in `editlink`
|
||||||
|
- Fix redirection after link deletion
|
||||||
|
- Do not access LinkDB links by ID before the Updater applies migrations
|
||||||
|
- Remove extra spaces in the bookmarklet's name
|
||||||
|
- Piwik plugin: Piwik URL protocol can now be set (http or https)
|
||||||
|
- All inline JS has been moved to dedicated JS files
|
||||||
|
- Keep tags after login redirection
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Markdown plugin: escape HTML entities by default
|
||||||
|
|
||||||
|
|
||||||
|
## [v0.8.7](https://github.com/shaarli/Shaarli/releases/tag/v0.8.7) - 2018-06-20
|
||||||
|
### Changed
|
||||||
|
- Build the Docker image from the local Git sources
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Disable PHP 5.3 Travis build (unsupported)
|
||||||
|
|
||||||
|
|
||||||
|
## [v0.8.6](https://github.com/shaarli/Shaarli/releases/tag/v0.8.6) - 2018-02-19
|
||||||
|
### Changed
|
||||||
|
- Run version check tests against the 'stable' branch
|
||||||
|
|
||||||
|
|
||||||
|
## [v0.8.5](https://github.com/shaarli/Shaarli/releases/tag/v0.8.5) - 2018-01-04
|
||||||
|
**XSS vulnerability fixed. Please update.**
|
||||||
|
|
||||||
|
## Security
|
||||||
|
- Fix an XSS (cross-site-scripting) vulnerability in `index.php` -
|
||||||
|
[CVE-2018-5249](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-5249)
|
||||||
|
|
||||||
|
## [v0.8.4](https://github.com/shaarli/Shaarli/releases/tag/v0.8.4) - 2017-03-04
|
||||||
|
### Security
|
||||||
|
- Markdown plugin: escape HTML entities by default
|
||||||
|
|
||||||
|
## [v0.8.3](https://github.com/shaarli/Shaarli/releases/tag/v0.8.3) - 2017-01-20
|
||||||
|
### Fixed
|
||||||
|
- PHP 7.1 compatibility: add ConfigManager parameter to anti-bruteforce function call in login template.
|
||||||
|
|
||||||
|
## [v0.8.2](https://github.com/shaarli/Shaarli/releases/tag/v0.8.2) - 2016-12-15
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Editing a link created before the new ID system would change its permalink.
|
||||||
|
|
||||||
|
## [v0.8.1](https://github.com/shaarli/Shaarli/releases/tag/v0.8.1) - 2016-12-12
|
||||||
|
|
||||||
|
> Note: this version will create an automatic backup of your database if anything goes wrong.
|
||||||
|
|
||||||
## [v0.8.1](https://github.com/shaarli/Shaarli/releases/tag/v0.8.1) - UNPUBLISHED
|
|
||||||
### Added
|
### Added
|
||||||
- Add CHANGELOG.md to track the whole project's history
|
- Add CHANGELOG.md to track the whole project's history
|
||||||
- Enable Composer cache for Travis builds
|
- Enable Composer cache for Travis builds
|
||||||
|
@ -18,7 +715,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
- Meta tag to *not* send the referrer to external resources.
|
- Meta tag to *not* send the referrer to external resources.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- Link ID complete refactoring:
|
||||||
|
- Links now have a numeric ID instead of dates
|
||||||
|
- Short URLs are now created once and can't change over time (previous URL are kept)
|
||||||
|
- Templates:
|
||||||
|
- Changed placeholder behaviour for: `buttons_toolbar`, `fields_toolbar` and `action_plugin`
|
||||||
- Cleanup `{loop}` declarations in templates
|
- Cleanup `{loop}` declarations in templates
|
||||||
|
- Tools: hide Firefox Social button when not in HTTPS
|
||||||
|
- Firefox Social: show Shaarli's title when shaaring using Firefox Social
|
||||||
- Release archives now have the same structure as GitHub-generated archives:
|
- Release archives now have the same structure as GitHub-generated archives:
|
||||||
- archives contain a `Shaarli` directory, itself containing sources + dependencies
|
- archives contain a `Shaarli` directory, itself containing sources + dependencies
|
||||||
- the tarball is now gzipped
|
- the tarball is now gzipped
|
||||||
|
@ -26,8 +730,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
- Markdown: Parsedown library is now imported through Composer
|
- Markdown: Parsedown library is now imported through Composer
|
||||||
- Minor code cleanup: PHPDoc, spelling, unused variables, etc.
|
- Minor code cleanup: PHPDoc, spelling, unused variables, etc.
|
||||||
- Docker: explicitly set the maximum file upload size to 10 MiB
|
- Docker: explicitly set the maximum file upload size to 10 MiB
|
||||||
- Tools: hide Firefox Social button when not in HTTPS
|
|
||||||
- Firefox Social: show Shaarli's title when shaaring using Firefox Social
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Fix the server `<self>` value in Atom/RSS feeds
|
- Fix the server `<self>` value in Atom/RSS feeds
|
||||||
|
@ -40,6 +742,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
- W3C compliance
|
- W3C compliance
|
||||||
- Use absolute URL for hashtags in RSS and ATOM feeds
|
- Use absolute URL for hashtags in RSS and ATOM feeds
|
||||||
- Docker: specify the location of the favicon
|
- Docker: specify the location of the favicon
|
||||||
|
- ATOM feed: remove new line between content tag and data
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- Allow whitelisting trusted IPs, else continue banning clients upon login failure
|
- Allow whitelisting trusted IPs, else continue banning clients upon login failure
|
||||||
|
@ -81,6 +784,10 @@ Please use our release archives, or follow the
|
||||||
- XSRF token now generated each time a page is rendered
|
- XSRF token now generated each time a page is rendered
|
||||||
|
|
||||||
|
|
||||||
|
## [v0.7.1](https://github.com/shaarli/Shaarli/releases/tag/v0.7.1) - 2017-03-08
|
||||||
|
### Security
|
||||||
|
- Markdown plugin: escape HTML entities by default
|
||||||
|
|
||||||
## [v0.7.0](https://github.com/shaarli/Shaarli/releases/tag/v0.7.0) - 2016-05-14
|
## [v0.7.0](https://github.com/shaarli/Shaarli/releases/tag/v0.7.0) - 2016-05-14
|
||||||
### Added
|
### Added
|
||||||
- Adds an option to encode redirector URL parameter
|
- Adds an option to encode redirector URL parameter
|
||||||
|
|
|
@ -17,14 +17,10 @@ Check the [milestones](https://github.com/shaarli/Shaarli/milestones) to see wha
|
||||||
* You can also join instant discussion at https://gitter.im/shaarli/Shaarli, or via IRC as described [here](https://github.com/shaarli/Shaarli/issues/44#issuecomment-77745105)
|
* You can also join instant discussion at https://gitter.im/shaarli/Shaarli, or via IRC as described [here](https://github.com/shaarli/Shaarli/issues/44#issuecomment-77745105)
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
**the [wiki](https://github.com/shaarli/Shaarli/wiki) is world-writable** - anyone can edit or add chapters and pages.
|
|
||||||
|
|
||||||
* Large changes should preferably be discussed in [General discussion](https://github.com/shaarli/Shaarli/issues/44) beforehand (you can post a draft there and edit it).
|
The [official documentation](http://shaarli.readthedocs.io/en/rtfd/) is generated from [Markdown](https://daringfireball.net/projects/markdown/syntax) documents in the `doc/md/` directory. HTML documentation is generated using [Mkdocs](http://www.mkdocs.org/). [Read the Docs](https://readthedocs.org/) provides hosting for the online documentation.
|
||||||
* If you create a new page, please link it from the new page (eg from the [Other links](https://github.com/shaarli/Shaarli/wiki#other-links) section.
|
|
||||||
* The wiki is a general documentation about Shaarli: usage, development, hacks, usage tricks, related links, projects. Try to keep it organized.
|
|
||||||
* The wiki will be synced to Shaarli's `doc/` directory on each release. Keep that in mind when reviewing the quality of your edits.
|
|
||||||
|
|
||||||
You can make the project known by publishing blog posts/articles/videos about it and adding them to the links section in the wiki.
|
To edit the documentation, please edit the appropriate `doc/md/*.md` files (and optionally `make htmlpages` to preview changes to HTML files). Then submit your changes as a Pull Request. Have a look at the MkDocs documentation and configuration file `mkdocs.yml` if you need to add/remove/rename/reorder pages.
|
||||||
|
|
||||||
### Translations
|
### Translations
|
||||||
Currently Shaarli has no translation/internationalization/localization system available and is single-language. You can help by proposing an i18n system (issue https://github.com/shaarli/Shaarli/issues/121)
|
Currently Shaarli has no translation/internationalization/localization system available and is single-language. You can help by proposing an i18n system (issue https://github.com/shaarli/Shaarli/issues/121)
|
||||||
|
@ -58,7 +54,7 @@ Please report any problem you might find.
|
||||||
* starting from branch ` master`, switch to a new branch (eg. `git checkout -b my-awesome-feature`)
|
* starting from branch ` master`, switch to a new branch (eg. `git checkout -b my-awesome-feature`)
|
||||||
* edit the required files (from the Github web interface or your text editor)
|
* edit the required files (from the Github web interface or your text editor)
|
||||||
* add and commit your changes with a meaningful commit message (eg `Cool new feature, fixes issue #1001`)
|
* add and commit your changes with a meaningful commit message (eg `Cool new feature, fixes issue #1001`)
|
||||||
* run unit tests against your patched version, see [Running unit tests](https://github.com/shaarli/Shaarli/wiki/Running-unit-tests)
|
* run unit tests against your patched version, see [Running unit tests](https://shaarli.readthedocs.io/en/master/Unit-tests/#run-unit-tests)
|
||||||
* Open your fork in the Github web interface and click the "Compare and Pull Request" button, enter required info and submit your Pull Request.
|
* Open your fork in the Github web interface and click the "Compare and Pull Request" button, enter required info and submit your Pull Request.
|
||||||
|
|
||||||
All changes you will do on the `my-awesome-feature` in the future will be added to your Pull Request. Don't work directly on the master branch, don't do unrelated work on your `my-awesome-feature` branch.
|
All changes you will do on the `my-awesome-feature` in the future will be added to your Pull Request. Don't work directly on the master branch, don't do unrelated work on your `my-awesome-feature` branch.
|
||||||
|
|
60
COPYING
60
COPYING
|
@ -1,72 +1,52 @@
|
||||||
Files: *
|
Files: *
|
||||||
License: zlib/libpng
|
License: zlib/libpng
|
||||||
Copyright: (c) 2011-2015 Sébastien SAUVAGE <sebsauvage@sebsauvage.net>
|
Copyright: (c) 2011-2015 Sébastien SAUVAGE <sebsauvage@sebsauvage.net>
|
||||||
(c) 2011-2015 Alexandre Alapetite <alexandre@alapetite.fr>
|
(c) 2011-2018 The Shaarli Community, see AUTHORS
|
||||||
(c) 2011-2015 David Sferruzza <david.sferruzza@gmail.com>
|
|
||||||
(c) 2011-2015 Christophe HENRY <christophe.henry@sbgodin.fr>
|
|
||||||
(c) 2011-2015 Mathieu Chabanon <git@matchab.fr>
|
|
||||||
(c) 2011-2015 BoboTiG <bobotig@gmail.com>
|
|
||||||
(c) 2011-2015 Bronco <bronco@warriordudimanche.net>
|
|
||||||
(c) 2011-2015 Emilien Klein <emilien@klein.st>
|
|
||||||
(c) 2011-2015 Knah Tsaeb <knah-tsaeb@knah-tsaeb.org>
|
|
||||||
(c) 2011-2015 Lionel Martin <renarddesmers@gmail.com>
|
|
||||||
(c) 2011-2015 lehollandaisvolant <levoltigeurhollandais@gmail.com>
|
|
||||||
(c) 2011-2015 timo van neerden <fire@lehollandaisvolant.net>
|
|
||||||
(c) 2011-2015 nodiscc <nodiscc@gmail.com>
|
|
||||||
(c) 2011-2015 Florian Eula <mr.pikzen@gmail.com>
|
|
||||||
(c) 2011-2015 Arthur Hoaro <arthur@hoa.ro>
|
|
||||||
(c) 2011-2015 Aurélien "VirtualTam" Tamisier <virtualtam@flibidi.net>
|
|
||||||
(c) 2011-2015 qwertygc <champlywood@free.fr>
|
|
||||||
(c) 2011-2015 idleman <idleman@idleman.fr>
|
|
||||||
(c) 2015 Alexis Ju <alexis@effingo.be>
|
|
||||||
(c) 2015 dimtion <zizou.xena@gmail.com>
|
|
||||||
(c) 2015 Fanch <fanch-github@qth.fr>
|
|
||||||
(c) 2015 Guillaume Virlet <github@virlet.org>
|
|
||||||
(c) 2015 Felix Bartels <felix@host-consultants.de>
|
|
||||||
(c) 2015 Marsup <marsup@gmail.com>
|
|
||||||
(c) 2015 Miloš Jovanović <mjovanovic@gmail.com>
|
|
||||||
(c) 2015 Nicolás Danelón <hola@nicolasdanelon.com.ar>
|
|
||||||
(c) 2015 TsT <tst2005@gmail.com>
|
|
||||||
|
|
||||||
|
Files: assets/vintage/css/reset.css
|
||||||
Files: inc/reset.css
|
|
||||||
License: BSD (http://opensource.org/licenses/BSD-3-Clause)
|
License: BSD (http://opensource.org/licenses/BSD-3-Clause)
|
||||||
Copyright: (c) 2010, Yahoo! Inc.
|
Copyright: (c) 2010, Yahoo! Inc.
|
||||||
|
|
||||||
Files: images/calendar.png, images/edit_icon.png, images/feed-icon-14x14.png, images/private.png, images/private_16x16.png, images/private_16x16_active.png, images/tag_blue.png
|
Files: assets/vintage/img/calendar.png
|
||||||
|
assets/vintage/img/edit_icon.png
|
||||||
|
assets/vintage/img/feed-icon-14x14.png
|
||||||
|
assets/vintage/img/private.png
|
||||||
|
assets/vintage/img/private_16x16.png
|
||||||
|
assets/vintage/img/private_16x16_active.png
|
||||||
|
assets/vintage/img/tag_blue.png
|
||||||
License: CC-BY (http://creativecommons.org/licenses/by/3.0/)
|
License: CC-BY (http://creativecommons.org/licenses/by/3.0/)
|
||||||
Copyright: (c) 2014 Yusuke Kamiyamane
|
Copyright: (c) 2014 Yusuke Kamiyamane
|
||||||
Source: http://p.yusukekamiyamane.com/
|
Source: http://p.yusukekamiyamane.com/
|
||||||
|
|
||||||
Files: images/delete_icon.png
|
Files: assets/vintage/img/delete_icon.png
|
||||||
License: CC-BY (http://creativecommons.org/licenses/by/3.0/)
|
License: CC-BY (http://creativecommons.org/licenses/by/3.0/)
|
||||||
Copyright: (c) 2014 Designmodo
|
Copyright: (c) 2014 Designmodo
|
||||||
Source: http://designmodo.com/linecons-free/
|
Source: http://designmodo.com/linecons-free/
|
||||||
|
|
||||||
Files: images/floral_left.png, images/floral_right.png, images/squiggle.png, images/squiggle2.png, images/squiggle_closing.png
|
Files: assets/vintage/img/floral_left.png
|
||||||
|
assets/vintage/img/floral_right.png
|
||||||
|
assets/vintage/img/squiggle.png
|
||||||
|
assets/vintage/img/squiggle_closing.png
|
||||||
Licence: Public Domain
|
Licence: Public Domain
|
||||||
Source: https://openclipart.org/people/j4p4n/j4p4n_ornimental_bookend_-_left.svg
|
Source: https://openclipart.org/people/j4p4n/j4p4n_ornimental_bookend_-_left.svg
|
||||||
|
|
||||||
Files: images/Paper_texture_v5_by_bashcorpo_w1000.jpg
|
Files: assets/vintage/img/Paper_texture_v5_by_bashcorpo_w1000.jpg
|
||||||
Licence: Public Domain
|
Licence: Public Domain
|
||||||
Source: http://bashcorpo.deviantart.com/art/Grungy-paper-texture-v-5-22966998
|
Source: http://bashcorpo.deviantart.com/art/Grungy-paper-texture-v-5-22966998
|
||||||
|
|
||||||
Files: images/logo.png
|
Files: assets/vintage/img/logo.png
|
||||||
|
assets/vintage/img/logo.png
|
||||||
License: zlib/libpng
|
License: zlib/libpng
|
||||||
Copyright: (c) 2011-2014 idleman idleman@idleman.fr
|
Copyright: (c) 2011-2014 idleman idleman@idleman.fr
|
||||||
|
|
||||||
Files: inc/blazy*.js
|
Files: assets/default/img/sad_star.png
|
||||||
License: MIT License (http://opensource.org/licenses/MIT)
|
License: MIT License (http://opensource.org/licenses/MIT)
|
||||||
Copyright: (C) Bjoern Klinggaard - @bklinggaard - http://dinbror.dk/blazy
|
Copyright: (C) 2015 kalvn - https://github.com/kalvn/Shaarli-Material
|
||||||
|
|
||||||
Files: inc/rain.tpl.class.php
|
Files: inc/rain.tpl.class.php
|
||||||
|
License: LGPL-3+ (https://www.gnu.org/licenses/lgpl-3.0.txt)
|
||||||
Copyright: 2011-2012, Federico Ulfo <rainelemental@gmail.com>
|
Copyright: 2011-2012, Federico Ulfo <rainelemental@gmail.com>
|
||||||
2011-2012, The Rain Team <hello@raintm.com>
|
2011-2012, The Rain Team <hello@raintm.com>
|
||||||
License: LGPL-3+ (https://www.gnu.org/licenses/lgpl-3.0.txt)
|
|
||||||
|
|
||||||
Files: inc/awesomplete*
|
|
||||||
License: MIT License (http://opensource.org/licenses/MIT)
|
|
||||||
Copyright: (C) 2015 Lea Verou - https://github.com/LeaVerou/awesomplete
|
|
||||||
|
|
||||||
Files: plugins/wallabag/wallabag.png
|
Files: plugins/wallabag/wallabag.png
|
||||||
License: MIT License (http://opensource.org/licenses/MIT)
|
License: MIT License (http://opensource.org/licenses/MIT)
|
||||||
|
|
74
Dockerfile
Normal file
74
Dockerfile
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
# Stage 1:
|
||||||
|
# - Copy Shaarli sources
|
||||||
|
# - Build documentation
|
||||||
|
FROM python:3-alpine as docs
|
||||||
|
ADD . /usr/src/app/shaarli
|
||||||
|
RUN cd /usr/src/app/shaarli \
|
||||||
|
&& apk add --no-cache gcc musl-dev make bash \
|
||||||
|
&& make htmldoc
|
||||||
|
|
||||||
|
# Stage 2:
|
||||||
|
# - Resolve PHP dependencies with Composer
|
||||||
|
FROM composer:latest as composer
|
||||||
|
COPY --from=docs /usr/src/app/shaarli /app/shaarli
|
||||||
|
RUN cd shaarli \
|
||||||
|
&& composer --prefer-dist --no-dev install
|
||||||
|
|
||||||
|
# Stage 3:
|
||||||
|
# - Frontend dependencies
|
||||||
|
FROM node:12-alpine as node
|
||||||
|
COPY --from=composer /app/shaarli shaarli
|
||||||
|
RUN cd shaarli \
|
||||||
|
&& yarnpkg install \
|
||||||
|
&& yarnpkg run build \
|
||||||
|
&& rm -rf node_modules
|
||||||
|
|
||||||
|
# Stage 4:
|
||||||
|
# - Shaarli image
|
||||||
|
FROM alpine:3.16.7
|
||||||
|
LABEL maintainer="Shaarli Community"
|
||||||
|
|
||||||
|
RUN apk --update --no-cache add \
|
||||||
|
ca-certificates \
|
||||||
|
nginx \
|
||||||
|
php8 \
|
||||||
|
php8-ctype \
|
||||||
|
php8-curl \
|
||||||
|
php8-fpm \
|
||||||
|
php8-gd \
|
||||||
|
php8-gettext \
|
||||||
|
php8-iconv \
|
||||||
|
php8-intl \
|
||||||
|
php8-json \
|
||||||
|
php8-ldap \
|
||||||
|
php8-mbstring \
|
||||||
|
php8-openssl \
|
||||||
|
php8-session \
|
||||||
|
php8-xml \
|
||||||
|
php8-simplexml \
|
||||||
|
php8-zlib \
|
||||||
|
s6
|
||||||
|
|
||||||
|
COPY .docker/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY .docker/php-fpm.conf /etc/php8/php-fpm.conf
|
||||||
|
COPY .docker/services.d /etc/services.d
|
||||||
|
|
||||||
|
RUN rm -rf /etc/php8/php-fpm.d/www.conf \
|
||||||
|
&& sed -i 's/post_max_size.*/post_max_size = 10M/' /etc/php8/php.ini \
|
||||||
|
&& sed -i 's/upload_max_filesize.*/upload_max_filesize = 10M/' /etc/php8/php.ini
|
||||||
|
|
||||||
|
|
||||||
|
WORKDIR /var/www
|
||||||
|
COPY --from=node /shaarli shaarli
|
||||||
|
|
||||||
|
RUN chown -R nginx:nginx . \
|
||||||
|
&& ln -sf /dev/stdout /var/log/nginx/shaarli.access.log \
|
||||||
|
&& ln -sf /dev/stderr /var/log/nginx/shaarli.error.log
|
||||||
|
|
||||||
|
VOLUME /var/www/shaarli/cache
|
||||||
|
VOLUME /var/www/shaarli/data
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
ENTRYPOINT ["/bin/s6-svscan", "/etc/services.d"]
|
||||||
|
CMD []
|
250
Makefile
250
Makefile
|
@ -1,30 +1,19 @@
|
||||||
# The personal, minimalist, super-fast, database free, bookmarking service.
|
# The personal, minimalist, super fast, database-free, bookmarking service.
|
||||||
# Makefile for PHP code analysis & testing, documentation and release generation
|
# Makefile for PHP code analysis & testing, documentation and release generation
|
||||||
|
|
||||||
# Prerequisites:
|
|
||||||
# - install Composer, either:
|
|
||||||
# - from your distro's package manager;
|
|
||||||
# - from the official website (https://getcomposer.org/download/);
|
|
||||||
# - install/update test dependencies:
|
|
||||||
# $ composer install # 1st setup
|
|
||||||
# $ composer update
|
|
||||||
# - install Xdebug for PHPUnit code coverage reports:
|
|
||||||
# - see http://xdebug.org/docs/install
|
|
||||||
# - enable in php.ini
|
|
||||||
|
|
||||||
BIN = vendor/bin
|
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
|
all: check_permissions test
|
||||||
|
|
||||||
##
|
##
|
||||||
# Concise status of the project
|
# Docker test adapter
|
||||||
# These targets are non-blocking: || exit 0
|
#
|
||||||
|
# Shaarli sources and vendored libraries are copied from a shared volume
|
||||||
|
# to a user-owned directory to enable running tests as a non-root user.
|
||||||
##
|
##
|
||||||
|
docker_%:
|
||||||
static_analysis_summary: code_sniffer_source copy_paste mess_detector_summary
|
rsync -az /shaarli/ ~/shaarli/
|
||||||
@echo
|
cd ~/shaarli && make $*
|
||||||
|
|
||||||
##
|
##
|
||||||
# PHP_CodeSniffer
|
# PHP_CodeSniffer
|
||||||
|
@ -33,70 +22,29 @@ 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.usage.php
|
||||||
# - http://pear.php.net/manual/en/package.php.php-codesniffer.reporting.php
|
# - http://pear.php.net/manual/en/package.php.php-codesniffer.reporting.php
|
||||||
##
|
##
|
||||||
|
PHPCS := $(BIN)/phpcs
|
||||||
|
|
||||||
code_sniffer: code_sniffer_full
|
# Use GNU Tar where available
|
||||||
|
ifneq (, $(shell which gtar))
|
||||||
|
TAR := gtar
|
||||||
|
else
|
||||||
|
TAR := tar
|
||||||
|
endif
|
||||||
|
|
||||||
### - errors filtered by coding standard: PEAR, PSR1, PSR2, Zend...
|
code_sniffer:
|
||||||
PHPCS_%:
|
@$(PHPCS)
|
||||||
@$(BIN)/phpcs $(PHP_SOURCE) --report-full --report-width=200 --standard=$*
|
|
||||||
|
|
||||||
### - errors by Git author
|
### - errors by Git author
|
||||||
code_sniffer_blame:
|
code_sniffer_blame:
|
||||||
@$(BIN)/phpcs $(PHP_SOURCE) --report-gitblame
|
@$(PHPCS) --report-gitblame
|
||||||
|
|
||||||
### - all errors/warnings
|
### - all errors/warnings
|
||||||
code_sniffer_full:
|
code_sniffer_full:
|
||||||
@$(BIN)/phpcs $(PHP_SOURCE) --report-full --report-width=200
|
@$(PHPCS) --report-full --report-width=200
|
||||||
|
|
||||||
### - errors grouped by kind
|
### - errors grouped by kind
|
||||||
code_sniffer_source:
|
code_sniffer_source:
|
||||||
@$(BIN)/phpcs $(PHP_SOURCE) --report-source || exit 0
|
@$(PHPCS) --report-source || exit 0
|
||||||
|
|
||||||
##
|
|
||||||
# PHP Copy/Paste Detector
|
|
||||||
# Detects code redundancy
|
|
||||||
# Documentation: https://github.com/sebastianbergmann/phpcpd
|
|
||||||
##
|
|
||||||
|
|
||||||
copy_paste:
|
|
||||||
@echo "-----------------------"
|
|
||||||
@echo "PHP COPY/PASTE DETECTOR"
|
|
||||||
@echo "-----------------------"
|
|
||||||
@$(BIN)/phpcpd $(PHP_SOURCE) || exit 0
|
|
||||||
@echo
|
|
||||||
|
|
||||||
##
|
|
||||||
# PHP Mess Detector
|
|
||||||
# Detects PHP syntax errors, sorted by category
|
|
||||||
# Rules documentation: http://phpmd.org/rules/index.html
|
|
||||||
##
|
|
||||||
MESS_DETECTOR_RULES = cleancode,codesize,controversial,design,naming,unusedcode
|
|
||||||
|
|
||||||
mess_title:
|
|
||||||
@echo "-----------------"
|
|
||||||
@echo "PHP MESS DETECTOR"
|
|
||||||
@echo "-----------------"
|
|
||||||
|
|
||||||
### - all warnings
|
|
||||||
mess_detector: mess_title
|
|
||||||
@$(BIN)/phpmd $(PHP_COMMA_SOURCE) text $(MESS_DETECTOR_RULES) | sed 's_.*\/__'
|
|
||||||
|
|
||||||
### - all warnings + HTML output contains links to PHPMD's documentation
|
|
||||||
mess_detector_html:
|
|
||||||
@$(BIN)/phpmd $(PHP_COMMA_SOURCE) html $(MESS_DETECTOR_RULES) \
|
|
||||||
--reportfile phpmd.html || exit 0
|
|
||||||
|
|
||||||
### - warnings grouped by message, sorted by descending frequency order
|
|
||||||
mess_detector_grouped: mess_title
|
|
||||||
@$(BIN)/phpmd $(PHP_SOURCE) text $(MESS_DETECTOR_RULES) \
|
|
||||||
| cut -f 2 | sort | uniq -c | sort -nr
|
|
||||||
|
|
||||||
### - summary: number of warnings by rule set
|
|
||||||
mess_detector_summary: mess_title
|
|
||||||
@for rule in $$(echo $(MESS_DETECTOR_RULES) | tr ',' ' '); do \
|
|
||||||
warnings=$$($(BIN)/phpmd $(PHP_COMMA_SOURCE) text $$rule | wc -l); \
|
|
||||||
printf "$$warnings\t$$rule\n"; \
|
|
||||||
done;
|
|
||||||
|
|
||||||
##
|
##
|
||||||
# Checks source file & script permissions
|
# Checks source file & script permissions
|
||||||
|
@ -105,7 +53,7 @@ check_permissions:
|
||||||
@echo "----------------------"
|
@echo "----------------------"
|
||||||
@echo "Check file permissions"
|
@echo "Check file permissions"
|
||||||
@echo "----------------------"
|
@echo "----------------------"
|
||||||
@for file in `git ls-files`; do \
|
@for file in `git ls-files | grep -v docker`; do \
|
||||||
if [ -x $$file ]; then \
|
if [ -x $$file ]; then \
|
||||||
errors=true; \
|
errors=true; \
|
||||||
echo "$${file} is executable"; \
|
echo "$${file} is executable"; \
|
||||||
|
@ -120,12 +68,29 @@ check_permissions:
|
||||||
# See phpunit.xml for configuration
|
# See phpunit.xml for configuration
|
||||||
# https://phpunit.de/manual/current/en/appendixes.configuration.html
|
# https://phpunit.de/manual/current/en/appendixes.configuration.html
|
||||||
##
|
##
|
||||||
test:
|
test: translate
|
||||||
@echo "-------"
|
@echo "-------"
|
||||||
@echo "PHPUNIT"
|
@echo "PHPUNIT"
|
||||||
@echo "-------"
|
@echo "-------"
|
||||||
@mkdir -p sandbox
|
@mkdir -p sandbox coverage
|
||||||
@$(BIN)/phpunit tests
|
@$(BIN)/phpunit --coverage-php coverage/main.cov --bootstrap tests/bootstrap.php --testsuite unit-tests
|
||||||
|
|
||||||
|
locale_test_%:
|
||||||
|
@UT_LOCALE=$*.utf8 \
|
||||||
|
$(BIN)/phpunit \
|
||||||
|
--coverage-php coverage/$(firstword $(subst _, ,$*)).cov \
|
||||||
|
--bootstrap tests/languages/bootstrap.php \
|
||||||
|
--testsuite language-$(firstword $(subst _, ,$*))
|
||||||
|
|
||||||
|
all_tests: test locale_test_de_DE locale_test_en_US locale_test_fr_FR
|
||||||
|
@# --The current version is not compatible with PHP 7.2
|
||||||
|
@#$(BIN)/phpcov merge --html coverage coverage
|
||||||
|
@# --text doesn't work with phpunit 4.* (v5 requires PHP 5.6)
|
||||||
|
@#$(BIN)/phpcov merge --text coverage/txt coverage
|
||||||
|
|
||||||
|
### download 3rd-party PHP libraries, including dev dependencies
|
||||||
|
composer_dependencies_dev: clean
|
||||||
|
composer install --prefer-dist
|
||||||
|
|
||||||
##
|
##
|
||||||
# Custom release archive generation
|
# Custom release archive generation
|
||||||
|
@ -143,21 +108,36 @@ release_archive: release_tar release_zip
|
||||||
|
|
||||||
### download 3rd-party PHP libraries
|
### download 3rd-party PHP libraries
|
||||||
composer_dependencies: clean
|
composer_dependencies: clean
|
||||||
composer update --no-dev
|
composer install --no-dev --prefer-dist
|
||||||
find vendor/ -name ".git" -type d -exec rm -rf {} +
|
find vendor/ -name ".git" -type d -exec rm -rf {} +
|
||||||
|
|
||||||
### generate a release tarball and include 3rd-party dependencies
|
### download 3rd-party frontend libraries
|
||||||
release_tar: composer_dependencies
|
frontend_dependencies:
|
||||||
|
yarnpkg install
|
||||||
|
|
||||||
|
### Build frontend dependencies
|
||||||
|
build_frontend: frontend_dependencies
|
||||||
|
yarnpkg run build
|
||||||
|
|
||||||
|
### generate a release tarball and include 3rd-party dependencies and translations
|
||||||
|
release_tar: composer_dependencies htmldoc translate build_frontend
|
||||||
git archive --prefix=$(ARCHIVE_PREFIX) -o $(ARCHIVE_VERSION).tar HEAD
|
git archive --prefix=$(ARCHIVE_PREFIX) -o $(ARCHIVE_VERSION).tar HEAD
|
||||||
tar rvf $(ARCHIVE_VERSION).tar --transform "s|^vendor|$(ARCHIVE_PREFIX)vendor|" vendor/
|
$(TAR) rvf $(ARCHIVE_VERSION).tar --transform "s|^vendor|$(ARCHIVE_PREFIX)vendor|" vendor/
|
||||||
|
$(TAR) rvf $(ARCHIVE_VERSION).tar --transform "s|^doc/html|$(ARCHIVE_PREFIX)doc/html|" doc/html/
|
||||||
|
$(TAR) rvf $(ARCHIVE_VERSION).tar --transform "s|^tpl|$(ARCHIVE_PREFIX)tpl|" tpl/
|
||||||
gzip $(ARCHIVE_VERSION).tar
|
gzip $(ARCHIVE_VERSION).tar
|
||||||
|
|
||||||
### generate a release zip and include 3rd-party dependencies
|
### generate a release zip and include 3rd-party dependencies and translations
|
||||||
release_zip: composer_dependencies
|
release_zip: composer_dependencies htmldoc translate build_frontend
|
||||||
git archive --prefix=$(ARCHIVE_PREFIX) -o $(ARCHIVE_VERSION).zip -9 HEAD
|
git archive --prefix=$(ARCHIVE_PREFIX) -o $(ARCHIVE_VERSION).zip -9 HEAD
|
||||||
mkdir $(ARCHIVE_PREFIX)
|
mkdir -p $(ARCHIVE_PREFIX)/doc
|
||||||
|
mkdir -p $(ARCHIVE_PREFIX)/vendor
|
||||||
|
rsync -a doc/html/ $(ARCHIVE_PREFIX)doc/html/
|
||||||
|
zip -r $(ARCHIVE_VERSION).zip $(ARCHIVE_PREFIX)doc/
|
||||||
rsync -a vendor/ $(ARCHIVE_PREFIX)vendor/
|
rsync -a vendor/ $(ARCHIVE_PREFIX)vendor/
|
||||||
zip -r $(ARCHIVE_VERSION).zip $(ARCHIVE_PREFIX)vendor/
|
zip -r $(ARCHIVE_VERSION).zip $(ARCHIVE_PREFIX)vendor/
|
||||||
|
rsync -a tpl/ $(ARCHIVE_PREFIX)tpl/
|
||||||
|
zip -r $(ARCHIVE_VERSION).zip $(ARCHIVE_PREFIX)tpl/
|
||||||
rm -rf $(ARCHIVE_PREFIX)
|
rm -rf $(ARCHIVE_PREFIX)
|
||||||
|
|
||||||
##
|
##
|
||||||
|
@ -167,51 +147,69 @@ release_zip: composer_dependencies
|
||||||
### remove all unversioned files
|
### remove all unversioned files
|
||||||
clean:
|
clean:
|
||||||
@git clean -df
|
@git clean -df
|
||||||
@rm -rf sandbox
|
@rm -rf sandbox trivy*
|
||||||
|
|
||||||
### generate Doxygen documentation
|
### generate the AUTHORS file from Git commit information
|
||||||
doxygen: clean
|
generate_authors:
|
||||||
@rm -rf doxygen
|
@cp .github/mailmap .mailmap
|
||||||
@( cat Doxyfile ; echo "PROJECT_NUMBER=`git describe`" ) | doxygen -
|
@git shortlog -sne > AUTHORS
|
||||||
|
@rm .mailmap
|
||||||
|
|
||||||
### update the local copy of the documentation
|
### generate phpDocumentor documentation
|
||||||
doc: clean
|
phpdoc: clean
|
||||||
@rm -rf doc
|
@docker run --rm -v $(PWD):/data -u `id -u`:`id -g` phpdoc/phpdoc
|
||||||
@git clone https://github.com/shaarli/Shaarli.wiki.git doc
|
|
||||||
@rm -rf doc/.git
|
|
||||||
|
|
||||||
### Generate a custom sidebar
|
### generate HTML documentation from Markdown pages with Sphinx
|
||||||
#
|
htmldoc:
|
||||||
# Sidebar content:
|
python3 -m venv venv/
|
||||||
# - convert GitHub-flavoured relative links to standard Markdown
|
bash -c 'source venv/bin/activate; \
|
||||||
# - trim HTML, only keep the list (<ul>[...]</ul>) part
|
pip install wheel; \
|
||||||
htmlsidebar:
|
pip install sphinx==7.1.0 furo==2023.7.26 myst-parser sphinx-design; \
|
||||||
@echo '<div id="local-sidebar">' > doc/sidebar.html
|
sphinx-build -b html -c doc/ doc/md/ doc/html/'
|
||||||
@awk 'BEGIN { FS = "[\\[\\]]{2}" }'\
|
find doc/html/ -type f -exec chmod a-x '{}' \;
|
||||||
'm = /\[/ { t=$$2; gsub(/ /, "-", $$2); print $$1"["t"]("$$2".html)"$$3 }'\
|
rm -r venv
|
||||||
'!m { print $$0 }' doc/_Sidebar.md > doc/tmp.md
|
|
||||||
@pandoc -f markdown -t html5 -s doc/tmp.md | awk '/(ul>|li>)/' >> doc/sidebar.html
|
|
||||||
@echo '</div>' >> doc/sidebar.html
|
|
||||||
@rm doc/tmp.md
|
|
||||||
|
|
||||||
### Convert local markdown documentation to HTML
|
### Generate Shaarli's translation compiled file (.mo)
|
||||||
#
|
translate:
|
||||||
# For all pages:
|
@echo "----------------------"
|
||||||
# - infer title from the file name
|
@echo "Compile translation files"
|
||||||
# - convert GitHub-flavoured relative links to standard Markdown
|
@echo "----------------------"
|
||||||
# - insert the sidebar menu
|
@for pofile in `find inc/languages/ -name shaarli.po`; do \
|
||||||
htmlpages:
|
echo "Compiling $$pofile"; \
|
||||||
@for file in `find doc/ -maxdepth 1 -name "*.md"`; do \
|
msgfmt -v "$$pofile" -o "`dirname "$$pofile"`/`basename "$$pofile" .po`.mo"; \
|
||||||
base=`basename $$file .md`; \
|
|
||||||
sed -i "1i #$${base//-/ }" $$file; \
|
|
||||||
awk 'BEGIN { FS = "[\\[\\]]{2}" }'\
|
|
||||||
'm = /\[/ { t=$$2; gsub(/ /, "-", $$2); print $$1"["t"]("$$2".html)"$$3 }'\
|
|
||||||
'!m { print $$0 }' $$file > doc/tmp.md; \
|
|
||||||
mv doc/tmp.md $$file; \
|
|
||||||
pandoc -f markdown_github -t html5 -s \
|
|
||||||
-c "github-markdown.css" \
|
|
||||||
-T Shaarli -M pagetitle:"$${base//-/ }" -B doc/sidebar.html \
|
|
||||||
-o doc/$$base.html $$file; \
|
|
||||||
done;
|
done;
|
||||||
|
|
||||||
htmldoc: doc htmlsidebar htmlpages
|
### Run ESLint check against Shaarli's JS files
|
||||||
|
eslint:
|
||||||
|
@yarnpkg run eslint -c .dev/.eslintrc.js assets/vintage/js/
|
||||||
|
@yarnpkg run eslint -c .dev/.eslintrc.js assets/default/js/
|
||||||
|
@yarnpkg run eslint -c .dev/.eslintrc.js assets/common/js/
|
||||||
|
|
||||||
|
### Run CSSLint check against Shaarli's SCSS files
|
||||||
|
sasslint:
|
||||||
|
@yarnpkg run stylelint --config .dev/.stylelintrc.js 'assets/default/scss/*.scss'
|
||||||
|
|
||||||
|
##
|
||||||
|
# Security scans
|
||||||
|
##
|
||||||
|
|
||||||
|
# trivy version (https://github.com/aquasecurity/trivy/releases)
|
||||||
|
TRIVY_VERSION=0.44.0
|
||||||
|
# default trivy exit code when vulnerabilities are found
|
||||||
|
TRIVY_EXIT_CODE=1
|
||||||
|
# default docker image to scan with trivy
|
||||||
|
TRIVY_TARGET_DOCKER_IMAGE=ghcr.io/shaarli/shaarli:latest
|
||||||
|
|
||||||
|
### download trivy vulneravbility scanner
|
||||||
|
download_trivy:
|
||||||
|
wget --quiet --continue -O trivy_$(TRIVY_VERSION)_Linux-64bit.tar.gz https://github.com/aquasecurity/trivy/releases/download/v$(TRIVY_VERSION)/trivy_$(TRIVY_VERSION)_Linux-64bit.tar.gz
|
||||||
|
tar -z -x trivy -f trivy_$(TRIVY_VERSION)_Linux-64bit.tar.gz
|
||||||
|
|
||||||
|
### run trivy vulnerability scanner on docker image
|
||||||
|
test_trivy_docker: download_trivy
|
||||||
|
./trivy --exit-code $(TRIVY_EXIT_CODE) image $(TRIVY_TARGET_DOCKER_IMAGE)
|
||||||
|
|
||||||
|
### run trivy vulnerability scanner on composer/yarn dependency trees
|
||||||
|
test_trivy_repo: download_trivy
|
||||||
|
./trivy --exit-code $(TRIVY_EXIT_CODE) fs composer.lock
|
||||||
|
./trivy --exit-code $(TRIVY_EXIT_CODE) fs yarn.lock
|
||||||
|
|
110
README.md
110
README.md
|
@ -1,115 +1,31 @@
|
||||||
![Shaarli logo](doc/images/doc-logo.png)
|
![Shaarli logo](doc/md/images/doc-logo.png)
|
||||||
|
|
||||||
The personal, minimalist, super-fast, database free, bookmarking service.
|
The personal, minimalist, super fast, database-free, bookmarking service.
|
||||||
|
|
||||||
_Do you want to share the links you discover?_
|
_Do you want to share the links you discover?_
|
||||||
_Shaarli is a minimalist delicious clone that you can install on your own server._
|
_Shaarli is a minimalist link sharing service that you can install on your own server._
|
||||||
_It is designed to be personal (single-user), fast and handy._
|
_It is designed to be personal (single-user), fast and handy._
|
||||||
|
|
||||||
[![](https://img.shields.io/travis/shaarli/Shaarli.svg?label=master)](https://travis-ci.org/shaarli/Shaarli)
|
[![](https://img.shields.io/badge/stable-v0.12.2-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1)
|
||||||
[![](https://img.shields.io/travis/shaarli/Shaarli/stable.svg?label=stable)](https://travis-ci.org/shaarli/Shaarli)
|
[![](https://img.shields.io/badge/latest-v0.13.0-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.12.2)
|
||||||
[![](https://img.shields.io/github/release/shaarli/shaarli.svg)](https://github.com/shaarli/Shaarli/releases/latest/)
|
[![](https://img.shields.io/badge/master-v0.13.x-blue.svg)](https://github.com/shaarli/Shaarli)
|
||||||
[![Docker repository](https://img.shields.io/docker/pulls/shaarli/shaarli.svg)](https://hub.docker.com/r/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)
|
[![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://github.com/shaarli/Shaarli/pkgs/container/shaarli)
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
- [Wiki/documentation](https://github.com/shaarli/Shaarli/wiki)
|
|
||||||
|
- [Documentation](https://shaarli.readthedocs.io)
|
||||||
- [Change log](CHANGELOG.md)
|
- [Change log](CHANGELOG.md)
|
||||||
- [Bugs/Feature requests/Discussion](https://github.com/shaarli/Shaarli/issues/)
|
- [Bugs/Feature requests/Discussion](https://github.com/shaarli/Shaarli/issues/)
|
||||||
|
|
||||||
### Demo
|
### Demo
|
||||||
You can use this [public demo instance of Shaarli](http://shaarlidemo.tuxfamily.org/Shaarli).
|
|
||||||
|
You can use this [public demo instance of Shaarli](https://demo.shaarli.org).
|
||||||
It runs the latest development version of Shaarli and is updated/reset daily.
|
It runs the latest development version of Shaarli and is updated/reset daily.
|
||||||
|
|
||||||
Login: `demo`; Password: `demo`
|
Login: `demo`; Password: `demo`
|
||||||
|
|
||||||
### Installation & upgrade
|
|
||||||
- [Download and installation](https://github.com/shaarli/Shaarli/wiki/Download-and-Installation)
|
|
||||||
- [Upgrade and migration](https://github.com/shaarli/Shaarli/wiki/Upgrade-and-migration)
|
|
||||||
- [Server requirements](https://github.com/shaarli/Shaarli/wiki/Server-requirements)
|
|
||||||
- [Server configuration](https://github.com/shaarli/Shaarli/wiki/Server-configuration)
|
|
||||||
- [Shaarli configuration](https://github.com/shaarli/Shaarli/wiki/Shaarli-configuration)
|
|
||||||
|
|
||||||
## Features
|
|
||||||
### Interface
|
|
||||||
- minimalist design (simple is beautiful)
|
|
||||||
- FAST
|
|
||||||
- ATOM and RSS feeds
|
|
||||||
- views:
|
|
||||||
- paginated link list
|
|
||||||
- tag cloud
|
|
||||||
- picture wall: image and video thumbnails
|
|
||||||
- daily: newspaper-like daily digest
|
|
||||||
- daily RSS feed
|
|
||||||
- permalinks for easy reference
|
|
||||||
- links can be public or private
|
|
||||||
- extensible through [plugins](https://github.com/shaarli/Shaarli/wiki/Plugins#plugin-usage)
|
|
||||||
|
|
||||||
### Tag, view and search your links!
|
|
||||||
- add a custom title and description to archived links
|
|
||||||
- add tags to classify and search links
|
|
||||||
- features tag autocompletion, renaming, merging and deletion
|
|
||||||
- full-text and tag search
|
|
||||||
|
|
||||||
### Easy setup
|
|
||||||
- dead-simple installation: drop the files, open the page
|
|
||||||
- links are stored in a file
|
|
||||||
- compact storage
|
|
||||||
- no database required
|
|
||||||
- easy backup: simply copy the datastore file
|
|
||||||
- import and export links as Netscape bookmarks
|
|
||||||
|
|
||||||
### Accessibility
|
|
||||||
- Firefox bookmarlet to share links in one click
|
|
||||||
- support for mobile browsers
|
|
||||||
- works with Javascript disabled
|
|
||||||
- easy page customization through HTML/CSS/RainTPL
|
|
||||||
|
|
||||||
### Security
|
|
||||||
- bruteforce-proof login form
|
|
||||||
- protected against [XSRF](http://en.wikipedia.org/wiki/Cross-site_request_forgery)
|
|
||||||
and session cookie hijacking
|
|
||||||
|
|
||||||
### Goodies
|
|
||||||
- thumbnail generation for images and video services:
|
|
||||||
dailymotion, flickr, imageshack, imgur, vimeo, xkcd, youtube...
|
|
||||||
- lazy-loading with [bLazy](http://dinbror.dk/blazy/)
|
|
||||||
- [PubSubHubbub](https://code.google.com/p/pubsubhubbub/) protocol support
|
|
||||||
- URL cleanup: automatic removal of `?utm_source=...`, `fb=...`
|
|
||||||
- discreet pop-up notification when a new release is available
|
|
||||||
|
|
||||||
### Other usages
|
|
||||||
Though Shaarli is primarily a bookmarking application, it can serve other purposes
|
|
||||||
(see [usage examples](https://github.com/shaarli/Shaarli/wiki#usage-examples)):
|
|
||||||
- micro-blogging
|
|
||||||
- pastebin
|
|
||||||
- online notepad
|
|
||||||
- snippet archive
|
|
||||||
|
|
||||||
## About
|
|
||||||
### Shaarli community fork
|
|
||||||
This friendly fork is maintained by the Shaarli community at https://github.com/shaarli/Shaarli
|
|
||||||
|
|
||||||
This is a community fork of the original [Shaarli](https://github.com/sebsauvage/Shaarli/) project by [Sébastien Sauvage](http://sebsauvage.net/).
|
|
||||||
|
|
||||||
The original project is currently unmaintained, and the developer [has informed us](https://github.com/sebsauvage/Shaarli/issues/191)
|
|
||||||
that he would have no time to work on Shaarli in the near future.
|
|
||||||
The Shaarli community has carried on the work to provide
|
|
||||||
[many patches](https://github.com/shaarli/Shaarli/compare/sebsauvage:master...master)
|
|
||||||
for [bug fixes and enhancements](https://github.com/shaarli/Shaarli/issues?q=is%3Aclosed+)
|
|
||||||
in this repository, and will keep maintaining the project for the foreseeable future, while keeping Shaarli simple and efficient.
|
|
||||||
|
|
||||||
### Contributing
|
|
||||||
If you'd like to help, please:
|
|
||||||
- have a look at the open [issues](https://github.com/shaarli/Shaarli/issues)
|
|
||||||
and [pull requests](https://github.com/shaarli/Shaarli/pulls)
|
|
||||||
- feel free to report bugs (feedback is much appreciated)
|
|
||||||
- suggest new features and improvements to both code and [documentation](https://github.com/shaarli/Shaarli/wiki)
|
|
||||||
- propose solutions to existing problems
|
|
||||||
- submit pull requests :-)
|
|
||||||
|
|
||||||
|
|
||||||
### License
|
### License
|
||||||
|
|
||||||
Shaarli is [Free Software](http://en.wikipedia.org/wiki/Free_software). See [COPYING](COPYING) for a detail of the contributors and licenses for each individual component.
|
Shaarli is [Free Software](http://en.wikipedia.org/wiki/Free_software). See [COPYING](COPYING) for a detail of the contributors and licenses for each individual component.
|
||||||
|
|
|
@ -1,198 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Shaarli (application) utilities
|
|
||||||
*/
|
|
||||||
class ApplicationUtils
|
|
||||||
{
|
|
||||||
private static $GIT_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli';
|
|
||||||
private static $GIT_BRANCHES = array('master', 'stable');
|
|
||||||
private static $VERSION_FILE = 'shaarli_version.php';
|
|
||||||
private static $VERSION_START_TAG = '<?php /* ';
|
|
||||||
private static $VERSION_END_TAG = ' */ ?>';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the latest version code from the Git repository
|
|
||||||
*
|
|
||||||
* The code is read from the raw content of the version file on the Git server.
|
|
||||||
*
|
|
||||||
* @param string $url URL to reach to get the latest version.
|
|
||||||
* @param int $timeout Timeout to check the URL (in seconds).
|
|
||||||
*
|
|
||||||
* @return mixed the version code from the repository if available, else 'false'
|
|
||||||
*/
|
|
||||||
public static function getLatestGitVersionCode($url, $timeout=2)
|
|
||||||
{
|
|
||||||
list($headers, $data) = get_http_response($url, $timeout);
|
|
||||||
|
|
||||||
if (strpos($headers[0], '200 OK') === false) {
|
|
||||||
error_log('Failed to retrieve ' . $url);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return str_replace(
|
|
||||||
array(self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL),
|
|
||||||
array('', '', ''),
|
|
||||||
$data
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a new Shaarli version has been published on the Git repository
|
|
||||||
*
|
|
||||||
* Updates checks are run periodically, according to the following criteria:
|
|
||||||
* - the update checks are enabled (install, global config);
|
|
||||||
* - the user is logged in (or this is an open instance);
|
|
||||||
* - the last check is older than a given interval;
|
|
||||||
* - the check is non-blocking if the HTTPS connection to Git fails;
|
|
||||||
* - in case of failure, the update file's modification date is updated,
|
|
||||||
* to avoid intempestive connection attempts.
|
|
||||||
*
|
|
||||||
* @param string $currentVersion the current version code
|
|
||||||
* @param string $updateFile the file where to store the latest version code
|
|
||||||
* @param int $checkInterval the minimum interval between update checks (in seconds
|
|
||||||
* @param bool $enableCheck whether to check for new versions
|
|
||||||
* @param bool $isLoggedIn whether the user is logged in
|
|
||||||
* @param string $branch check update for the given branch
|
|
||||||
*
|
|
||||||
* @throws Exception an invalid branch has been set for update checks
|
|
||||||
*
|
|
||||||
* @return mixed the new version code if available and greater, else 'false'
|
|
||||||
*/
|
|
||||||
public static function checkUpdate($currentVersion,
|
|
||||||
$updateFile,
|
|
||||||
$checkInterval,
|
|
||||||
$enableCheck,
|
|
||||||
$isLoggedIn,
|
|
||||||
$branch='stable')
|
|
||||||
{
|
|
||||||
if (! $isLoggedIn) {
|
|
||||||
// Do not check versions for visitors
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($enableCheck)) {
|
|
||||||
// Do not check if the user doesn't want to
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_file($updateFile) && (filemtime($updateFile) > time() - $checkInterval)) {
|
|
||||||
// Shaarli has checked for updates recently - skip HTTP query
|
|
||||||
$latestKnownVersion = file_get_contents($updateFile);
|
|
||||||
|
|
||||||
if (version_compare($latestKnownVersion, $currentVersion) == 1) {
|
|
||||||
return $latestKnownVersion;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array($branch, self::$GIT_BRANCHES)) {
|
|
||||||
throw new Exception(
|
|
||||||
'Invalid branch selected for updates: "' . $branch . '"'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Late Static Binding allows overriding within tests
|
|
||||||
// See http://php.net/manual/en/language.oop5.late-static-bindings.php
|
|
||||||
$latestVersion = static::getLatestGitVersionCode(
|
|
||||||
self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $latestVersion) {
|
|
||||||
// Only update the file's modification date
|
|
||||||
file_put_contents($updateFile, $currentVersion);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the file's content and modification date
|
|
||||||
file_put_contents($updateFile, $latestVersion);
|
|
||||||
|
|
||||||
if (version_compare($latestVersion, $currentVersion) == 1) {
|
|
||||||
return $latestVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks the PHP version to ensure Shaarli can run
|
|
||||||
*
|
|
||||||
* @param string $minVersion minimum PHP required version
|
|
||||||
* @param string $curVersion current PHP version (use PHP_VERSION)
|
|
||||||
*
|
|
||||||
* @throws Exception the PHP version is not supported
|
|
||||||
*/
|
|
||||||
public static function checkPHPVersion($minVersion, $curVersion)
|
|
||||||
{
|
|
||||||
if (version_compare($curVersion, $minVersion) < 0) {
|
|
||||||
throw new Exception(
|
|
||||||
'Your PHP version is obsolete!'
|
|
||||||
.' Shaarli requires at least PHP '.$minVersion.', and thus cannot run.'
|
|
||||||
.' Your PHP version has known security vulnerabilities and should be'
|
|
||||||
.' updated as soon as possible.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks Shaarli has the proper access permissions to its resources
|
|
||||||
*
|
|
||||||
* @param ConfigManager $conf Configuration Manager instance.
|
|
||||||
*
|
|
||||||
* @return array A list of the detected configuration issues
|
|
||||||
*/
|
|
||||||
public static function checkResourcePermissions($conf)
|
|
||||||
{
|
|
||||||
$errors = array();
|
|
||||||
|
|
||||||
// Check script and template directories are readable
|
|
||||||
foreach (array(
|
|
||||||
'application',
|
|
||||||
'inc',
|
|
||||||
'plugins',
|
|
||||||
$conf->get('resource.raintpl_tpl'),
|
|
||||||
$conf->get('resource.raintpl_tpl').'/'.$conf->get('resource.theme'),
|
|
||||||
) as $path) {
|
|
||||||
if (! is_readable(realpath($path))) {
|
|
||||||
$errors[] = '"'.$path.'" directory is not readable';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check cache and data directories are readable and writable
|
|
||||||
foreach (array(
|
|
||||||
$conf->get('resource.thumbnails_cache'),
|
|
||||||
$conf->get('resource.data_dir'),
|
|
||||||
$conf->get('resource.page_cache'),
|
|
||||||
$conf->get('resource.raintpl_tmp'),
|
|
||||||
) as $path) {
|
|
||||||
if (! is_readable(realpath($path))) {
|
|
||||||
$errors[] = '"'.$path.'" directory is not readable';
|
|
||||||
}
|
|
||||||
if (! is_writable(realpath($path))) {
|
|
||||||
$errors[] = '"'.$path.'" directory is not writable';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check configuration files are readable and writable
|
|
||||||
foreach (array(
|
|
||||||
$conf->getConfigFileExt(),
|
|
||||||
$conf->get('resource.datastore'),
|
|
||||||
$conf->get('resource.ban_file'),
|
|
||||||
$conf->get('resource.log'),
|
|
||||||
$conf->get('resource.update_check'),
|
|
||||||
) as $path) {
|
|
||||||
if (! is_file(realpath($path))) {
|
|
||||||
# the file may not exist yet
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_readable(realpath($path))) {
|
|
||||||
$errors[] = '"'.$path.'" file is not readable';
|
|
||||||
}
|
|
||||||
if (! is_writable(realpath($path))) {
|
|
||||||
$errors[] = '"'.$path.'" file is not writable';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $errors;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,38 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Cache utilities
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Purges all cached pages
|
|
||||||
*
|
|
||||||
* @param string $pageCacheDir page cache directory
|
|
||||||
*
|
|
||||||
* @return mixed an error string if the directory is missing
|
|
||||||
*/
|
|
||||||
function purgeCachedPages($pageCacheDir)
|
|
||||||
{
|
|
||||||
if (! is_dir($pageCacheDir)) {
|
|
||||||
$error = 'Cannot purge '.$pageCacheDir.': no directory';
|
|
||||||
error_log($error);
|
|
||||||
return $error;
|
|
||||||
}
|
|
||||||
|
|
||||||
array_map('unlink', glob($pageCacheDir.'/*.cache'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidates caches when the database is changed or the user logs out.
|
|
||||||
*
|
|
||||||
* @param string $pageCacheDir page cache directory
|
|
||||||
*/
|
|
||||||
function invalidateCaches($pageCacheDir)
|
|
||||||
{
|
|
||||||
// Purge cache attached to session.
|
|
||||||
if (isset($_SESSION['tags'])) {
|
|
||||||
unset($_SESSION['tags']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Purge page cache shared by sessions.
|
|
||||||
purgeCachedPages($pageCacheDir);
|
|
||||||
}
|
|
|
@ -1,63 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Simple cache system, mainly for the RSS/ATOM feeds
|
|
||||||
*/
|
|
||||||
class CachedPage
|
|
||||||
{
|
|
||||||
// Directory containing page caches
|
|
||||||
private $cacheDir;
|
|
||||||
|
|
||||||
// Full URL of the page to cache -typically the value returned by pageUrl()
|
|
||||||
private $url;
|
|
||||||
|
|
||||||
// Should this URL be cached (boolean)?
|
|
||||||
private $shouldBeCached;
|
|
||||||
|
|
||||||
// Name of the cache file for this URL
|
|
||||||
private $filename;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new CachedPage
|
|
||||||
*
|
|
||||||
* @param string $cacheDir page cache directory
|
|
||||||
* @param string $url page URL
|
|
||||||
* @param bool $shouldBeCached whether this page needs to be cached
|
|
||||||
*/
|
|
||||||
public function __construct($cacheDir, $url, $shouldBeCached)
|
|
||||||
{
|
|
||||||
// TODO: check write access to the cache directory
|
|
||||||
$this->cacheDir = $cacheDir;
|
|
||||||
$this->url = $url;
|
|
||||||
$this->filename = $this->cacheDir.'/'.sha1($url).'.cache';
|
|
||||||
$this->shouldBeCached = $shouldBeCached;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the cached version of a page, if it exists and should be cached
|
|
||||||
*
|
|
||||||
* @return string a cached version of the page if it exists, null otherwise
|
|
||||||
*/
|
|
||||||
public function cachedVersion()
|
|
||||||
{
|
|
||||||
if (!$this->shouldBeCached) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (is_file($this->filename)) {
|
|
||||||
return file_get_contents($this->filename);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Puts a page in the cache
|
|
||||||
*
|
|
||||||
* @param string $pageContent XML content to cache
|
|
||||||
*/
|
|
||||||
public function cache($pageContent)
|
|
||||||
{
|
|
||||||
if (!$this->shouldBeCached) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
file_put_contents($this->filename, $pageContent);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,307 +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 PubSub hub URL.
|
|
||||||
*/
|
|
||||||
protected $pubsubhubUrl;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string server locale.
|
|
||||||
*/
|
|
||||||
protected $locale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var DateTime Latest item date.
|
|
||||||
*/
|
|
||||||
protected $latestDate;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Feed constructor.
|
|
||||||
*
|
|
||||||
* @param LinkDB $linkDB LinkDB instance.
|
|
||||||
* @param string $feedType Type of feed.
|
|
||||||
* @param array $serverInfo $_SERVER.
|
|
||||||
* @param array $userInput $_GET.
|
|
||||||
* @param boolean $isLoggedIn True if the user is currently logged in, false otherwise.
|
|
||||||
*/
|
|
||||||
public function __construct($linkDB, $feedType, $serverInfo, $userInput, $isLoggedIn)
|
|
||||||
{
|
|
||||||
$this->linkDB = $linkDB;
|
|
||||||
$this->feedType = $feedType;
|
|
||||||
$this->serverInfo = $serverInfo;
|
|
||||||
$this->userInput = $userInput;
|
|
||||||
$this->isLoggedIn = $isLoggedIn;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build data for feed templates.
|
|
||||||
*
|
|
||||||
* @return array Formatted data for feeds templates.
|
|
||||||
*/
|
|
||||||
public function buildData()
|
|
||||||
{
|
|
||||||
// Optionally filter the results:
|
|
||||||
$linksToDisplay = $this->linkDB->filterSearch($this->userInput);
|
|
||||||
|
|
||||||
$nblinksToDisplay = $this->getNbLinks(count($linksToDisplay));
|
|
||||||
|
|
||||||
// Can't use array_keys() because $link is a LinkDB instance and not a real array.
|
|
||||||
$keys = array();
|
|
||||||
foreach ($linksToDisplay as $key => $value) {
|
|
||||||
$keys[] = $key;
|
|
||||||
}
|
|
||||||
|
|
||||||
$pageaddr = escape(index_url($this->serverInfo));
|
|
||||||
$linkDisplayed = array();
|
|
||||||
for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) {
|
|
||||||
$linkDisplayed[$keys[$i]] = $this->buildItem($linksToDisplay[$keys[$i]], $pageaddr);
|
|
||||||
}
|
|
||||||
|
|
||||||
$data['language'] = $this->getTypeLanguage();
|
|
||||||
$data['pubsubhub_url'] = $this->pubsubhubUrl;
|
|
||||||
$data['last_update'] = $this->getLatestDateFormatted();
|
|
||||||
$data['show_dates'] = !$this->hideDates || $this->isLoggedIn;
|
|
||||||
// Remove leading slash from REQUEST_URI.
|
|
||||||
$data['self_link'] = escape(server_url($this->serverInfo))
|
|
||||||
. escape($this->serverInfo['REQUEST_URI']);
|
|
||||||
$data['index_url'] = $pageaddr;
|
|
||||||
$data['usepermalinks'] = $this->usePermalinks === true;
|
|
||||||
$data['links'] = $linkDisplayed;
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a feed item (one per shaare).
|
|
||||||
*
|
|
||||||
* @param array $link Single link array extracted from LinkDB.
|
|
||||||
* @param string $pageaddr Index URL.
|
|
||||||
*
|
|
||||||
* @return array Link array with feed attributes.
|
|
||||||
*/
|
|
||||||
protected function buildItem($link, $pageaddr)
|
|
||||||
{
|
|
||||||
$link['guid'] = $pageaddr .'?'. smallHash($link['linkdate']);
|
|
||||||
// Check for both signs of a note: starting with ? and 7 chars long.
|
|
||||||
if ($link['url'][0] === '?' && strlen($link['url']) === 7) {
|
|
||||||
$link['url'] = $pageaddr . $link['url'];
|
|
||||||
}
|
|
||||||
if ($this->usePermalinks === true) {
|
|
||||||
$permalink = '<a href="'. $link['url'] .'" title="Direct link">Direct link</a>';
|
|
||||||
} else {
|
|
||||||
$permalink = '<a href="'. $link['guid'] .'" title="Permalink">Permalink</a>';
|
|
||||||
}
|
|
||||||
$link['description'] = format_description($link['description'], '', $pageaddr);
|
|
||||||
$link['description'] .= PHP_EOL .'<br>— '. $permalink;
|
|
||||||
|
|
||||||
$pubDate = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $link['linkdate']);
|
|
||||||
$link['pub_iso_date'] = $this->getIsoDate($pubDate);
|
|
||||||
|
|
||||||
// atom:entry elements MUST contain exactly one atom:updated element.
|
|
||||||
if (!empty($link['updated'])) {
|
|
||||||
$upDate = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $link['updated']);
|
|
||||||
$link['up_iso_date'] = $this->getIsoDate($upDate, DateTime::ATOM);
|
|
||||||
} else {
|
|
||||||
$link['up_iso_date'] = $this->getIsoDate($pubDate, DateTime::ATOM);;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the more recent item.
|
|
||||||
if (empty($this->latestDate) || $this->latestDate < $pubDate) {
|
|
||||||
$this->latestDate = $pubDate;
|
|
||||||
}
|
|
||||||
if (!empty($upDate) && $this->latestDate < $upDate) {
|
|
||||||
$this->latestDate = $upDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
$taglist = array_filter(explode(' ', $link['tags']), 'strlen');
|
|
||||||
uasort($taglist, 'strcasecmp');
|
|
||||||
$link['taglist'] = $taglist;
|
|
||||||
|
|
||||||
return $link;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assign PubSub hub URL.
|
|
||||||
*
|
|
||||||
* @param string $pubsubhubUrl PubSub hub url.
|
|
||||||
*/
|
|
||||||
public function setPubsubhubUrl($pubsubhubUrl)
|
|
||||||
{
|
|
||||||
$this->pubsubhubUrl = $pubsubhubUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set this to true to use permalinks instead of direct links.
|
|
||||||
*
|
|
||||||
* @param boolean $usePermalinks true to force permalinks.
|
|
||||||
*/
|
|
||||||
public function setUsePermalinks($usePermalinks)
|
|
||||||
{
|
|
||||||
$this->usePermalinks = $usePermalinks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set this to true to hide timestamps in feeds.
|
|
||||||
*
|
|
||||||
* @param boolean $hideDates true to enable.
|
|
||||||
*/
|
|
||||||
public function setHideDates($hideDates)
|
|
||||||
{
|
|
||||||
$this->hideDates = $hideDates;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the locale. Used to show feed language.
|
|
||||||
*
|
|
||||||
* @param string $locale The locale (eg. 'fr_FR.UTF8').
|
|
||||||
*/
|
|
||||||
public function setLocale($locale)
|
|
||||||
{
|
|
||||||
$this->locale = strtolower($locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the language according to the feed type, based on the locale:
|
|
||||||
*
|
|
||||||
* - RSS format: en-us (default: 'en-en').
|
|
||||||
* - ATOM format: fr (default: 'en').
|
|
||||||
*
|
|
||||||
* @return string The language.
|
|
||||||
*/
|
|
||||||
public function getTypeLanguage()
|
|
||||||
{
|
|
||||||
// Use the locale do define the language, if available.
|
|
||||||
if (! empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) {
|
|
||||||
$length = ($this->feedType == self::$FEED_RSS) ? 5 : 2;
|
|
||||||
return str_replace('_', '-', substr($this->locale, 0, $length));
|
|
||||||
}
|
|
||||||
return ($this->feedType == self::$FEED_RSS) ? 'en-en' : 'en';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format the latest item date found according to the feed type.
|
|
||||||
*
|
|
||||||
* Return an empty string if invalid DateTime is passed.
|
|
||||||
*
|
|
||||||
* @return string Formatted date.
|
|
||||||
*/
|
|
||||||
protected function getLatestDateFormatted()
|
|
||||||
{
|
|
||||||
if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$type = ($this->feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM;
|
|
||||||
return $this->latestDate->format($type);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get ISO date from DateTime according to feed type.
|
|
||||||
*
|
|
||||||
* @param DateTime $date Date to format.
|
|
||||||
* @param string|bool $format Force format.
|
|
||||||
*
|
|
||||||
* @return string Formatted date.
|
|
||||||
*/
|
|
||||||
protected function getIsoDate(DateTime $date, $format = false)
|
|
||||||
{
|
|
||||||
if ($format !== false) {
|
|
||||||
return $date->format($format);
|
|
||||||
}
|
|
||||||
if ($this->feedType == self::$FEED_RSS) {
|
|
||||||
return $date->format(DateTime::RSS);
|
|
||||||
|
|
||||||
}
|
|
||||||
return $date->format(DateTime::ATOM);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the number of link to display according to 'nb' user input parameter.
|
|
||||||
*
|
|
||||||
* If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS.
|
|
||||||
* If 'nb' is set to 'all', display all filtered links (max parameter).
|
|
||||||
*
|
|
||||||
* @param int $max maximum number of links to display.
|
|
||||||
*
|
|
||||||
* @return int number of links to display.
|
|
||||||
*/
|
|
||||||
public function getNbLinks($max)
|
|
||||||
{
|
|
||||||
if (empty($this->userInput['nb'])) {
|
|
||||||
return self::$DEFAULT_NB_LINKS;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->userInput['nb'] == 'all') {
|
|
||||||
return $max;
|
|
||||||
}
|
|
||||||
|
|
||||||
$intNb = intval($this->userInput['nb']);
|
|
||||||
if (! is_int($intNb) || $intNb == 0) {
|
|
||||||
return self::$DEFAULT_NB_LINKS;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $intNb;
|
|
||||||
}
|
|
||||||
}
|
|
223
application/History.php
Normal file
223
application/History.php
Normal file
|
@ -0,0 +1,223 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use Exception;
|
||||||
|
use Shaarli\Bookmark\Bookmark;
|
||||||
|
use Shaarli\Helper\FileUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class History
|
||||||
|
*
|
||||||
|
* Handle the history file tracing events in Shaarli.
|
||||||
|
* The history is stored as JSON in a file set by 'resource.history' setting.
|
||||||
|
*
|
||||||
|
* Available data:
|
||||||
|
* - event: event key
|
||||||
|
* - datetime: event date, in ISO8601 format.
|
||||||
|
* - id: event item identifier (currently only link IDs).
|
||||||
|
*
|
||||||
|
* Available event keys:
|
||||||
|
* - CREATED: new link
|
||||||
|
* - UPDATED: link updated
|
||||||
|
* - DELETED: link deleted
|
||||||
|
* - SETTINGS: the settings have been updated through the UI.
|
||||||
|
* - IMPORT: bulk bookmarks import
|
||||||
|
*
|
||||||
|
* Note: new events are put at the beginning of the file and history array.
|
||||||
|
*/
|
||||||
|
class History
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string Action key: a new link has been created.
|
||||||
|
*/
|
||||||
|
public const CREATED = 'CREATED';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string Action key: a link has been updated.
|
||||||
|
*/
|
||||||
|
public const UPDATED = 'UPDATED';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string Action key: a link has been deleted.
|
||||||
|
*/
|
||||||
|
public const DELETED = 'DELETED';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string Action key: settings have been updated.
|
||||||
|
*/
|
||||||
|
public const SETTINGS = 'SETTINGS';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string Action key: a bulk import has been processed.
|
||||||
|
*/
|
||||||
|
public const IMPORT = 'IMPORT';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string History file path.
|
||||||
|
*/
|
||||||
|
protected $historyFilePath;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array History data.
|
||||||
|
*/
|
||||||
|
protected $history;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int History retention time in seconds (1 month).
|
||||||
|
*/
|
||||||
|
protected $retentionTime = 2678400;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* History constructor.
|
||||||
|
*
|
||||||
|
* @param string $historyFilePath History file path.
|
||||||
|
* @param int $retentionTime History content retention time in seconds.
|
||||||
|
*
|
||||||
|
* @throws Exception if something goes wrong.
|
||||||
|
*/
|
||||||
|
public function __construct($historyFilePath, $retentionTime = null)
|
||||||
|
{
|
||||||
|
$this->historyFilePath = $historyFilePath;
|
||||||
|
if ($retentionTime !== null) {
|
||||||
|
$this->retentionTime = $retentionTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize: read history file.
|
||||||
|
*
|
||||||
|
* Allow lazy loading (don't read the file if it isn't necessary).
|
||||||
|
*/
|
||||||
|
protected function initialize()
|
||||||
|
{
|
||||||
|
$this->check();
|
||||||
|
$this->read();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add Event: new link.
|
||||||
|
*
|
||||||
|
* @param Bookmark $link Link data.
|
||||||
|
*/
|
||||||
|
public function addLink($link)
|
||||||
|
{
|
||||||
|
$this->addEvent(self::CREATED, $link->getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add Event: update existing link.
|
||||||
|
*
|
||||||
|
* @param Bookmark $link Link data.
|
||||||
|
*/
|
||||||
|
public function updateLink($link)
|
||||||
|
{
|
||||||
|
$this->addEvent(self::UPDATED, $link->getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add Event: delete existing link.
|
||||||
|
*
|
||||||
|
* @param Bookmark $link Link data.
|
||||||
|
*/
|
||||||
|
public function deleteLink($link)
|
||||||
|
{
|
||||||
|
$this->addEvent(self::DELETED, $link->getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add Event: settings updated.
|
||||||
|
*/
|
||||||
|
public function updateSettings()
|
||||||
|
{
|
||||||
|
$this->addEvent(self::SETTINGS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add Event: bulk import.
|
||||||
|
*
|
||||||
|
* Note: we don't store bookmarks add/update one by one since it can have a huge impact on performances.
|
||||||
|
*/
|
||||||
|
public function importLinks()
|
||||||
|
{
|
||||||
|
$this->addEvent(self::IMPORT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a new event and write it in the history file.
|
||||||
|
*
|
||||||
|
* @param string $status Event key, should be defined as constant.
|
||||||
|
* @param mixed $id Event item identifier (e.g. link ID).
|
||||||
|
*/
|
||||||
|
protected function addEvent($status, $id = null)
|
||||||
|
{
|
||||||
|
if ($this->history === null) {
|
||||||
|
$this->initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = [
|
||||||
|
'event' => $status,
|
||||||
|
'datetime' => new DateTime(),
|
||||||
|
'id' => $id !== null ? $id : '',
|
||||||
|
];
|
||||||
|
$this->history = array_merge([$item], $this->history);
|
||||||
|
$this->write();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check that the history file is writable.
|
||||||
|
* Create the file if it doesn't exist.
|
||||||
|
*
|
||||||
|
* @throws Exception if it isn't writable.
|
||||||
|
*/
|
||||||
|
protected function check()
|
||||||
|
{
|
||||||
|
if (!is_file($this->historyFilePath)) {
|
||||||
|
FileUtils::writeFlatDB($this->historyFilePath, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_writable($this->historyFilePath)) {
|
||||||
|
throw new Exception(t('History file isn\'t readable or writable'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read JSON history file.
|
||||||
|
*/
|
||||||
|
protected function read()
|
||||||
|
{
|
||||||
|
$this->history = FileUtils::readFlatDB($this->historyFilePath, []);
|
||||||
|
if ($this->history === false) {
|
||||||
|
throw new Exception(t('Could not parse history file'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write JSON history file and delete old entries.
|
||||||
|
*/
|
||||||
|
protected function write()
|
||||||
|
{
|
||||||
|
$comparaison = new DateTime('-' . $this->retentionTime . ' seconds');
|
||||||
|
foreach ($this->history as $key => $value) {
|
||||||
|
if ($value['datetime'] < $comparaison) {
|
||||||
|
unset($this->history[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FileUtils::writeFlatDB($this->historyFilePath, array_values($this->history));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the History.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getHistory()
|
||||||
|
{
|
||||||
|
if ($this->history === null) {
|
||||||
|
$this->initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->history;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,383 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* GET an HTTP URL to retrieve its content
|
|
||||||
* Uses the cURL library or a fallback method
|
|
||||||
*
|
|
||||||
* @param string $url URL to get (http://...)
|
|
||||||
* @param int $timeout network timeout (in seconds)
|
|
||||||
* @param int $maxBytes maximum downloaded bytes (default: 4 MiB)
|
|
||||||
*
|
|
||||||
* @return array HTTP response headers, downloaded content
|
|
||||||
*
|
|
||||||
* Output format:
|
|
||||||
* [0] = associative array containing HTTP response headers
|
|
||||||
* [1] = URL content (downloaded data)
|
|
||||||
*
|
|
||||||
* Example:
|
|
||||||
* list($headers, $data) = get_http_response('http://sebauvage.net/');
|
|
||||||
* if (strpos($headers[0], '200 OK') !== false) {
|
|
||||||
* echo 'Data type: '.htmlspecialchars($headers['Content-Type']);
|
|
||||||
* } else {
|
|
||||||
* echo 'There was an error: '.htmlspecialchars($headers[0]);
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* @see https://secure.php.net/manual/en/ref.curl.php
|
|
||||||
* @see https://secure.php.net/manual/en/functions.anonymous.php
|
|
||||||
* @see https://secure.php.net/manual/en/function.preg-split.php
|
|
||||||
* @see https://secure.php.net/manual/en/function.explode.php
|
|
||||||
* @see http://stackoverflow.com/q/17641073
|
|
||||||
* @see http://stackoverflow.com/q/9183178
|
|
||||||
* @see http://stackoverflow.com/q/1462720
|
|
||||||
*/
|
|
||||||
function get_http_response($url, $timeout = 30, $maxBytes = 4194304)
|
|
||||||
{
|
|
||||||
$urlObj = new Url($url);
|
|
||||||
$cleanUrl = $urlObj->idnToAscii();
|
|
||||||
|
|
||||||
if (!filter_var($cleanUrl, FILTER_VALIDATE_URL) || !$urlObj->isHttp()) {
|
|
||||||
return array(array(0 => 'Invalid HTTP Url'), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
$userAgent =
|
|
||||||
'Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:45.0)'
|
|
||||||
. ' Gecko/20100101 Firefox/45.0';
|
|
||||||
$acceptLanguage =
|
|
||||||
substr(setlocale(LC_COLLATE, 0), 0, 2) . ',en-US;q=0.7,en;q=0.3';
|
|
||||||
$maxRedirs = 3;
|
|
||||||
|
|
||||||
if (!function_exists('curl_init')) {
|
|
||||||
return get_http_response_fallback(
|
|
||||||
$cleanUrl,
|
|
||||||
$timeout,
|
|
||||||
$maxBytes,
|
|
||||||
$userAgent,
|
|
||||||
$acceptLanguage,
|
|
||||||
$maxRedirs
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$ch = curl_init($cleanUrl);
|
|
||||||
if ($ch === false) {
|
|
||||||
return array(array(0 => 'curl_init() error'), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// General cURL settings
|
|
||||||
curl_setopt($ch, CURLOPT_AUTOREFERER, true);
|
|
||||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
|
||||||
curl_setopt($ch, CURLOPT_HEADER, true);
|
|
||||||
curl_setopt(
|
|
||||||
$ch,
|
|
||||||
CURLOPT_HTTPHEADER,
|
|
||||||
array('Accept-Language: ' . $acceptLanguage)
|
|
||||||
);
|
|
||||||
curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirs);
|
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
||||||
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
|
|
||||||
curl_setopt($ch, CURLOPT_USERAGENT, $userAgent);
|
|
||||||
|
|
||||||
// Max download size management
|
|
||||||
curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024);
|
|
||||||
curl_setopt($ch, CURLOPT_NOPROGRESS, false);
|
|
||||||
curl_setopt($ch, CURLOPT_PROGRESSFUNCTION,
|
|
||||||
function($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes)
|
|
||||||
{
|
|
||||||
if (version_compare(phpversion(), '5.5', '<')) {
|
|
||||||
// PHP version lower than 5.5
|
|
||||||
// Callback has 4 arguments
|
|
||||||
$downloaded = $arg1;
|
|
||||||
} else {
|
|
||||||
// Callback has 5 arguments
|
|
||||||
$downloaded = $arg2;
|
|
||||||
}
|
|
||||||
// Non-zero return stops downloading
|
|
||||||
return ($downloaded > $maxBytes) ? 1 : 0;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = curl_exec($ch);
|
|
||||||
$errorNo = curl_errno($ch);
|
|
||||||
$errorStr = curl_error($ch);
|
|
||||||
$headSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
|
||||||
curl_close($ch);
|
|
||||||
|
|
||||||
if ($response === false) {
|
|
||||||
if ($errorNo == CURLE_COULDNT_RESOLVE_HOST) {
|
|
||||||
/*
|
|
||||||
* Workaround to match fallback method behaviour
|
|
||||||
* Removing this would require updating
|
|
||||||
* GetHttpUrlTest::testGetInvalidRemoteUrl()
|
|
||||||
*/
|
|
||||||
return array(false, false);
|
|
||||||
}
|
|
||||||
return array(array(0 => 'curl_exec() error: ' . $errorStr), false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Formatting output like the fallback method
|
|
||||||
$rawHeaders = substr($response, 0, $headSize);
|
|
||||||
|
|
||||||
// Keep only headers from latest redirection
|
|
||||||
$rawHeadersArrayRedirs = explode("\r\n\r\n", trim($rawHeaders));
|
|
||||||
$rawHeadersLastRedir = end($rawHeadersArrayRedirs);
|
|
||||||
|
|
||||||
$content = substr($response, $headSize);
|
|
||||||
$headers = array();
|
|
||||||
foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) {
|
|
||||||
if (empty($line) or ctype_space($line)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$splitLine = explode(': ', $line, 2);
|
|
||||||
if (count($splitLine) > 1) {
|
|
||||||
$key = $splitLine[0];
|
|
||||||
$value = $splitLine[1];
|
|
||||||
if (array_key_exists($key, $headers)) {
|
|
||||||
if (!is_array($headers[$key])) {
|
|
||||||
$headers[$key] = array(0 => $headers[$key]);
|
|
||||||
}
|
|
||||||
$headers[$key][] = $value;
|
|
||||||
} else {
|
|
||||||
$headers[$key] = $value;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$headers[] = $splitLine[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array($headers, $content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET an HTTP URL to retrieve its content (fallback method)
|
|
||||||
*
|
|
||||||
* @param string $cleanUrl URL to get (http://... valid and in ASCII form)
|
|
||||||
* @param int $timeout network timeout (in seconds)
|
|
||||||
* @param int $maxBytes maximum downloaded bytes
|
|
||||||
* @param string $userAgent "User-Agent" header
|
|
||||||
* @param string $acceptLanguage "Accept-Language" header
|
|
||||||
* @param int $maxRedr maximum amount of redirections followed
|
|
||||||
*
|
|
||||||
* @return array HTTP response headers, downloaded content
|
|
||||||
*
|
|
||||||
* Output format:
|
|
||||||
* [0] = associative array containing HTTP response headers
|
|
||||||
* [1] = URL content (downloaded data)
|
|
||||||
*
|
|
||||||
* @see http://php.net/manual/en/function.file-get-contents.php
|
|
||||||
* @see http://php.net/manual/en/function.stream-context-create.php
|
|
||||||
* @see http://php.net/manual/en/function.get-headers.php
|
|
||||||
*/
|
|
||||||
function get_http_response_fallback(
|
|
||||||
$cleanUrl,
|
|
||||||
$timeout,
|
|
||||||
$maxBytes,
|
|
||||||
$userAgent,
|
|
||||||
$acceptLanguage,
|
|
||||||
$maxRedr
|
|
||||||
) {
|
|
||||||
$options = array(
|
|
||||||
'http' => array(
|
|
||||||
'method' => 'GET',
|
|
||||||
'timeout' => $timeout,
|
|
||||||
'user_agent' => $userAgent,
|
|
||||||
'header' => "Accept: */*\r\n"
|
|
||||||
. 'Accept-Language: ' . $acceptLanguage
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
stream_context_set_default($options);
|
|
||||||
list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr);
|
|
||||||
if (! $headers || strpos($headers[0], '200 OK') === false) {
|
|
||||||
$options['http']['request_fulluri'] = true;
|
|
||||||
stream_context_set_default($options);
|
|
||||||
list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $headers) {
|
|
||||||
return array($headers, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// TODO: catch Exception in calling code (thumbnailer)
|
|
||||||
$context = stream_context_create($options);
|
|
||||||
$content = file_get_contents($finalUrl, false, $context, -1, $maxBytes);
|
|
||||||
} catch (Exception $exc) {
|
|
||||||
return array(array(0 => 'HTTP Error'), $exc->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
return array($headers, $content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve HTTP headers, following n redirections (temporary and permanent ones).
|
|
||||||
*
|
|
||||||
* @param string $url initial URL to reach.
|
|
||||||
* @param int $redirectionLimit max redirection follow.
|
|
||||||
*
|
|
||||||
* @return array HTTP headers, or false if it failed.
|
|
||||||
*/
|
|
||||||
function get_redirected_headers($url, $redirectionLimit = 3)
|
|
||||||
{
|
|
||||||
$headers = get_headers($url, 1);
|
|
||||||
if (!empty($headers['location']) && empty($headers['Location'])) {
|
|
||||||
$headers['Location'] = $headers['location'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Headers found, redirection found, and limit not reached.
|
|
||||||
if ($redirectionLimit-- > 0
|
|
||||||
&& !empty($headers)
|
|
||||||
&& (strpos($headers[0], '301') !== false || strpos($headers[0], '302') !== false)
|
|
||||||
&& !empty($headers['Location'])) {
|
|
||||||
|
|
||||||
$redirection = is_array($headers['Location']) ? end($headers['Location']) : $headers['Location'];
|
|
||||||
if ($redirection != $url) {
|
|
||||||
$redirection = getAbsoluteUrl($url, $redirection);
|
|
||||||
return get_redirected_headers($redirection, $redirectionLimit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array($headers, $url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get an absolute URL from a complete one, and another absolute/relative URL.
|
|
||||||
*
|
|
||||||
* @param string $originalUrl The original complete URL.
|
|
||||||
* @param string $newUrl The new one, absolute or relative.
|
|
||||||
*
|
|
||||||
* @return string Final URL:
|
|
||||||
* - $newUrl if it was already an absolute URL.
|
|
||||||
* - if it was relative, absolute URL from $originalUrl path.
|
|
||||||
*/
|
|
||||||
function getAbsoluteUrl($originalUrl, $newUrl)
|
|
||||||
{
|
|
||||||
$newScheme = parse_url($newUrl, PHP_URL_SCHEME);
|
|
||||||
// Already an absolute URL.
|
|
||||||
if (!empty($newScheme)) {
|
|
||||||
return $newUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = parse_url($originalUrl);
|
|
||||||
$final = $parts['scheme'] .'://'. $parts['host'];
|
|
||||||
$final .= (!empty($parts['port'])) ? $parts['port'] : '';
|
|
||||||
$final .= '/';
|
|
||||||
if ($newUrl[0] != '/') {
|
|
||||||
$final .= substr(ltrim($parts['path'], '/'), 0, strrpos($parts['path'], '/'));
|
|
||||||
}
|
|
||||||
$final .= ltrim($newUrl, '/');
|
|
||||||
return $final;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the server's base URL: scheme://domain.tld[:port]
|
|
||||||
*
|
|
||||||
* @param array $server the $_SERVER array
|
|
||||||
*
|
|
||||||
* @return string the server's base URL
|
|
||||||
*
|
|
||||||
* @see http://www.ietf.org/rfc/rfc7239.txt
|
|
||||||
* @see http://www.ietf.org/rfc/rfc6648.txt
|
|
||||||
* @see http://stackoverflow.com/a/3561399
|
|
||||||
* @see http://stackoverflow.com/q/452375
|
|
||||||
*/
|
|
||||||
function server_url($server)
|
|
||||||
{
|
|
||||||
$scheme = 'http';
|
|
||||||
$port = '';
|
|
||||||
|
|
||||||
// Shaarli is served behind a proxy
|
|
||||||
if (isset($server['HTTP_X_FORWARDED_PROTO'])) {
|
|
||||||
// Keep forwarded scheme
|
|
||||||
if (strpos($server['HTTP_X_FORWARDED_PROTO'], ',') !== false) {
|
|
||||||
$schemes = explode(',', $server['HTTP_X_FORWARDED_PROTO']);
|
|
||||||
$scheme = trim($schemes[0]);
|
|
||||||
} else {
|
|
||||||
$scheme = $server['HTTP_X_FORWARDED_PROTO'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($server['HTTP_X_FORWARDED_PORT'])) {
|
|
||||||
// Keep forwarded port
|
|
||||||
if (strpos($server['HTTP_X_FORWARDED_PORT'], ',') !== false) {
|
|
||||||
$ports = explode(',', $server['HTTP_X_FORWARDED_PORT']);
|
|
||||||
$port = ':' . trim($ports[0]);
|
|
||||||
} else {
|
|
||||||
$port = ':' . $server['HTTP_X_FORWARDED_PORT'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $scheme.'://'.$server['SERVER_NAME'].$port;
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSL detection
|
|
||||||
if ((! empty($server['HTTPS']) && strtolower($server['HTTPS']) == 'on')
|
|
||||||
|| (isset($server['SERVER_PORT']) && $server['SERVER_PORT'] == '443')) {
|
|
||||||
$scheme = 'https';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do not append standard port values
|
|
||||||
if (($scheme == 'http' && $server['SERVER_PORT'] != '80')
|
|
||||||
|| ($scheme == 'https' && $server['SERVER_PORT'] != '443')) {
|
|
||||||
$port = ':'.$server['SERVER_PORT'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $scheme.'://'.$server['SERVER_NAME'].$port;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the absolute URL of the current script, without the query
|
|
||||||
*
|
|
||||||
* If the resource is "index.php", then it is removed (for better-looking URLs)
|
|
||||||
*
|
|
||||||
* @param array $server the $_SERVER array
|
|
||||||
*
|
|
||||||
* @return string the absolute URL of the current script, without the query
|
|
||||||
*/
|
|
||||||
function index_url($server)
|
|
||||||
{
|
|
||||||
$scriptname = $server['SCRIPT_NAME'];
|
|
||||||
if (endsWith($scriptname, 'index.php')) {
|
|
||||||
$scriptname = substr($scriptname, 0, -9);
|
|
||||||
}
|
|
||||||
return server_url($server) . $scriptname;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the absolute URL of the current script, with the query
|
|
||||||
*
|
|
||||||
* If the resource is "index.php", then it is removed (for better-looking URLs)
|
|
||||||
*
|
|
||||||
* @param array $server the $_SERVER array
|
|
||||||
*
|
|
||||||
* @return string the absolute URL of the current script, with the query
|
|
||||||
*/
|
|
||||||
function page_url($server)
|
|
||||||
{
|
|
||||||
if (! empty($server['QUERY_STRING'])) {
|
|
||||||
return index_url($server).'?'.$server['QUERY_STRING'];
|
|
||||||
}
|
|
||||||
return index_url($server);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve the initial IP forwarded by the reverse proxy.
|
|
||||||
*
|
|
||||||
* Inspired from: https://github.com/zendframework/zend-http/blob/master/src/PhpEnvironment/RemoteAddress.php
|
|
||||||
*
|
|
||||||
* @param array $server $_SERVER array which contains HTTP headers.
|
|
||||||
* @param array $trustedIps List of trusted IP from the configuration.
|
|
||||||
*
|
|
||||||
* @return string|bool The forwarded IP, or false if none could be extracted.
|
|
||||||
*/
|
|
||||||
function getIpAddressFromProxy($server, $trustedIps)
|
|
||||||
{
|
|
||||||
$forwardedIpHeader = 'HTTP_X_FORWARDED_FOR';
|
|
||||||
if (empty($server[$forwardedIpHeader])) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ips = preg_split('/\s*,\s*/', $server[$forwardedIpHeader]);
|
|
||||||
$ips = array_diff($ips, $trustedIps);
|
|
||||||
if (empty($ips)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_pop($ips);
|
|
||||||
}
|
|
|
@ -1,21 +1,193 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli;
|
||||||
|
|
||||||
|
use Gettext\GettextTranslator;
|
||||||
|
use Gettext\Translations;
|
||||||
|
use Gettext\Translator;
|
||||||
|
use Gettext\TranslatorInterface;
|
||||||
|
use Shaarli\Config\ConfigManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper function for translation which match the API
|
* Class Languages
|
||||||
* of gettext()/_() and ngettext().
|
|
||||||
*
|
*
|
||||||
* Not doing translation for now.
|
* Load Shaarli translations using 'gettext/gettext'.
|
||||||
|
* This class allows to either use PHP gettext extension, or a PHP implementation of gettext,
|
||||||
|
* with a fixed language, or dynamically using autoLocale().
|
||||||
*
|
*
|
||||||
* @param string $text Text to translate.
|
* Translation files PO/MO files follow gettext standard and must be placed under:
|
||||||
* @param string $nText The plural message ID.
|
* <translation path>/<language>/LC_MESSAGES/<domain>.[po|mo]
|
||||||
* @param int $nb The number of items for plural forms.
|
|
||||||
*
|
*
|
||||||
* @return String Text translated.
|
* Pros/cons:
|
||||||
|
* - gettext extension is faster
|
||||||
|
* - gettext is very system dependent (PHP extension, the locale must be installed, and web server reloaded)
|
||||||
|
*
|
||||||
|
* Settings:
|
||||||
|
* - translation.mode:
|
||||||
|
* - auto: use default setting (PHP implementation)
|
||||||
|
* - php: use PHP implementation
|
||||||
|
* - gettext: use gettext wrapper
|
||||||
|
* - translation.language:
|
||||||
|
* - auto: use autoLocale() and the language change according to user HTTP headers
|
||||||
|
* - fixed language: e.g. 'fr'
|
||||||
|
* - translation.extensions:
|
||||||
|
* - domain => translation_path: allow plugins and themes to extend the defaut extension
|
||||||
|
* The domain must be unique, and translation path must be relative, and contains the tree mentioned above.
|
||||||
|
*
|
||||||
|
* @package Shaarli
|
||||||
*/
|
*/
|
||||||
function t($text, $nText = '', $nb = 0) {
|
class Languages
|
||||||
if (empty($nText)) {
|
{
|
||||||
return $text;
|
/**
|
||||||
|
* Core translations domain
|
||||||
|
*/
|
||||||
|
public const DEFAULT_DOMAIN = 'shaarli';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var TranslatorInterface
|
||||||
|
*/
|
||||||
|
protected $translator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $language;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ConfigManager
|
||||||
|
*/
|
||||||
|
protected $conf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Languages constructor.
|
||||||
|
*
|
||||||
|
* @param string $language lang determined by autoLocale(), can be overridden.
|
||||||
|
* @param ConfigManager $conf instance.
|
||||||
|
*/
|
||||||
|
public function __construct($language, $conf)
|
||||||
|
{
|
||||||
|
$this->conf = $conf;
|
||||||
|
$confLanguage = $this->conf->get('translation.language', 'auto');
|
||||||
|
// Auto mode or invalid parameter, use the detected language.
|
||||||
|
// If the detected language is invalid, it doesn't matter, it will use English.
|
||||||
|
if ($confLanguage === 'auto' || ! $this->isValidLanguage($confLanguage)) {
|
||||||
|
$this->language = substr($language, 0, 5);
|
||||||
|
} else {
|
||||||
|
$this->language = $confLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
! extension_loaded('gettext')
|
||||||
|
|| in_array($this->conf->get('translation.mode', 'auto'), ['auto', 'php'])
|
||||||
|
) {
|
||||||
|
$this->initPhpTranslator();
|
||||||
|
} else {
|
||||||
|
$this->initGettextTranslator();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register default functions (e.g. '__()') to use our Translator
|
||||||
|
$this->translator->register();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the translator using php gettext extension (gettext dependency act as a wrapper).
|
||||||
|
*/
|
||||||
|
protected function initGettextTranslator()
|
||||||
|
{
|
||||||
|
$this->translator = new GettextTranslator();
|
||||||
|
$this->translator->setLanguage($this->language);
|
||||||
|
$this->translator->loadDomain(self::DEFAULT_DOMAIN, 'inc/languages');
|
||||||
|
|
||||||
|
// Default extension translation from the current theme
|
||||||
|
$themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') . '/' . $this->conf->get('theme') . '/language';
|
||||||
|
if (is_dir($themeTransFolder)) {
|
||||||
|
$this->translator->loadDomain($this->conf->get('theme'), $themeTransFolder, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->conf->get('translation.extensions', []) as $domain => $translationPath) {
|
||||||
|
if ($domain !== self::DEFAULT_DOMAIN) {
|
||||||
|
$this->translator->loadDomain($domain, $translationPath, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the translator using a PHP implementation of gettext.
|
||||||
|
*
|
||||||
|
* Note that if language po file doesn't exist, errors are ignored (e.g. not installed language).
|
||||||
|
*/
|
||||||
|
protected function initPhpTranslator()
|
||||||
|
{
|
||||||
|
$this->translator = new Translator();
|
||||||
|
$translations = new Translations();
|
||||||
|
// Core translations
|
||||||
|
try {
|
||||||
|
$translations = $translations->addFromPoFile(
|
||||||
|
'inc/languages/' . $this->language . '/LC_MESSAGES/shaarli.po'
|
||||||
|
);
|
||||||
|
$translations->setDomain('shaarli');
|
||||||
|
$this->translator->loadTranslations($translations);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default extension translation from the current theme
|
||||||
|
$theme = $this->conf->get('theme');
|
||||||
|
$themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') . '/' . $theme . '/language';
|
||||||
|
if (is_dir($themeTransFolder)) {
|
||||||
|
try {
|
||||||
|
$translations = Translations::fromPoFile(
|
||||||
|
$themeTransFolder . '/' . $this->language . '/LC_MESSAGES/' . $theme . '.po'
|
||||||
|
);
|
||||||
|
$translations->setDomain($theme);
|
||||||
|
$this->translator->loadTranslations($translations);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension translations (plugins, themes, etc.).
|
||||||
|
foreach ($this->conf->get('translation.extensions', []) as $domain => $translationPath) {
|
||||||
|
if ($domain === self::DEFAULT_DOMAIN) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$extension = Translations::fromPoFile(
|
||||||
|
$translationPath . $this->language . '/LC_MESSAGES/' . $domain . '.po'
|
||||||
|
);
|
||||||
|
$extension->setDomain($domain);
|
||||||
|
$this->translator->loadTranslations($extension);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a language string is valid.
|
||||||
|
*
|
||||||
|
* @param string $language e.g. 'fr' or 'en_US'
|
||||||
|
*
|
||||||
|
* @return bool true if valid, false otherwise
|
||||||
|
*/
|
||||||
|
protected function isValidLanguage($language)
|
||||||
|
{
|
||||||
|
return preg_match('/^[a-z]{2}(_[A-Z]{2})?/', $language) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of available languages for Shaarli.
|
||||||
|
*
|
||||||
|
* @return array List of available languages, with their label.
|
||||||
|
*/
|
||||||
|
public static function getAvailableLanguages()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'auto' => t('Automatic'),
|
||||||
|
'de' => t('German'),
|
||||||
|
'en' => t('English'),
|
||||||
|
'fr' => t('French'),
|
||||||
|
'jp' => t('Japanese'),
|
||||||
|
'ru' => t('Russian'),
|
||||||
|
'zh_CN' => t('Chinese (Simplified)'),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
$actualForm = $nb > 1 ? $nText : $text;
|
|
||||||
return sprintf($actualForm, $nb);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,478 +0,0 @@
|
||||||
<?php
|
|
||||||
/**
|
|
||||||
* Data storage for links.
|
|
||||||
*
|
|
||||||
* This object behaves like an associative array.
|
|
||||||
*
|
|
||||||
* Example:
|
|
||||||
* $myLinks = new LinkDB();
|
|
||||||
* echo $myLinks['20110826_161819']['title'];
|
|
||||||
* foreach ($myLinks as $link)
|
|
||||||
* echo $link['title'].' at url '.$link['url'].'; description:'.$link['description'];
|
|
||||||
*
|
|
||||||
* Available keys:
|
|
||||||
* - description: description of the entry
|
|
||||||
* - linkdate: creation date of this entry, format: YYYYMMDD_HHMMSS
|
|
||||||
* (e.g.'20110914_192317')
|
|
||||||
* - updated: last modification date of this entry, format: YYYYMMDD_HHMMSS
|
|
||||||
* - private: Is this link private? 0=no, other value=yes
|
|
||||||
* - tags: tags attached to this entry (separated by spaces)
|
|
||||||
* - title Title of the link
|
|
||||||
* - url URL of the link. Used for displayable links (no redirector, relative, etc.).
|
|
||||||
* Can be absolute or relative.
|
|
||||||
* Relative URLs are permalinks (e.g.'?m-ukcw')
|
|
||||||
* - real_url Absolute processed URL.
|
|
||||||
*
|
|
||||||
* Implements 3 interfaces:
|
|
||||||
* - ArrayAccess: behaves like an associative array;
|
|
||||||
* - Countable: there is a count() method;
|
|
||||||
* - Iterator: usable in foreach () loops.
|
|
||||||
*/
|
|
||||||
class LinkDB implements Iterator, Countable, ArrayAccess
|
|
||||||
{
|
|
||||||
// Links are stored as a PHP serialized string
|
|
||||||
private $datastore;
|
|
||||||
|
|
||||||
// Link date storage format
|
|
||||||
const LINK_DATE_FORMAT = 'Ymd_His';
|
|
||||||
|
|
||||||
// Datastore PHP prefix
|
|
||||||
protected static $phpPrefix = '<?php /* ';
|
|
||||||
|
|
||||||
// Datastore PHP suffix
|
|
||||||
protected static $phpSuffix = ' */ ?>';
|
|
||||||
|
|
||||||
// List of links (associative array)
|
|
||||||
// - key: link date (e.g. "20110823_124546"),
|
|
||||||
// - value: associative array (keys: title, description...)
|
|
||||||
private $links;
|
|
||||||
|
|
||||||
// List of all recorded URLs (key=url, value=linkdate)
|
|
||||||
// for fast reserve search (url-->linkdate)
|
|
||||||
private $urls;
|
|
||||||
|
|
||||||
// List of linkdate keys (for the Iterator interface implementation)
|
|
||||||
private $keys;
|
|
||||||
|
|
||||||
// Position in the $this->keys array (for the Iterator interface)
|
|
||||||
private $position;
|
|
||||||
|
|
||||||
// Is the user logged in? (used to filter private links)
|
|
||||||
private $loggedIn;
|
|
||||||
|
|
||||||
// Hide public links
|
|
||||||
private $hidePublicLinks;
|
|
||||||
|
|
||||||
// link redirector set in user settings.
|
|
||||||
private $redirector;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set this to `true` to urlencode link behind redirector link, `false` to leave it untouched.
|
|
||||||
*
|
|
||||||
* Example:
|
|
||||||
* anonym.to needs clean URL while dereferer.org needs urlencoded URL.
|
|
||||||
*
|
|
||||||
* @var boolean $redirectorEncode parameter: true or false
|
|
||||||
*/
|
|
||||||
private $redirectorEncode;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new LinkDB
|
|
||||||
*
|
|
||||||
* Checks if the datastore exists; else, attempts to create a dummy one.
|
|
||||||
*
|
|
||||||
* @param string $datastore datastore file path.
|
|
||||||
* @param boolean $isLoggedIn is the user logged in?
|
|
||||||
* @param boolean $hidePublicLinks if true all links are private.
|
|
||||||
* @param string $redirector link redirector set in user settings.
|
|
||||||
* @param boolean $redirectorEncode Enable urlencode on redirected urls (default: true).
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
$datastore,
|
|
||||||
$isLoggedIn,
|
|
||||||
$hidePublicLinks,
|
|
||||||
$redirector = '',
|
|
||||||
$redirectorEncode = true
|
|
||||||
)
|
|
||||||
{
|
|
||||||
$this->datastore = $datastore;
|
|
||||||
$this->loggedIn = $isLoggedIn;
|
|
||||||
$this->hidePublicLinks = $hidePublicLinks;
|
|
||||||
$this->redirector = $redirector;
|
|
||||||
$this->redirectorEncode = $redirectorEncode === true;
|
|
||||||
$this->check();
|
|
||||||
$this->read();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Countable - Counts elements of an object
|
|
||||||
*/
|
|
||||||
public function count()
|
|
||||||
{
|
|
||||||
return count($this->links);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ArrayAccess - Assigns a value to the specified offset
|
|
||||||
*/
|
|
||||||
public function offsetSet($offset, $value)
|
|
||||||
{
|
|
||||||
// TODO: use exceptions instead of "die"
|
|
||||||
if (!$this->loggedIn) {
|
|
||||||
die('You are not authorized to add a link.');
|
|
||||||
}
|
|
||||||
if (empty($value['linkdate']) || empty($value['url'])) {
|
|
||||||
die('Internal Error: A link should always have a linkdate and URL.');
|
|
||||||
}
|
|
||||||
if (empty($offset)) {
|
|
||||||
die('You must specify a key.');
|
|
||||||
}
|
|
||||||
$this->links[$offset] = $value;
|
|
||||||
$this->urls[$value['url']]=$offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ArrayAccess - Whether or not an offset exists
|
|
||||||
*/
|
|
||||||
public function offsetExists($offset)
|
|
||||||
{
|
|
||||||
return array_key_exists($offset, $this->links);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ArrayAccess - Unsets an offset
|
|
||||||
*/
|
|
||||||
public function offsetUnset($offset)
|
|
||||||
{
|
|
||||||
if (!$this->loggedIn) {
|
|
||||||
// TODO: raise an exception
|
|
||||||
die('You are not authorized to delete a link.');
|
|
||||||
}
|
|
||||||
$url = $this->links[$offset]['url'];
|
|
||||||
unset($this->urls[$url]);
|
|
||||||
unset($this->links[$offset]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ArrayAccess - Returns the value at specified offset
|
|
||||||
*/
|
|
||||||
public function offsetGet($offset)
|
|
||||||
{
|
|
||||||
return isset($this->links[$offset]) ? $this->links[$offset] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Iterator - Returns the current element
|
|
||||||
*/
|
|
||||||
public function current()
|
|
||||||
{
|
|
||||||
return $this->links[$this->keys[$this->position]];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Iterator - Returns the key of the current element
|
|
||||||
*/
|
|
||||||
public function key()
|
|
||||||
{
|
|
||||||
return $this->keys[$this->position];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Iterator - Moves forward to next element
|
|
||||||
*/
|
|
||||||
public function next()
|
|
||||||
{
|
|
||||||
++$this->position;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Iterator - Rewinds the Iterator to the first element
|
|
||||||
*
|
|
||||||
* Entries are sorted by date (latest first)
|
|
||||||
*/
|
|
||||||
public function rewind()
|
|
||||||
{
|
|
||||||
$this->keys = array_keys($this->links);
|
|
||||||
rsort($this->keys);
|
|
||||||
$this->position = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Iterator - Checks if current position is valid
|
|
||||||
*/
|
|
||||||
public function valid()
|
|
||||||
{
|
|
||||||
return isset($this->keys[$this->position]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the DB directory and file exist
|
|
||||||
*
|
|
||||||
* If no DB file is found, creates a dummy DB.
|
|
||||||
*/
|
|
||||||
private function check()
|
|
||||||
{
|
|
||||||
if (file_exists($this->datastore)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a dummy database for example
|
|
||||||
$this->links = array();
|
|
||||||
$link = array(
|
|
||||||
'title'=>' Shaarli: the personal, minimalist, super-fast, no-database delicious clone',
|
|
||||||
'url'=>'https://github.com/shaarli/Shaarli/wiki',
|
|
||||||
'description'=>'Welcome to Shaarli! This is your first public bookmark. To edit or delete me, you must first login.
|
|
||||||
|
|
||||||
To learn how to use Shaarli, consult the link "Help/documentation" at the bottom of this page.
|
|
||||||
|
|
||||||
You use the community supported version of the original Shaarli project, by Sebastien Sauvage.',
|
|
||||||
'private'=>0,
|
|
||||||
'linkdate'=> date('Ymd_His'),
|
|
||||||
'tags'=>'opensource software'
|
|
||||||
);
|
|
||||||
$this->links[$link['linkdate']] = $link;
|
|
||||||
|
|
||||||
$link = array(
|
|
||||||
'title'=>'My secret stuff... - Pastebin.com',
|
|
||||||
'url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
|
|
||||||
'description'=>'Shhhh! I\'m a private link only YOU can see. You can delete me too.',
|
|
||||||
'private'=>1,
|
|
||||||
'linkdate'=> date('Ymd_His', strtotime('-1 minute')),
|
|
||||||
'tags'=>'secretstuff'
|
|
||||||
);
|
|
||||||
$this->links[$link['linkdate']] = $link;
|
|
||||||
|
|
||||||
// Write database to disk
|
|
||||||
$this->write();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads database from disk to memory
|
|
||||||
*/
|
|
||||||
private function read()
|
|
||||||
{
|
|
||||||
|
|
||||||
// Public links are hidden and user not logged in => nothing to show
|
|
||||||
if ($this->hidePublicLinks && !$this->loggedIn) {
|
|
||||||
$this->links = array();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read data
|
|
||||||
// Note that gzinflate is faster than gzuncompress.
|
|
||||||
// See: http://www.php.net/manual/en/function.gzdeflate.php#96439
|
|
||||||
$this->links = array();
|
|
||||||
|
|
||||||
if (file_exists($this->datastore)) {
|
|
||||||
$this->links = unserialize(gzinflate(base64_decode(
|
|
||||||
substr(file_get_contents($this->datastore),
|
|
||||||
strlen(self::$phpPrefix), -strlen(self::$phpSuffix)))));
|
|
||||||
}
|
|
||||||
|
|
||||||
// If user is not logged in, filter private links.
|
|
||||||
if (!$this->loggedIn) {
|
|
||||||
$toremove = array();
|
|
||||||
foreach ($this->links as $link) {
|
|
||||||
if ($link['private'] != 0) {
|
|
||||||
$toremove[] = $link['linkdate'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
foreach ($toremove as $linkdate) {
|
|
||||||
unset($this->links[$linkdate]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->urls = array();
|
|
||||||
foreach ($this->links as &$link) {
|
|
||||||
// Keep the list of the mapping URLs-->linkdate up-to-date.
|
|
||||||
$this->urls[$link['url']] = $link['linkdate'];
|
|
||||||
|
|
||||||
// Sanitize data fields.
|
|
||||||
sanitizeLink($link);
|
|
||||||
|
|
||||||
// Remove private tags if the user is not logged in.
|
|
||||||
if (! $this->loggedIn) {
|
|
||||||
$link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do not use the redirector for internal links (Shaarli note URL starting with a '?').
|
|
||||||
if (!empty($this->redirector) && !startsWith($link['url'], '?')) {
|
|
||||||
$link['real_url'] = $this->redirector;
|
|
||||||
if ($this->redirectorEncode) {
|
|
||||||
$link['real_url'] .= urlencode(unescape($link['url']));
|
|
||||||
} else {
|
|
||||||
$link['real_url'] .= $link['url'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$link['real_url'] = $link['url'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the database from memory to disk
|
|
||||||
*
|
|
||||||
* @throws IOException the datastore is not writable
|
|
||||||
*/
|
|
||||||
private function write()
|
|
||||||
{
|
|
||||||
if (is_file($this->datastore) && !is_writeable($this->datastore)) {
|
|
||||||
// The datastore exists but is not writeable
|
|
||||||
throw new IOException($this->datastore);
|
|
||||||
} else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
|
|
||||||
// The datastore does not exist and its parent directory is not writeable
|
|
||||||
throw new IOException(dirname($this->datastore));
|
|
||||||
}
|
|
||||||
|
|
||||||
file_put_contents(
|
|
||||||
$this->datastore,
|
|
||||||
self::$phpPrefix.base64_encode(gzdeflate(serialize($this->links))).self::$phpSuffix
|
|
||||||
);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the database from memory to disk
|
|
||||||
*
|
|
||||||
* @param string $pageCacheDir page cache directory
|
|
||||||
*/
|
|
||||||
public function save($pageCacheDir)
|
|
||||||
{
|
|
||||||
if (!$this->loggedIn) {
|
|
||||||
// TODO: raise an Exception instead
|
|
||||||
die('You are not authorized to change the database.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->write();
|
|
||||||
|
|
||||||
invalidateCaches($pageCacheDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the link for a given URL, or False if it does not exist.
|
|
||||||
*
|
|
||||||
* @param string $url URL to search for
|
|
||||||
*
|
|
||||||
* @return mixed the existing link if it exists, else 'false'
|
|
||||||
*/
|
|
||||||
public function getLinkFromUrl($url)
|
|
||||||
{
|
|
||||||
if (isset($this->urls[$url])) {
|
|
||||||
return $this->links[$this->urls[$url]];
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the shaare corresponding to a smallHash.
|
|
||||||
*
|
|
||||||
* @param string $request QUERY_STRING server parameter.
|
|
||||||
*
|
|
||||||
* @return array $filtered array containing permalink data.
|
|
||||||
*
|
|
||||||
* @throws LinkNotFoundException if the smallhash is malformed or doesn't match any link.
|
|
||||||
*/
|
|
||||||
public function filterHash($request)
|
|
||||||
{
|
|
||||||
$request = substr($request, 0, 6);
|
|
||||||
$linkFilter = new LinkFilter($this->links);
|
|
||||||
return $linkFilter->filter(LinkFilter::$FILTER_HASH, $request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the list of articles for a given day.
|
|
||||||
*
|
|
||||||
* @param string $request day to filter. Format: YYYYMMDD.
|
|
||||||
*
|
|
||||||
* @return array list of shaare found.
|
|
||||||
*/
|
|
||||||
public function filterDay($request) {
|
|
||||||
$linkFilter = new LinkFilter($this->links);
|
|
||||||
return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter links according to search parameters.
|
|
||||||
*
|
|
||||||
* @param array $filterRequest Search request content. Supported keys:
|
|
||||||
* - searchtags: list of tags
|
|
||||||
* - searchterm: term search
|
|
||||||
* @param bool $casesensitive Optional: Perform case sensitive filter
|
|
||||||
* @param bool $privateonly Optional: Returns private links only if true.
|
|
||||||
*
|
|
||||||
* @return array filtered links, all links if no suitable filter was provided.
|
|
||||||
*/
|
|
||||||
public function filterSearch($filterRequest = array(), $casesensitive = false, $privateonly = false)
|
|
||||||
{
|
|
||||||
// Filter link database according to parameters.
|
|
||||||
$searchtags = !empty($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
|
|
||||||
$searchterm = !empty($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
|
|
||||||
|
|
||||||
// Search tags + fullsearch.
|
|
||||||
if (! empty($searchtags) && ! empty($searchterm)) {
|
|
||||||
$type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT;
|
|
||||||
$request = array($searchtags, $searchterm);
|
|
||||||
}
|
|
||||||
// Search by tags.
|
|
||||||
elseif (! empty($searchtags)) {
|
|
||||||
$type = LinkFilter::$FILTER_TAG;
|
|
||||||
$request = $searchtags;
|
|
||||||
}
|
|
||||||
// Fulltext search.
|
|
||||||
elseif (! empty($searchterm)) {
|
|
||||||
$type = LinkFilter::$FILTER_TEXT;
|
|
||||||
$request = $searchterm;
|
|
||||||
}
|
|
||||||
// Otherwise, display without filtering.
|
|
||||||
else {
|
|
||||||
$type = '';
|
|
||||||
$request = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$linkFilter = new LinkFilter($this->links);
|
|
||||||
return $linkFilter->filter($type, $request, $casesensitive, $privateonly);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the list of all tags
|
|
||||||
* Output: associative array key=tags, value=0
|
|
||||||
*/
|
|
||||||
public function allTags()
|
|
||||||
{
|
|
||||||
$tags = array();
|
|
||||||
$caseMapping = array();
|
|
||||||
foreach ($this->links as $link) {
|
|
||||||
foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) {
|
|
||||||
if (empty($tag)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// The first case found will be displayed.
|
|
||||||
if (!isset($caseMapping[strtolower($tag)])) {
|
|
||||||
$caseMapping[strtolower($tag)] = $tag;
|
|
||||||
$tags[$caseMapping[strtolower($tag)]] = 0;
|
|
||||||
}
|
|
||||||
$tags[$caseMapping[strtolower($tag)]]++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Sort tags by usage (most used tag first)
|
|
||||||
arsort($tags);
|
|
||||||
return $tags;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the list of days containing articles (oldest first)
|
|
||||||
* Output: An array containing days (in format YYYYMMDD).
|
|
||||||
*/
|
|
||||||
public function days()
|
|
||||||
{
|
|
||||||
$linkDays = array();
|
|
||||||
foreach (array_keys($this->links) as $day) {
|
|
||||||
$linkDays[substr($day, 0, 8)] = 0;
|
|
||||||
}
|
|
||||||
$linkDays = array_keys($linkDays);
|
|
||||||
sort($linkDays);
|
|
||||||
|
|
||||||
return $linkDays;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,361 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class LinkFilter.
|
|
||||||
*
|
|
||||||
* Perform search and filter operation on link data list.
|
|
||||||
*/
|
|
||||||
class LinkFilter
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var string permalinks.
|
|
||||||
*/
|
|
||||||
public static $FILTER_HASH = 'permalink';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string text search.
|
|
||||||
*/
|
|
||||||
public static $FILTER_TEXT = 'fulltext';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string tag filter.
|
|
||||||
*/
|
|
||||||
public static $FILTER_TAG = 'tags';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string filter by day.
|
|
||||||
*/
|
|
||||||
public static $FILTER_DAY = 'FILTER_DAY';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var string Allowed characters for hashtags (regex syntax).
|
|
||||||
*/
|
|
||||||
public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array all available links.
|
|
||||||
*/
|
|
||||||
private $links;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array $links initialization.
|
|
||||||
*/
|
|
||||||
public function __construct($links)
|
|
||||||
{
|
|
||||||
$this->links = $links;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter links according to parameters.
|
|
||||||
*
|
|
||||||
* @param string $type Type of filter (eg. tags, permalink, etc.).
|
|
||||||
* @param mixed $request Filter content.
|
|
||||||
* @param bool $casesensitive Optional: Perform case sensitive filter if true.
|
|
||||||
* @param bool $privateonly Optional: Only returns private links if true.
|
|
||||||
*
|
|
||||||
* @return array filtered link list.
|
|
||||||
*/
|
|
||||||
public function filter($type, $request, $casesensitive = false, $privateonly = false)
|
|
||||||
{
|
|
||||||
switch($type) {
|
|
||||||
case self::$FILTER_HASH:
|
|
||||||
return $this->filterSmallHash($request);
|
|
||||||
case self::$FILTER_TAG | self::$FILTER_TEXT:
|
|
||||||
if (!empty($request)) {
|
|
||||||
$filtered = $this->links;
|
|
||||||
if (isset($request[0])) {
|
|
||||||
$filtered = $this->filterTags($request[0], $casesensitive, $privateonly);
|
|
||||||
}
|
|
||||||
if (isset($request[1])) {
|
|
||||||
$lf = new LinkFilter($filtered);
|
|
||||||
$filtered = $lf->filterFulltext($request[1], $privateonly);
|
|
||||||
}
|
|
||||||
return $filtered;
|
|
||||||
}
|
|
||||||
return $this->noFilter($privateonly);
|
|
||||||
case self::$FILTER_TEXT:
|
|
||||||
return $this->filterFulltext($request, $privateonly);
|
|
||||||
case self::$FILTER_TAG:
|
|
||||||
return $this->filterTags($request, $casesensitive, $privateonly);
|
|
||||||
case self::$FILTER_DAY:
|
|
||||||
return $this->filterDay($request);
|
|
||||||
default:
|
|
||||||
return $this->noFilter($privateonly);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unknown filter, but handle private only.
|
|
||||||
*
|
|
||||||
* @param bool $privateonly returns private link only if true.
|
|
||||||
*
|
|
||||||
* @return array filtered links.
|
|
||||||
*/
|
|
||||||
private function noFilter($privateonly = false)
|
|
||||||
{
|
|
||||||
if (! $privateonly) {
|
|
||||||
krsort($this->links);
|
|
||||||
return $this->links;
|
|
||||||
}
|
|
||||||
|
|
||||||
$out = array();
|
|
||||||
foreach ($this->links as $value) {
|
|
||||||
if ($value['private']) {
|
|
||||||
$out[$value['linkdate']] = $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
krsort($out);
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the shaare corresponding to a smallHash.
|
|
||||||
*
|
|
||||||
* @param string $smallHash permalink hash.
|
|
||||||
*
|
|
||||||
* @return array $filtered array containing permalink data.
|
|
||||||
*
|
|
||||||
* @throws LinkNotFoundException if the smallhash doesn't match any link.
|
|
||||||
*/
|
|
||||||
private function filterSmallHash($smallHash)
|
|
||||||
{
|
|
||||||
$filtered = array();
|
|
||||||
foreach ($this->links as $l) {
|
|
||||||
if ($smallHash == smallHash($l['linkdate'])) {
|
|
||||||
// Yes, this is ugly and slow
|
|
||||||
$filtered[$l['linkdate']] = $l;
|
|
||||||
return $filtered;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($filtered)) {
|
|
||||||
throw new LinkNotFoundException();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the list of links corresponding to a full-text search
|
|
||||||
*
|
|
||||||
* Searches:
|
|
||||||
* - in the URLs, title and description;
|
|
||||||
* - are case-insensitive;
|
|
||||||
* - terms surrounded by quotes " are exact terms search.
|
|
||||||
* - terms starting with a dash - are excluded (except exact terms).
|
|
||||||
*
|
|
||||||
* Example:
|
|
||||||
* print_r($mydb->filterFulltext('hollandais'));
|
|
||||||
*
|
|
||||||
* mb_convert_case($val, MB_CASE_LOWER, 'UTF-8')
|
|
||||||
* - allows to perform searches on Unicode text
|
|
||||||
* - see https://github.com/shaarli/Shaarli/issues/75 for examples
|
|
||||||
*
|
|
||||||
* @param string $searchterms search query.
|
|
||||||
* @param bool $privateonly return only private links if true.
|
|
||||||
*
|
|
||||||
* @return array search results.
|
|
||||||
*/
|
|
||||||
private function filterFulltext($searchterms, $privateonly = false)
|
|
||||||
{
|
|
||||||
if (empty($searchterms)) {
|
|
||||||
return $this->links;
|
|
||||||
}
|
|
||||||
|
|
||||||
$filtered = array();
|
|
||||||
$search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
|
|
||||||
$exactRegex = '/"([^"]+)"/';
|
|
||||||
// Retrieve exact search terms.
|
|
||||||
preg_match_all($exactRegex, $search, $exactSearch);
|
|
||||||
$exactSearch = array_values(array_filter($exactSearch[1]));
|
|
||||||
|
|
||||||
// Remove exact search terms to get AND terms search.
|
|
||||||
$explodedSearchAnd = explode(' ', trim(preg_replace($exactRegex, '', $search)));
|
|
||||||
$explodedSearchAnd = array_values(array_filter($explodedSearchAnd));
|
|
||||||
|
|
||||||
// Filter excluding terms and update andSearch.
|
|
||||||
$excludeSearch = array();
|
|
||||||
$andSearch = array();
|
|
||||||
foreach ($explodedSearchAnd as $needle) {
|
|
||||||
if ($needle[0] == '-' && strlen($needle) > 1) {
|
|
||||||
$excludeSearch[] = substr($needle, 1);
|
|
||||||
} else {
|
|
||||||
$andSearch[] = $needle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$keys = array('title', 'description', 'url', 'tags');
|
|
||||||
|
|
||||||
// Iterate over every stored link.
|
|
||||||
foreach ($this->links as $link) {
|
|
||||||
|
|
||||||
// ignore non private links when 'privatonly' is on.
|
|
||||||
if (! $link['private'] && $privateonly === true) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Concatenate link fields to search across fields.
|
|
||||||
// Adds a '\' separator for exact search terms.
|
|
||||||
$content = '';
|
|
||||||
foreach ($keys as $key) {
|
|
||||||
$content .= mb_convert_case($link[$key], MB_CASE_LOWER, 'UTF-8') . '\\';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Be optimistic
|
|
||||||
$found = true;
|
|
||||||
|
|
||||||
// First, we look for exact term search
|
|
||||||
for ($i = 0; $i < count($exactSearch) && $found; $i++) {
|
|
||||||
$found = strpos($content, $exactSearch[$i]) !== false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iterate over keywords, if keyword is not found,
|
|
||||||
// no need to check for the others. We want all or nothing.
|
|
||||||
for ($i = 0; $i < count($andSearch) && $found; $i++) {
|
|
||||||
$found = strpos($content, $andSearch[$i]) !== false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exclude terms.
|
|
||||||
for ($i = 0; $i < count($excludeSearch) && $found; $i++) {
|
|
||||||
$found = strpos($content, $excludeSearch[$i]) === false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($found) {
|
|
||||||
$filtered[$link['linkdate']] = $link;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
krsort($filtered);
|
|
||||||
return $filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the list of links associated with a given list of tags
|
|
||||||
*
|
|
||||||
* You can specify one or more tags, separated by space or a comma, e.g.
|
|
||||||
* print_r($mydb->filterTags('linux programming'));
|
|
||||||
*
|
|
||||||
* @param string $tags list of tags separated by commas or blank spaces.
|
|
||||||
* @param bool $casesensitive ignore case if false.
|
|
||||||
* @param bool $privateonly returns private links only.
|
|
||||||
*
|
|
||||||
* @return array filtered links.
|
|
||||||
*/
|
|
||||||
public function filterTags($tags, $casesensitive = false, $privateonly = false)
|
|
||||||
{
|
|
||||||
// Implode if array for clean up.
|
|
||||||
$tags = is_array($tags) ? trim(implode(' ', $tags)) : $tags;
|
|
||||||
if (empty($tags)) {
|
|
||||||
return $this->links;
|
|
||||||
}
|
|
||||||
|
|
||||||
$searchtags = self::tagsStrToArray($tags, $casesensitive);
|
|
||||||
$filtered = array();
|
|
||||||
if (empty($searchtags)) {
|
|
||||||
return $filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->links as $link) {
|
|
||||||
// ignore non private links when 'privatonly' is on.
|
|
||||||
if (! $link['private'] && $privateonly === true) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$linktags = self::tagsStrToArray($link['tags'], $casesensitive);
|
|
||||||
|
|
||||||
$found = true;
|
|
||||||
for ($i = 0 ; $i < count($searchtags) && $found; $i++) {
|
|
||||||
// Exclusive search, quit if tag found.
|
|
||||||
// Or, tag not found in the link, quit.
|
|
||||||
if (($searchtags[$i][0] == '-'
|
|
||||||
&& $this->searchTagAndHashTag(substr($searchtags[$i], 1), $linktags, $link['description']))
|
|
||||||
|| ($searchtags[$i][0] != '-')
|
|
||||||
&& ! $this->searchTagAndHashTag($searchtags[$i], $linktags, $link['description'])
|
|
||||||
) {
|
|
||||||
$found = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($found) {
|
|
||||||
$filtered[$link['linkdate']] = $link;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
krsort($filtered);
|
|
||||||
return $filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the list of articles for a given day, chronologically sorted
|
|
||||||
*
|
|
||||||
* Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g.
|
|
||||||
* print_r($mydb->filterDay('20120125'));
|
|
||||||
*
|
|
||||||
* @param string $day day to filter.
|
|
||||||
*
|
|
||||||
* @return array all link matching given day.
|
|
||||||
*
|
|
||||||
* @throws Exception if date format is invalid.
|
|
||||||
*/
|
|
||||||
public function filterDay($day)
|
|
||||||
{
|
|
||||||
if (! checkDateFormat('Ymd', $day)) {
|
|
||||||
throw new Exception('Invalid date format');
|
|
||||||
}
|
|
||||||
|
|
||||||
$filtered = array();
|
|
||||||
foreach ($this->links as $l) {
|
|
||||||
if (startsWith($l['linkdate'], $day)) {
|
|
||||||
$filtered[$l['linkdate']] = $l;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ksort($filtered);
|
|
||||||
return $filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a tag is found in the taglist, or as an hashtag in the link description.
|
|
||||||
*
|
|
||||||
* @param string $tag Tag to search.
|
|
||||||
* @param array $taglist List of tags for the current link.
|
|
||||||
* @param string $description Link description.
|
|
||||||
*
|
|
||||||
* @return bool True if found, false otherwise.
|
|
||||||
*/
|
|
||||||
protected function searchTagAndHashTag($tag, $taglist, $description)
|
|
||||||
{
|
|
||||||
if (in_array($tag, $taglist)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/(^| )#'. $tag .'([^'. self::$HASHTAG_CHARS .']|$)/mui', $description) > 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a list of tags (str) to an array. Also
|
|
||||||
* - handle case sensitivity.
|
|
||||||
* - accepts spaces commas as separator.
|
|
||||||
*
|
|
||||||
* @param string $tags string containing a list of tags.
|
|
||||||
* @param bool $casesensitive will convert everything to lowercase if false.
|
|
||||||
*
|
|
||||||
* @return array filtered tags string.
|
|
||||||
*/
|
|
||||||
public static function tagsStrToArray($tags, $casesensitive)
|
|
||||||
{
|
|
||||||
// We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
|
|
||||||
$tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8');
|
|
||||||
$tagsOut = str_replace(',', ' ', $tagsOut);
|
|
||||||
|
|
||||||
return array_values(array_filter(explode(' ', trim($tagsOut)), 'strlen'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class LinkNotFoundException extends Exception
|
|
||||||
{
|
|
||||||
protected $message = 'The link you are trying to reach does not exist or has been deleted.';
|
|
||||||
}
|
|
|
@ -1,171 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract title from an HTML document.
|
|
||||||
*
|
|
||||||
* @param string $html HTML content where to look for a title.
|
|
||||||
*
|
|
||||||
* @return bool|string Extracted title if found, false otherwise.
|
|
||||||
*/
|
|
||||||
function html_extract_title($html)
|
|
||||||
{
|
|
||||||
if (preg_match('!<title.*?>(.*?)</title>!is', $html, $matches)) {
|
|
||||||
return trim(str_replace("\n", '', $matches[1]));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine charset from downloaded page.
|
|
||||||
* Priority:
|
|
||||||
* 1. HTTP headers (Content type).
|
|
||||||
* 2. HTML content page (tag <meta charset>).
|
|
||||||
* 3. Use a default charset (default: UTF-8).
|
|
||||||
*
|
|
||||||
* @param array $headers HTTP headers array.
|
|
||||||
* @param string $htmlContent HTML content where to look for charset.
|
|
||||||
* @param string $defaultCharset Default charset to apply if other methods failed.
|
|
||||||
*
|
|
||||||
* @return string Determined charset.
|
|
||||||
*/
|
|
||||||
function get_charset($headers, $htmlContent, $defaultCharset = 'utf-8')
|
|
||||||
{
|
|
||||||
if ($charset = headers_extract_charset($headers)) {
|
|
||||||
return $charset;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($charset = html_extract_charset($htmlContent)) {
|
|
||||||
return $charset;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $defaultCharset;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract charset from HTTP headers if it's defined.
|
|
||||||
*
|
|
||||||
* @param array $headers HTTP headers array.
|
|
||||||
*
|
|
||||||
* @return bool|string Charset string if found (lowercase), false otherwise.
|
|
||||||
*/
|
|
||||||
function headers_extract_charset($headers)
|
|
||||||
{
|
|
||||||
if (! empty($headers['Content-Type']) && strpos($headers['Content-Type'], 'charset=') !== false) {
|
|
||||||
preg_match('/charset="?([^; ]+)/i', $headers['Content-Type'], $match);
|
|
||||||
if (! empty($match[1])) {
|
|
||||||
return strtolower(trim($match[1]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract charset HTML content (tag <meta charset>).
|
|
||||||
*
|
|
||||||
* @param string $html HTML content where to look for charset.
|
|
||||||
*
|
|
||||||
* @return bool|string Charset string if found, false otherwise.
|
|
||||||
*/
|
|
||||||
function html_extract_charset($html)
|
|
||||||
{
|
|
||||||
// Get encoding specified in HTML header.
|
|
||||||
preg_match('#<meta .*charset=["\']?([^";\'>/]+)["\']? */?>#Usi', $html, $enc);
|
|
||||||
if (!empty($enc[1])) {
|
|
||||||
return strtolower($enc[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count private links in given linklist.
|
|
||||||
*
|
|
||||||
* @param array|Countable $links Linklist.
|
|
||||||
*
|
|
||||||
* @return int Number of private links.
|
|
||||||
*/
|
|
||||||
function count_private($links)
|
|
||||||
{
|
|
||||||
$cpt = 0;
|
|
||||||
foreach ($links as $link) {
|
|
||||||
$cpt = $link['private'] == true ? $cpt + 1 : $cpt;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $cpt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In a string, converts URLs to clickable links.
|
|
||||||
*
|
|
||||||
* @param string $text input string.
|
|
||||||
* @param string $redirector if a redirector is set, use it to gerenate links.
|
|
||||||
*
|
|
||||||
* @return string returns $text with all links converted to HTML links.
|
|
||||||
*
|
|
||||||
* @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
|
|
||||||
*/
|
|
||||||
function text2clickable($text, $redirector = '')
|
|
||||||
{
|
|
||||||
$regex = '!(((?:https?|ftp|file)://|apt:|magnet:)\S+[[:alnum:]]/?)!si';
|
|
||||||
|
|
||||||
if (empty($redirector)) {
|
|
||||||
return preg_replace($regex, '<a href="$1">$1</a>', $text);
|
|
||||||
}
|
|
||||||
// Redirector is set, urlencode the final URL.
|
|
||||||
return preg_replace_callback(
|
|
||||||
$regex,
|
|
||||||
function ($matches) use ($redirector) {
|
|
||||||
return '<a href="' . $redirector . urlencode($matches[1]) .'">'. $matches[1] .'</a>';
|
|
||||||
},
|
|
||||||
$text
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-link hashtags.
|
|
||||||
*
|
|
||||||
* @param string $description Given description.
|
|
||||||
* @param string $indexUrl Root URL.
|
|
||||||
*
|
|
||||||
* @return string Description with auto-linked hashtags.
|
|
||||||
*/
|
|
||||||
function hashtag_autolink($description, $indexUrl = '')
|
|
||||||
{
|
|
||||||
/*
|
|
||||||
* To support unicode: http://stackoverflow.com/a/35498078/1484919
|
|
||||||
* \p{Pc} - to match underscore
|
|
||||||
* \p{N} - numeric character in any script
|
|
||||||
* \p{L} - letter from any language
|
|
||||||
* \p{Mn} - any non marking space (accents, umlauts, etc)
|
|
||||||
*/
|
|
||||||
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
|
|
||||||
$replacement = '$1<a href="'. $indexUrl .'?addtag=$2" title="Hashtag $2">#$2</a>';
|
|
||||||
return preg_replace($regex, $replacement, $description);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function inserts where relevant so that multiple spaces are properly displayed in HTML
|
|
||||||
* even in the absence of <pre> (This is used in description to keep text formatting).
|
|
||||||
*
|
|
||||||
* @param string $text input text.
|
|
||||||
*
|
|
||||||
* @return string formatted text.
|
|
||||||
*/
|
|
||||||
function space2nbsp($text)
|
|
||||||
{
|
|
||||||
return preg_replace('/(^| ) /m', '$1 ', $text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format Shaarli's description
|
|
||||||
*
|
|
||||||
* @param string $description shaare's description.
|
|
||||||
* @param string $redirector if a redirector is set, use it to gerenate links.
|
|
||||||
* @param string $indexUrl URL to Shaarli's index.
|
|
||||||
*
|
|
||||||
* @return string formatted description.
|
|
||||||
*/
|
|
||||||
function format_description($description, $redirector = '', $indexUrl = '') {
|
|
||||||
return nl2br(space2nbsp(hashtag_autolink(text2clickable($description, $redirector), $indexUrl)));
|
|
||||||
}
|
|
|
@ -1,195 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utilities to import and export bookmarks using the Netscape format
|
|
||||||
*/
|
|
||||||
class NetscapeBookmarkUtils
|
|
||||||
{
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filters links and adds Netscape-formatted fields
|
|
||||||
*
|
|
||||||
* Added fields:
|
|
||||||
* - timestamp link addition date, using the Unix epoch format
|
|
||||||
* - taglist comma-separated tag list
|
|
||||||
*
|
|
||||||
* @param LinkDB $linkDb Link datastore
|
|
||||||
* @param string $selection Which links to export: (all|private|public)
|
|
||||||
* @param bool $prependNoteUrl Prepend note permalinks with the server's URL
|
|
||||||
* @param string $indexUrl Absolute URL of the Shaarli index page
|
|
||||||
*
|
|
||||||
* @throws Exception Invalid export selection
|
|
||||||
*
|
|
||||||
* @return array The links to be exported, with additional fields
|
|
||||||
*/
|
|
||||||
public static function filterAndFormat($linkDb, $selection, $prependNoteUrl, $indexUrl)
|
|
||||||
{
|
|
||||||
// see tpl/export.html for possible values
|
|
||||||
if (! in_array($selection, array('all', 'public', 'private'))) {
|
|
||||||
throw new Exception('Invalid export selection: "'.$selection.'"');
|
|
||||||
}
|
|
||||||
|
|
||||||
$bookmarkLinks = array();
|
|
||||||
|
|
||||||
foreach ($linkDb as $link) {
|
|
||||||
if ($link['private'] != 0 && $selection == 'public') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($link['private'] == 0 && $selection == 'private') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$date = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $link['linkdate']);
|
|
||||||
$link['timestamp'] = $date->getTimestamp();
|
|
||||||
$link['taglist'] = str_replace(' ', ',', $link['tags']);
|
|
||||||
|
|
||||||
if (startsWith($link['url'], '?') && $prependNoteUrl) {
|
|
||||||
$link['url'] = $indexUrl . $link['url'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$bookmarkLinks[] = $link;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $bookmarkLinks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates an import status summary
|
|
||||||
*
|
|
||||||
* @param string $filename name of the file to import
|
|
||||||
* @param int $filesize size of the file to import
|
|
||||||
* @param int $importCount how many links were imported
|
|
||||||
* @param int $overwriteCount how many links were overwritten
|
|
||||||
* @param int $skipCount how many links were skipped
|
|
||||||
*
|
|
||||||
* @return string Summary of the bookmark import status
|
|
||||||
*/
|
|
||||||
private static function importStatus(
|
|
||||||
$filename,
|
|
||||||
$filesize,
|
|
||||||
$importCount=0,
|
|
||||||
$overwriteCount=0,
|
|
||||||
$skipCount=0
|
|
||||||
)
|
|
||||||
{
|
|
||||||
$status = 'File '.$filename.' ('.$filesize.' bytes) ';
|
|
||||||
if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) {
|
|
||||||
$status .= 'has an unknown file format. Nothing was imported.';
|
|
||||||
} else {
|
|
||||||
$status .= 'was successfully processed: '.$importCount.' links imported, ';
|
|
||||||
$status .= $overwriteCount.' links overwritten, ';
|
|
||||||
$status .= $skipCount.' links skipped.';
|
|
||||||
}
|
|
||||||
return $status;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Imports Web bookmarks from an uploaded Netscape bookmark dump
|
|
||||||
*
|
|
||||||
* @param array $post Server $_POST parameters
|
|
||||||
* @param array $files Server $_FILES parameters
|
|
||||||
* @param LinkDB $linkDb Loaded LinkDB instance
|
|
||||||
* @param string $pagecache Page cache
|
|
||||||
*
|
|
||||||
* @return string Summary of the bookmark import status
|
|
||||||
*/
|
|
||||||
public static function import($post, $files, $linkDb, $pagecache)
|
|
||||||
{
|
|
||||||
$filename = $files['filetoupload']['name'];
|
|
||||||
$filesize = $files['filetoupload']['size'];
|
|
||||||
$data = file_get_contents($files['filetoupload']['tmp_name']);
|
|
||||||
|
|
||||||
if (strpos($data, '<!DOCTYPE NETSCAPE-Bookmark-file-1>') === false) {
|
|
||||||
return self::importStatus($filename, $filesize);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overwrite existing links?
|
|
||||||
$overwrite = ! empty($post['overwrite']);
|
|
||||||
|
|
||||||
// Add tags to all imported links?
|
|
||||||
if (empty($post['default_tags'])) {
|
|
||||||
$defaultTags = array();
|
|
||||||
} else {
|
|
||||||
$defaultTags = preg_split(
|
|
||||||
'/[\s,]+/',
|
|
||||||
escape($post['default_tags'])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// links are imported as public by default
|
|
||||||
$defaultPrivacy = 0;
|
|
||||||
|
|
||||||
$parser = new NetscapeBookmarkParser(
|
|
||||||
true, // nested tag support
|
|
||||||
$defaultTags, // additional user-specified tags
|
|
||||||
strval(1 - $defaultPrivacy) // defaultPub = 1 - defaultPrivacy
|
|
||||||
);
|
|
||||||
$bookmarks = $parser->parseString($data);
|
|
||||||
|
|
||||||
$importCount = 0;
|
|
||||||
$overwriteCount = 0;
|
|
||||||
$skipCount = 0;
|
|
||||||
|
|
||||||
foreach ($bookmarks as $bkm) {
|
|
||||||
$private = $defaultPrivacy;
|
|
||||||
if (empty($post['privacy']) || $post['privacy'] == 'default') {
|
|
||||||
// use value from the imported file
|
|
||||||
$private = $bkm['pub'] == '1' ? 0 : 1;
|
|
||||||
} else if ($post['privacy'] == 'private') {
|
|
||||||
// all imported links are private
|
|
||||||
$private = 1;
|
|
||||||
} else if ($post['privacy'] == 'public') {
|
|
||||||
// all imported links are public
|
|
||||||
$private = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$newLink = array(
|
|
||||||
'title' => $bkm['title'],
|
|
||||||
'url' => $bkm['uri'],
|
|
||||||
'description' => $bkm['note'],
|
|
||||||
'private' => $private,
|
|
||||||
'linkdate'=> '',
|
|
||||||
'tags' => $bkm['tags']
|
|
||||||
);
|
|
||||||
|
|
||||||
$existingLink = $linkDb->getLinkFromUrl($bkm['uri']);
|
|
||||||
|
|
||||||
if ($existingLink !== false) {
|
|
||||||
if ($overwrite === false) {
|
|
||||||
// Do not overwrite an existing link
|
|
||||||
$skipCount++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overwrite an existing link, keep its date
|
|
||||||
$newLink['linkdate'] = $existingLink['linkdate'];
|
|
||||||
$linkDb[$existingLink['linkdate']] = $newLink;
|
|
||||||
$importCount++;
|
|
||||||
$overwriteCount++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add a new link
|
|
||||||
$newLinkDate = new DateTime('@'.strval($bkm['time']));
|
|
||||||
while (!empty($linkDb[$newLinkDate->format(LinkDB::LINK_DATE_FORMAT)])) {
|
|
||||||
// Ensure the date/time is not already used
|
|
||||||
// - this hack is necessary as the date/time acts as a primary key
|
|
||||||
// - apply 1 second increments until an unused index is found
|
|
||||||
// See https://github.com/shaarli/Shaarli/issues/351
|
|
||||||
$newLinkDate->add(new DateInterval('PT1S'));
|
|
||||||
}
|
|
||||||
$linkDbDate = $newLinkDate->format(LinkDB::LINK_DATE_FORMAT);
|
|
||||||
$newLink['linkdate'] = $linkDbDate;
|
|
||||||
$linkDb[$linkDbDate] = $newLink;
|
|
||||||
$importCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
$linkDb->save($pagecache);
|
|
||||||
return self::importStatus(
|
|
||||||
$filename,
|
|
||||||
$filesize,
|
|
||||||
$importCount,
|
|
||||||
$overwriteCount,
|
|
||||||
$skipCount
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,149 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This class is in charge of building the final page.
|
|
||||||
* (This is basically a wrapper around RainTPL which pre-fills some fields.)
|
|
||||||
* $p = new PageBuilder();
|
|
||||||
* $p->assign('myfield','myvalue');
|
|
||||||
* $p->renderPage('mytemplate');
|
|
||||||
*/
|
|
||||||
class PageBuilder
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var RainTPL RainTPL instance.
|
|
||||||
*/
|
|
||||||
private $tpl;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var ConfigManager $conf Configuration Manager instance.
|
|
||||||
*/
|
|
||||||
protected $conf;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PageBuilder constructor.
|
|
||||||
* $tpl is initialized at false for lazy loading.
|
|
||||||
*
|
|
||||||
* @param ConfigManager $conf Configuration Manager instance (reference).
|
|
||||||
*/
|
|
||||||
function __construct(&$conf)
|
|
||||||
{
|
|
||||||
$this->tpl = false;
|
|
||||||
$this->conf = $conf;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize all default tpl tags.
|
|
||||||
*/
|
|
||||||
private function initialize()
|
|
||||||
{
|
|
||||||
$this->tpl = new RainTPL();
|
|
||||||
|
|
||||||
try {
|
|
||||||
$version = ApplicationUtils::checkUpdate(
|
|
||||||
shaarli_version,
|
|
||||||
$this->conf->get('resource.update_check'),
|
|
||||||
$this->conf->get('updates.check_updates_interval'),
|
|
||||||
$this->conf->get('updates.check_updates'),
|
|
||||||
isLoggedIn(),
|
|
||||||
$this->conf->get('updates.check_updates_branch')
|
|
||||||
);
|
|
||||||
$this->tpl->assign('newVersion', escape($version));
|
|
||||||
$this->tpl->assign('versionError', '');
|
|
||||||
|
|
||||||
} catch (Exception $exc) {
|
|
||||||
logm($this->conf->get('resource.log'), $_SERVER['REMOTE_ADDR'], $exc->getMessage());
|
|
||||||
$this->tpl->assign('newVersion', '');
|
|
||||||
$this->tpl->assign('versionError', escape($exc->getMessage()));
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->tpl->assign('feedurl', escape(index_url($_SERVER)));
|
|
||||||
$searchcrits = ''; // Search criteria
|
|
||||||
if (!empty($_GET['searchtags'])) {
|
|
||||||
$searchcrits .= '&searchtags=' . urlencode($_GET['searchtags']);
|
|
||||||
}
|
|
||||||
if (!empty($_GET['searchterm'])) {
|
|
||||||
$searchcrits .= '&searchterm=' . urlencode($_GET['searchterm']);
|
|
||||||
}
|
|
||||||
$this->tpl->assign('searchcrits', $searchcrits);
|
|
||||||
$this->tpl->assign('source', index_url($_SERVER));
|
|
||||||
$this->tpl->assign('version', shaarli_version);
|
|
||||||
$this->tpl->assign('scripturl', index_url($_SERVER));
|
|
||||||
$this->tpl->assign('privateonly', !empty($_SESSION['privateonly'])); // Show only private links?
|
|
||||||
$this->tpl->assign('pagetitle', $this->conf->get('general.title', 'Shaarli'));
|
|
||||||
if ($this->conf->exists('general.header_link')) {
|
|
||||||
$this->tpl->assign('titleLink', $this->conf->get('general.header_link'));
|
|
||||||
}
|
|
||||||
$this->tpl->assign('shaarlititle', $this->conf->get('general.title', 'Shaarli'));
|
|
||||||
$this->tpl->assign('openshaarli', $this->conf->get('security.open_shaarli', false));
|
|
||||||
$this->tpl->assign('showatom', $this->conf->get('feed.show_atom', false));
|
|
||||||
$this->tpl->assign('hide_timestamps', $this->conf->get('privacy.hide_timestamps', false));
|
|
||||||
$this->tpl->assign('token', getToken($this->conf));
|
|
||||||
// To be removed with a proper theme configuration.
|
|
||||||
$this->tpl->assign('conf', $this->conf->get('resource.theme', 'default'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The following assign() method is basically the same as RainTPL (except lazy loading)
|
|
||||||
*
|
|
||||||
* @param string $placeholder Template placeholder.
|
|
||||||
* @param mixed $value Value to assign.
|
|
||||||
*/
|
|
||||||
public function assign($placeholder, $value)
|
|
||||||
{
|
|
||||||
if ($this->tpl === false) {
|
|
||||||
$this->initialize();
|
|
||||||
}
|
|
||||||
$this->tpl->assign($placeholder, $value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assign an array of data to the template builder.
|
|
||||||
*
|
|
||||||
* @param array $data Data to assign.
|
|
||||||
*
|
|
||||||
* @return false if invalid data.
|
|
||||||
*/
|
|
||||||
public function assignAll($data)
|
|
||||||
{
|
|
||||||
if ($this->tpl === false) {
|
|
||||||
$this->initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($data) || !is_array($data)){
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($data as $key => $value) {
|
|
||||||
$this->assign($key, $value);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a specific page (using a template file).
|
|
||||||
* e.g. $pb->renderPage('picwall');
|
|
||||||
*
|
|
||||||
* @param string $page Template filename (without extension).
|
|
||||||
*/
|
|
||||||
public function renderPage($page)
|
|
||||||
{
|
|
||||||
if ($this->tpl === false) {
|
|
||||||
$this->initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->tpl->draw($page);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a 404 page (uses the template : tpl/404.tpl)
|
|
||||||
* usage : $PAGE->render404('The link was deleted')
|
|
||||||
*
|
|
||||||
* @param string $message A messate to display what is not found
|
|
||||||
*/
|
|
||||||
public function render404($message = 'The page you are trying to reach does not exist or has been deleted.')
|
|
||||||
{
|
|
||||||
header($_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found');
|
|
||||||
$this->tpl->assign('error_message', $message);
|
|
||||||
$this->renderPage('404');
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,242 +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);
|
|
||||||
|
|
||||||
// Read parameters and format them into an array.
|
|
||||||
if (isset($metaData[$plugin]['parameters'])) {
|
|
||||||
$params = explode(';', $metaData[$plugin]['parameters']);
|
|
||||||
} else {
|
|
||||||
$params = array();
|
|
||||||
}
|
|
||||||
$metaData[$plugin]['parameters'] = array();
|
|
||||||
foreach ($params as $param) {
|
|
||||||
if (empty($param)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$metaData[$plugin]['parameters'][$param]['value'] = '';
|
|
||||||
// Optional parameter description in parameter.PARAM_NAME=
|
|
||||||
if (isset($metaData[$plugin]['parameter.'. $param])) {
|
|
||||||
$metaData[$plugin]['parameters'][$param]['desc'] = $metaData[$plugin]['parameter.'. $param];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $metaData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the list of encountered errors.
|
|
||||||
*
|
|
||||||
* @return array List of errors (empty array if none exists).
|
|
||||||
*/
|
|
||||||
public function getErrors()
|
|
||||||
{
|
|
||||||
return $this->errors;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class PluginFileNotFoundException
|
|
||||||
*
|
|
||||||
* Raise when plugin files can't be found.
|
|
||||||
*/
|
|
||||||
class PluginFileNotFoundException extends Exception
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Construct exception with plugin name.
|
|
||||||
* Generate message.
|
|
||||||
*
|
|
||||||
* @param string $pluginName name of the plugin not found
|
|
||||||
*/
|
|
||||||
public function __construct($pluginName)
|
|
||||||
{
|
|
||||||
$this->message = 'Plugin "'. $pluginName .'" files not found.';
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,141 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class Router
|
|
||||||
*
|
|
||||||
* (only displayable pages here)
|
|
||||||
*/
|
|
||||||
class Router
|
|
||||||
{
|
|
||||||
public static $PAGE_LOGIN = 'login';
|
|
||||||
|
|
||||||
public static $PAGE_PICWALL = 'picwall';
|
|
||||||
|
|
||||||
public static $PAGE_TAGCLOUD = 'tagcloud';
|
|
||||||
|
|
||||||
public static $PAGE_DAILY = 'daily';
|
|
||||||
|
|
||||||
public static $PAGE_FEED_ATOM = 'atom';
|
|
||||||
|
|
||||||
public static $PAGE_FEED_RSS = 'rss';
|
|
||||||
|
|
||||||
public static $PAGE_TOOLS = 'tools';
|
|
||||||
|
|
||||||
public static $PAGE_CHANGEPASSWORD = 'changepasswd';
|
|
||||||
|
|
||||||
public static $PAGE_CONFIGURE = 'configure';
|
|
||||||
|
|
||||||
public static $PAGE_CHANGETAG = 'changetag';
|
|
||||||
|
|
||||||
public static $PAGE_ADDLINK = 'addlink';
|
|
||||||
|
|
||||||
public static $PAGE_EDITLINK = 'edit_link';
|
|
||||||
|
|
||||||
public static $PAGE_EXPORT = 'export';
|
|
||||||
|
|
||||||
public static $PAGE_IMPORT = 'import';
|
|
||||||
|
|
||||||
public static $PAGE_OPENSEARCH = 'opensearch';
|
|
||||||
|
|
||||||
public static $PAGE_LINKLIST = 'linklist';
|
|
||||||
|
|
||||||
public static $PAGE_PLUGINSADMIN = 'pluginadmin';
|
|
||||||
|
|
||||||
public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reproducing renderPage() if hell, to avoid regression.
|
|
||||||
*
|
|
||||||
* This highlights how bad this needs to be rewrite,
|
|
||||||
* but let's focus on plugins for now.
|
|
||||||
*
|
|
||||||
* @param string $query $_SERVER['QUERY_STRING'].
|
|
||||||
* @param array $get $_SERVER['GET'].
|
|
||||||
* @param bool $loggedIn true if authenticated user.
|
|
||||||
*
|
|
||||||
* @return string page found.
|
|
||||||
*/
|
|
||||||
public static function findPage($query, $get, $loggedIn)
|
|
||||||
{
|
|
||||||
$loggedIn = ($loggedIn === true) ? true : false;
|
|
||||||
|
|
||||||
if (empty($query) && !isset($get['edit_link']) && !isset($get['post'])) {
|
|
||||||
return self::$PAGE_LINKLIST;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startsWith($query, 'do='. self::$PAGE_LOGIN) && $loggedIn === false) {
|
|
||||||
return self::$PAGE_LOGIN;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startsWith($query, 'do='. self::$PAGE_PICWALL)) {
|
|
||||||
return self::$PAGE_PICWALL;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startsWith($query, 'do='. self::$PAGE_TAGCLOUD)) {
|
|
||||||
return self::$PAGE_TAGCLOUD;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startsWith($query, 'do='. self::$PAGE_OPENSEARCH)) {
|
|
||||||
return self::$PAGE_OPENSEARCH;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startsWith($query, 'do='. self::$PAGE_DAILY)) {
|
|
||||||
return self::$PAGE_DAILY;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startsWith($query, 'do='. self::$PAGE_FEED_ATOM)) {
|
|
||||||
return self::$PAGE_FEED_ATOM;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startsWith($query, 'do='. self::$PAGE_FEED_RSS)) {
|
|
||||||
return self::$PAGE_FEED_RSS;
|
|
||||||
}
|
|
||||||
|
|
||||||
// At this point, only loggedin pages.
|
|
||||||
if (!$loggedIn) {
|
|
||||||
return self::$PAGE_LINKLIST;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startsWith($query, 'do='. self::$PAGE_TOOLS)) {
|
|
||||||
return self::$PAGE_TOOLS;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startsWith($query, 'do='. self::$PAGE_CHANGEPASSWORD)) {
|
|
||||||
return self::$PAGE_CHANGEPASSWORD;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startsWith($query, 'do='. self::$PAGE_CONFIGURE)) {
|
|
||||||
return self::$PAGE_CONFIGURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startsWith($query, 'do='. self::$PAGE_CHANGETAG)) {
|
|
||||||
return self::$PAGE_CHANGETAG;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startsWith($query, 'do='. self::$PAGE_ADDLINK)) {
|
|
||||||
return self::$PAGE_ADDLINK;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($get['edit_link']) || isset($get['post'])) {
|
|
||||||
return self::$PAGE_EDITLINK;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startsWith($query, 'do='. self::$PAGE_EXPORT)) {
|
|
||||||
return self::$PAGE_EXPORT;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startsWith($query, 'do='. self::$PAGE_IMPORT)) {
|
|
||||||
return self::$PAGE_IMPORT;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startsWith($query, 'do='. self::$PAGE_PLUGINSADMIN)) {
|
|
||||||
return self::$PAGE_PLUGINSADMIN;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startsWith($query, 'do='. self::$PAGE_SAVE_PLUGINSADMIN)) {
|
|
||||||
return self::$PAGE_SAVE_PLUGINSADMIN;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::$PAGE_LINKLIST;
|
|
||||||
}
|
|
||||||
}
|
|
131
application/Thumbnailer.php
Normal file
131
application/Thumbnailer.php
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli;
|
||||||
|
|
||||||
|
use Shaarli\Config\ConfigManager;
|
||||||
|
use WebThumbnailer\Application\ConfigManager as WTConfigManager;
|
||||||
|
use WebThumbnailer\WebThumbnailer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class Thumbnailer
|
||||||
|
*
|
||||||
|
* Utility class used to retrieve thumbnails using web-thumbnailer dependency.
|
||||||
|
*/
|
||||||
|
class Thumbnailer
|
||||||
|
{
|
||||||
|
protected const COMMON_MEDIA_DOMAINS = [
|
||||||
|
'imgur.com',
|
||||||
|
'flickr.com',
|
||||||
|
'youtube.com',
|
||||||
|
'wikimedia.org',
|
||||||
|
'redd.it',
|
||||||
|
'gfycat.com',
|
||||||
|
'media.giphy.com',
|
||||||
|
'twitter.com',
|
||||||
|
'twimg.com',
|
||||||
|
'instagram.com',
|
||||||
|
'pinterest.com',
|
||||||
|
'pinterest.fr',
|
||||||
|
'soundcloud.com',
|
||||||
|
'tumblr.com',
|
||||||
|
'deviantart.com',
|
||||||
|
];
|
||||||
|
|
||||||
|
public const MODE_ALL = 'all';
|
||||||
|
public const MODE_COMMON = 'common';
|
||||||
|
public const MODE_NONE = 'none';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var WebThumbnailer instance.
|
||||||
|
*/
|
||||||
|
protected $wt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ConfigManager instance.
|
||||||
|
*/
|
||||||
|
protected $conf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thumbnailer constructor.
|
||||||
|
*
|
||||||
|
* @param ConfigManager $conf instance.
|
||||||
|
*/
|
||||||
|
public function __construct($conf)
|
||||||
|
{
|
||||||
|
$this->conf = $conf;
|
||||||
|
|
||||||
|
if (! $this->checkRequirements()) {
|
||||||
|
$this->conf->set('thumbnails.mode', Thumbnailer::MODE_NONE);
|
||||||
|
$this->conf->write(true);
|
||||||
|
// TODO: create a proper error handling system able to catch exceptions...
|
||||||
|
die(t(
|
||||||
|
'php-gd extension must be loaded to use thumbnails. '
|
||||||
|
. 'Thumbnails are now disabled. Please reload the page.'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->wt = new WebThumbnailer();
|
||||||
|
WTConfigManager::addFile('inc/web-thumbnailer.json');
|
||||||
|
$this->wt->maxWidth($this->conf->get('thumbnails.width'))
|
||||||
|
->maxHeight($this->conf->get('thumbnails.height'))
|
||||||
|
->crop(true)
|
||||||
|
->debug($this->conf->get('dev.debug', false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve a thumbnail for given URL
|
||||||
|
*
|
||||||
|
* @param string $url where to look for a thumbnail.
|
||||||
|
*
|
||||||
|
* @return bool|string The thumbnail relative cache file path, or false if none has been found.
|
||||||
|
*/
|
||||||
|
public function get($url)
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
$this->conf->get('thumbnails.mode') === self::MODE_COMMON
|
||||||
|
&& ! $this->isCommonMediaOrImage($url)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->wt->thumbnail($url);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Exceptions are only thrown in debug mode.
|
||||||
|
error_log(get_class($e) . ': ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We check weather the given URL is from a common media domain,
|
||||||
|
* or if the file extension is an image.
|
||||||
|
*
|
||||||
|
* @param string $url to check
|
||||||
|
*
|
||||||
|
* @return bool true if it's an image or from a common media domain, false otherwise.
|
||||||
|
*/
|
||||||
|
public function isCommonMediaOrImage($url)
|
||||||
|
{
|
||||||
|
foreach (self::COMMON_MEDIA_DOMAINS as $domain) {
|
||||||
|
if (strpos($url, $domain) !== false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endsWith($url, '.jpg') || endsWith($url, '.png') || endsWith($url, '.jpeg')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make sure that requirements are match to use thumbnails:
|
||||||
|
* - php-gd is loaded
|
||||||
|
*/
|
||||||
|
protected function checkRequirements()
|
||||||
|
{
|
||||||
|
return extension_loaded('gd');
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,23 +1,43 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the timezone selection form and JavaScript.
|
* Generates a list of available timezone continents and cities.
|
||||||
*
|
*
|
||||||
* Note: 'UTC/UTC' is mapped to 'UTC' to form a valid option
|
* Two distinct array based on available timezones
|
||||||
|
* and the one selected in the settings:
|
||||||
|
* - (0) continents:
|
||||||
|
* + list of available continents
|
||||||
|
* + special key 'selected' containing the value of the selected timezone's continent
|
||||||
|
* - (1) cities:
|
||||||
|
* + list of available cities associated with their continent
|
||||||
|
* + special key 'selected' containing the value of the selected timezone's city (without the continent)
|
||||||
*
|
*
|
||||||
* Example: preselect Europe/Paris
|
* Example:
|
||||||
* list($htmlform, $js) = generateTimeZoneForm('Europe/Paris');
|
* [
|
||||||
|
* [
|
||||||
|
* 'America',
|
||||||
|
* 'Europe',
|
||||||
|
* 'selected' => 'Europe',
|
||||||
|
* ],
|
||||||
|
* [
|
||||||
|
* ['continent' => 'America', 'city' => 'Toronto'],
|
||||||
|
* ['continent' => 'Europe', 'city' => 'Paris'],
|
||||||
|
* 'selected' => 'Paris',
|
||||||
|
* ],
|
||||||
|
* ];
|
||||||
*
|
*
|
||||||
|
* Notes:
|
||||||
|
* - 'UTC/UTC' is mapped to 'UTC' to form a valid option
|
||||||
|
* - a few timezone cities includes the country/state, such as Argentina/Buenos_Aires
|
||||||
|
* - these arrays are designed to build timezone selects in template files with any HTML structure
|
||||||
|
*
|
||||||
|
* @param array $installedTimeZones List of installed timezones as string
|
||||||
* @param string $preselectedTimezone preselected timezone (optional)
|
* @param string $preselectedTimezone preselected timezone (optional)
|
||||||
*
|
*
|
||||||
* @return array containing the generated HTML form and Javascript code
|
* @return array[] continents and cities
|
||||||
**/
|
**/
|
||||||
function generateTimeZoneForm($preselectedTimezone='')
|
function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
|
||||||
{
|
{
|
||||||
// Select the server timezone
|
|
||||||
if ($preselectedTimezone == '') {
|
|
||||||
$preselectedTimezone = date_default_timezone_get();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($preselectedTimezone == 'UTC') {
|
if ($preselectedTimezone == 'UTC') {
|
||||||
$pcity = $pcontinent = 'UTC';
|
$pcity = $pcontinent = 'UTC';
|
||||||
} else {
|
} else {
|
||||||
|
@ -27,62 +47,30 @@ function generateTimeZoneForm($preselectedTimezone='')
|
||||||
$pcity = substr($preselectedTimezone, $spos + 1);
|
$pcity = substr($preselectedTimezone, $spos + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The list is in the form 'Europe/Paris', 'America/Argentina/Buenos_Aires'
|
$continents = [];
|
||||||
// We split the list in continents/cities.
|
$cities = [];
|
||||||
$continents = array();
|
foreach ($installedTimeZones as $tz) {
|
||||||
$cities = array();
|
|
||||||
|
|
||||||
// TODO: use a template to generate the HTML/Javascript form
|
|
||||||
|
|
||||||
foreach (timezone_identifiers_list() as $tz) {
|
|
||||||
if ($tz == 'UTC') {
|
if ($tz == 'UTC') {
|
||||||
$tz = 'UTC/UTC';
|
$tz = 'UTC/UTC';
|
||||||
}
|
}
|
||||||
$spos = strpos($tz, '/');
|
$spos = strpos($tz, '/');
|
||||||
|
|
||||||
if ($spos !== false) {
|
// Ignore invalid timezones
|
||||||
|
if ($spos === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$continent = substr($tz, 0, $spos);
|
$continent = substr($tz, 0, $spos);
|
||||||
$city = substr($tz, $spos + 1);
|
$city = substr($tz, $spos + 1);
|
||||||
$continents[$continent] = 1;
|
$cities[] = ['continent' => $continent, 'city' => $city];
|
||||||
|
$continents[$continent] = true;
|
||||||
if (!isset($cities[$continent])) {
|
|
||||||
$cities[$continent] = '';
|
|
||||||
}
|
|
||||||
$cities[$continent] .= '<option value="'.$city.'"';
|
|
||||||
if ($pcity == $city) {
|
|
||||||
$cities[$continent] .= ' selected="selected"';
|
|
||||||
}
|
|
||||||
$cities[$continent] .= '>'.$city.'</option>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$continentsHtml = '';
|
|
||||||
$continents = array_keys($continents);
|
$continents = array_keys($continents);
|
||||||
|
$continents['selected'] = $pcontinent;
|
||||||
|
$cities['selected'] = $pcity;
|
||||||
|
|
||||||
foreach ($continents as $continent) {
|
return [$continents, $cities];
|
||||||
$continentsHtml .= '<option value="'.$continent.'"';
|
|
||||||
if ($pcontinent == $continent) {
|
|
||||||
$continentsHtml .= ' selected="selected"';
|
|
||||||
}
|
|
||||||
$continentsHtml .= '>'.$continent.'</option>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timezone selection form
|
|
||||||
$timezoneForm = 'Continent:';
|
|
||||||
$timezoneForm .= '<select name="continent" id="continent" onChange="onChangecontinent();">';
|
|
||||||
$timezoneForm .= $continentsHtml.'</select>';
|
|
||||||
$timezoneForm .= ' City:';
|
|
||||||
$timezoneForm .= '<select name="city" id="city">'.$cities[$pcontinent].'</select><br />';
|
|
||||||
|
|
||||||
// Javascript handler - updates the city list when the user selects a continent
|
|
||||||
$timezoneJs = '<script>';
|
|
||||||
$timezoneJs .= 'function onChangecontinent() {';
|
|
||||||
$timezoneJs .= 'document.getElementById("city").innerHTML =';
|
|
||||||
$timezoneJs .= ' citiescontinent[document.getElementById("continent").value]; }';
|
|
||||||
$timezoneJs .= 'var citiescontinent = '.json_encode($cities).';';
|
|
||||||
$timezoneJs .= '</script>';
|
|
||||||
|
|
||||||
return array($timezoneForm, $timezoneJs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,311 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class Updater.
|
|
||||||
* Used to update stuff when a new Shaarli's version is reached.
|
|
||||||
* Update methods are ran only once, and the stored in a JSON file.
|
|
||||||
*/
|
|
||||||
class Updater
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array Updates which are already done.
|
|
||||||
*/
|
|
||||||
protected $doneUpdates;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var LinkDB instance.
|
|
||||||
*/
|
|
||||||
protected $linkDB;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var ConfigManager $conf Configuration Manager instance.
|
|
||||||
*/
|
|
||||||
protected $conf;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var bool True if the user is logged in, false otherwise.
|
|
||||||
*/
|
|
||||||
protected $isLoggedIn;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var ReflectionMethod[] List of current class methods.
|
|
||||||
*/
|
|
||||||
protected $methods;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Object constructor.
|
|
||||||
*
|
|
||||||
* @param array $doneUpdates Updates which are already done.
|
|
||||||
* @param LinkDB $linkDB LinkDB instance.
|
|
||||||
* @param ConfigManager $conf Configuration Manager instance.
|
|
||||||
* @param boolean $isLoggedIn True if the user is logged in.
|
|
||||||
*/
|
|
||||||
public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn)
|
|
||||||
{
|
|
||||||
$this->doneUpdates = $doneUpdates;
|
|
||||||
$this->linkDB = $linkDB;
|
|
||||||
$this->conf = $conf;
|
|
||||||
$this->isLoggedIn = $isLoggedIn;
|
|
||||||
|
|
||||||
// Retrieve all update methods.
|
|
||||||
$class = new ReflectionClass($this);
|
|
||||||
$this->methods = $class->getMethods();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run all new updates.
|
|
||||||
* Update methods have to start with 'updateMethod' and return true (on success).
|
|
||||||
*
|
|
||||||
* @return array An array containing ran updates.
|
|
||||||
*
|
|
||||||
* @throws UpdaterException If something went wrong.
|
|
||||||
*/
|
|
||||||
public function update()
|
|
||||||
{
|
|
||||||
$updatesRan = array();
|
|
||||||
|
|
||||||
// If the user isn't logged in, exit without updating.
|
|
||||||
if ($this->isLoggedIn !== true) {
|
|
||||||
return $updatesRan;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->methods == null) {
|
|
||||||
throw new UpdaterException('Couldn\'t retrieve Updater class methods.');
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->methods as $method) {
|
|
||||||
// Not an update method or already done, pass.
|
|
||||||
if (! startsWith($method->getName(), 'updateMethod')
|
|
||||||
|| in_array($method->getName(), $this->doneUpdates)
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$method->setAccessible(true);
|
|
||||||
$res = $method->invoke($this);
|
|
||||||
// Update method must return true to be considered processed.
|
|
||||||
if ($res === true) {
|
|
||||||
$updatesRan[] = $method->getName();
|
|
||||||
}
|
|
||||||
} catch (Exception $e) {
|
|
||||||
throw new UpdaterException($method, $e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->doneUpdates = array_merge($this->doneUpdates, $updatesRan);
|
|
||||||
|
|
||||||
return $updatesRan;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array Updates methods already processed.
|
|
||||||
*/
|
|
||||||
public function getDoneUpdates()
|
|
||||||
{
|
|
||||||
return $this->doneUpdates;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move deprecated options.php to config.php.
|
|
||||||
*
|
|
||||||
* Milestone 0.9 (old versioning) - shaarli/Shaarli#41:
|
|
||||||
* options.php is not supported anymore.
|
|
||||||
*/
|
|
||||||
public function updateMethodMergeDeprecatedConfigFile()
|
|
||||||
{
|
|
||||||
if (is_file($this->conf->get('resource.data_dir') . '/options.php')) {
|
|
||||||
include $this->conf->get('resource.data_dir') . '/options.php';
|
|
||||||
|
|
||||||
// Load GLOBALS into config
|
|
||||||
$allowedKeys = array_merge(ConfigPhp::$ROOT_KEYS);
|
|
||||||
$allowedKeys[] = 'config';
|
|
||||||
foreach ($GLOBALS as $key => $value) {
|
|
||||||
if (in_array($key, $allowedKeys)) {
|
|
||||||
$this->conf->set($key, $value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->conf->write($this->isLoggedIn);
|
|
||||||
unlink($this->conf->get('resource.data_dir').'/options.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rename tags starting with a '-' to work with tag exclusion search.
|
|
||||||
*/
|
|
||||||
public function updateMethodRenameDashTags()
|
|
||||||
{
|
|
||||||
$linklist = $this->linkDB->filterSearch();
|
|
||||||
foreach ($linklist as $link) {
|
|
||||||
$link['tags'] = preg_replace('/(^| )\-/', '$1', $link['tags']);
|
|
||||||
$link['tags'] = implode(' ', array_unique(LinkFilter::tagsStrToArray($link['tags'], true)));
|
|
||||||
$this->linkDB[$link['linkdate']] = $link;
|
|
||||||
}
|
|
||||||
$this->linkDB->save($this->conf->get('resource.page_cache'));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move old configuration in PHP to the new config system in JSON format.
|
|
||||||
*
|
|
||||||
* Will rename 'config.php' into 'config.save.php' and create 'config.json.php'.
|
|
||||||
* It will also convert legacy setting keys to the new ones.
|
|
||||||
*/
|
|
||||||
public function updateMethodConfigToJson()
|
|
||||||
{
|
|
||||||
// JSON config already exists, nothing to do.
|
|
||||||
if ($this->conf->getConfigIO() instanceof ConfigJson) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$configPhp = new ConfigPhp();
|
|
||||||
$configJson = new ConfigJson();
|
|
||||||
$oldConfig = $configPhp->read($this->conf->getConfigFile() . '.php');
|
|
||||||
rename($this->conf->getConfigFileExt(), $this->conf->getConfigFile() . '.save.php');
|
|
||||||
$this->conf->setConfigIO($configJson);
|
|
||||||
$this->conf->reload();
|
|
||||||
|
|
||||||
$legacyMap = array_flip(ConfigPhp::$LEGACY_KEYS_MAPPING);
|
|
||||||
foreach (ConfigPhp::$ROOT_KEYS as $key) {
|
|
||||||
$this->conf->set($legacyMap[$key], $oldConfig[$key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set sub config keys (config and plugins)
|
|
||||||
$subConfig = array('config', 'plugins');
|
|
||||||
foreach ($subConfig as $sub) {
|
|
||||||
foreach ($oldConfig[$sub] as $key => $value) {
|
|
||||||
if (isset($legacyMap[$sub .'.'. $key])) {
|
|
||||||
$configKey = $legacyMap[$sub .'.'. $key];
|
|
||||||
} else {
|
|
||||||
$configKey = $sub .'.'. $key;
|
|
||||||
}
|
|
||||||
$this->conf->set($configKey, $value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try{
|
|
||||||
$this->conf->write($this->isLoggedIn);
|
|
||||||
return true;
|
|
||||||
} catch (IOException $e) {
|
|
||||||
error_log($e->getMessage());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape settings which have been manually escaped in every request in previous versions:
|
|
||||||
* - general.title
|
|
||||||
* - general.header_link
|
|
||||||
* - redirector.url
|
|
||||||
*
|
|
||||||
* @return bool true if the update is successful, false otherwise.
|
|
||||||
*/
|
|
||||||
public function updateMethodEscapeUnescapedConfig()
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$this->conf->set('general.title', escape($this->conf->get('general.title')));
|
|
||||||
$this->conf->set('general.header_link', escape($this->conf->get('general.header_link')));
|
|
||||||
$this->conf->set('redirector.url', escape($this->conf->get('redirector.url')));
|
|
||||||
$this->conf->write($this->isLoggedIn);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
error_log($e->getMessage());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class UpdaterException.
|
|
||||||
*/
|
|
||||||
class UpdaterException extends Exception
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var string Method where the error occurred.
|
|
||||||
*/
|
|
||||||
protected $method;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var Exception The parent exception.
|
|
||||||
*/
|
|
||||||
protected $previous;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constructor.
|
|
||||||
*
|
|
||||||
* @param string $message Force the error message if set.
|
|
||||||
* @param string $method Method where the error occurred.
|
|
||||||
* @param Exception|bool $previous Parent exception.
|
|
||||||
*/
|
|
||||||
public function __construct($message = '', $method = '', $previous = false)
|
|
||||||
{
|
|
||||||
$this->method = $method;
|
|
||||||
$this->previous = $previous;
|
|
||||||
$this->message = $this->buildMessage($message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the exception error message.
|
|
||||||
*
|
|
||||||
* @param string $message Optional given error message.
|
|
||||||
*
|
|
||||||
* @return string The built error message.
|
|
||||||
*/
|
|
||||||
private function buildMessage($message)
|
|
||||||
{
|
|
||||||
$out = '';
|
|
||||||
if (! empty($message)) {
|
|
||||||
$out .= $message . PHP_EOL;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! empty($this->method)) {
|
|
||||||
$out .= 'An error occurred while running the update '. $this->method . PHP_EOL;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! empty($this->previous)) {
|
|
||||||
$out .= ' '. $this->previous->getMessage();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the updates file, and return already done updates.
|
|
||||||
*
|
|
||||||
* @param string $updatesFilepath Updates file path.
|
|
||||||
*
|
|
||||||
* @return array Already done update methods.
|
|
||||||
*/
|
|
||||||
function read_updates_file($updatesFilepath)
|
|
||||||
{
|
|
||||||
if (! empty($updatesFilepath) && is_file($updatesFilepath)) {
|
|
||||||
$content = file_get_contents($updatesFilepath);
|
|
||||||
if (! empty($content)) {
|
|
||||||
return explode(';', $content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return array();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write updates file.
|
|
||||||
*
|
|
||||||
* @param string $updatesFilepath Updates file path.
|
|
||||||
* @param array $updates Updates array to write.
|
|
||||||
*
|
|
||||||
* @throws Exception Couldn't write version number.
|
|
||||||
*/
|
|
||||||
function write_updates_file($updatesFilepath, $updates)
|
|
||||||
{
|
|
||||||
if (empty($updatesFilepath)) {
|
|
||||||
throw new Exception('Updates file path is not set, can\'t write updates.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$res = file_put_contents($updatesFilepath, implode(';', $updates));
|
|
||||||
if ($res === false) {
|
|
||||||
throw new Exception('Unable to write updates in '. $updatesFilepath . '.');
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,24 +1,27 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shaarli utilities
|
* Shaarli utilities
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logs a message to a text file
|
* Format log using provided data.
|
||||||
*
|
*
|
||||||
* The log format is compatible with fail2ban.
|
|
||||||
*
|
|
||||||
* @param string $logFile where to write the logs
|
|
||||||
* @param string $clientIp the client's remote IPv4/IPv6 address
|
|
||||||
* @param string $message the message to log
|
* @param string $message the message to log
|
||||||
|
* @param string|null $clientIp the client's remote IPv4/IPv6 address
|
||||||
|
*
|
||||||
|
* @return string Formatted message to log
|
||||||
*/
|
*/
|
||||||
function logm($logFile, $clientIp, $message)
|
function format_log(string $message, string $clientIp = null): string
|
||||||
{
|
{
|
||||||
file_put_contents(
|
$out = $message;
|
||||||
$logFile,
|
|
||||||
date('Y/m/d H:i:s').' - '.$clientIp.' - '.strval($message).PHP_EOL,
|
if (!empty($clientIp)) {
|
||||||
FILE_APPEND
|
// Note: we keep the first dash to avoid breaking fail2ban configs
|
||||||
);
|
$out = '- ' . $clientIp . ' - ' . $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -31,7 +34,11 @@ function logm($logFile, $clientIp, $message)
|
||||||
* - are NOT cryptographically secure (they CAN be forged)
|
* - are NOT cryptographically secure (they CAN be forged)
|
||||||
*
|
*
|
||||||
* In Shaarli, they are used as a tinyurl-like link to individual entries,
|
* In Shaarli, they are used as a tinyurl-like link to individual entries,
|
||||||
* e.g. smallHash('20111006_131924') --> yZH23w
|
* built once with the combination of the date and item ID.
|
||||||
|
* e.g. smallHash('20111006_131924' . 142) --> eaWxtQ
|
||||||
|
*
|
||||||
|
* @warning before v0.8.1, smallhashes were built only with the date,
|
||||||
|
* and their value has been preserved.
|
||||||
*
|
*
|
||||||
* @param string $text Create a hash from this text.
|
* @param string $text Create a hash from this text.
|
||||||
*
|
*
|
||||||
|
@ -54,6 +61,7 @@ function smallHash($text)
|
||||||
*/
|
*/
|
||||||
function startsWith($haystack, $needle, $case = true)
|
function startsWith($haystack, $needle, $case = true)
|
||||||
{
|
{
|
||||||
|
$needle = $needle ?? '';
|
||||||
if ($case) {
|
if ($case) {
|
||||||
return (strcmp(substr($haystack, 0, strlen($needle)), $needle) === 0);
|
return (strcmp(substr($haystack, 0, strlen($needle)), $needle) === 0);
|
||||||
}
|
}
|
||||||
|
@ -83,14 +91,22 @@ function endsWith($haystack, $needle, $case = true)
|
||||||
*
|
*
|
||||||
* @param mixed $input Data to escape: a single string or an array of strings.
|
* @param mixed $input Data to escape: a single string or an array of strings.
|
||||||
*
|
*
|
||||||
* @return string escaped.
|
* @return string|array escaped.
|
||||||
*/
|
*/
|
||||||
function escape($input)
|
function escape($input)
|
||||||
{
|
{
|
||||||
|
if (null === $input) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_bool($input) || is_int($input) || is_float($input) || $input instanceof DateTimeInterface) {
|
||||||
|
return $input;
|
||||||
|
}
|
||||||
|
|
||||||
if (is_array($input)) {
|
if (is_array($input)) {
|
||||||
$out = array();
|
$out = [];
|
||||||
foreach ($input as $key => $value) {
|
foreach ($input as $key => $value) {
|
||||||
$out[$key] = escape($value);
|
$out[escape($key)] = escape($value);
|
||||||
}
|
}
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
@ -149,12 +165,12 @@ function checkDateFormat($format, $string)
|
||||||
*
|
*
|
||||||
* @return string $referer - final referer.
|
* @return string $referer - final referer.
|
||||||
*/
|
*/
|
||||||
function generateLocation($referer, $host, $loopTerms = array())
|
function generateLocation($referer, $host, $loopTerms = [])
|
||||||
{
|
{
|
||||||
$finalReferer = '?';
|
$finalReferer = './?';
|
||||||
|
|
||||||
// No referer if it contains any value in $loopCriteria.
|
// No referer if it contains any value in $loopCriteria.
|
||||||
foreach ($loopTerms as $value) {
|
foreach (array_filter($loopTerms) as $value) {
|
||||||
if (strpos($referer, $value) !== false) {
|
if (strpos($referer, $value) !== false) {
|
||||||
return $finalReferer;
|
return $finalReferer;
|
||||||
}
|
}
|
||||||
|
@ -165,7 +181,7 @@ function generateLocation($referer, $host, $loopTerms = array())
|
||||||
$host = substr($host, 0, $pos);
|
$host = substr($host, 0, $pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
$refererHost = parse_url($referer, PHP_URL_HOST);
|
$refererHost = parse_url($referer, PHP_URL_HOST) ?? '';
|
||||||
if (!empty($referer) && (strpos($refererHost, $host) !== false || startsWith('?', $refererHost))) {
|
if (!empty($referer) && (strpos($refererHost, $host) !== false || startsWith('?', $refererHost))) {
|
||||||
$finalReferer = $referer;
|
$finalReferer = $referer;
|
||||||
}
|
}
|
||||||
|
@ -173,36 +189,6 @@ function generateLocation($referer, $host, $loopTerms = array())
|
||||||
return $finalReferer;
|
return $finalReferer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate session ID to prevent Full Path Disclosure.
|
|
||||||
*
|
|
||||||
* See #298.
|
|
||||||
* The session ID's format depends on the hash algorithm set in PHP settings
|
|
||||||
*
|
|
||||||
* @param string $sessionId Session ID
|
|
||||||
*
|
|
||||||
* @return true if valid, false otherwise.
|
|
||||||
*
|
|
||||||
* @see http://php.net/manual/en/function.hash-algos.php
|
|
||||||
* @see http://php.net/manual/en/session.configuration.php
|
|
||||||
*/
|
|
||||||
function is_session_id_valid($sessionId)
|
|
||||||
{
|
|
||||||
if (empty($sessionId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$sessionId) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!preg_match('/^[a-zA-Z0-9,-]{2,128}$/', $sessionId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sniff browser language to set the locale automatically.
|
* Sniff browser language to set the locale automatically.
|
||||||
* Note that is may not work on your server if the corresponding locale is not installed.
|
* Note that is may not work on your server if the corresponding locale is not installed.
|
||||||
|
@ -212,28 +198,308 @@ function is_session_id_valid($sessionId)
|
||||||
function autoLocale($headerLocale)
|
function autoLocale($headerLocale)
|
||||||
{
|
{
|
||||||
// Default if browser does not send HTTP_ACCEPT_LANGUAGE
|
// Default if browser does not send HTTP_ACCEPT_LANGUAGE
|
||||||
$attempts = array('en_US');
|
$locales = ['en_US.UTF-8', 'en_US.utf8', 'en_US'];
|
||||||
if (isset($headerLocale)) {
|
if (! empty($headerLocale)) {
|
||||||
// (It's a bit crude, but it works very well. Preferred language is always presented first.)
|
if (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) {
|
||||||
if (preg_match('/([a-z]{2})-?([a-z]{2})?/i', $headerLocale, $matches)) {
|
$attempts = [];
|
||||||
$loc = $matches[1] . (!empty($matches[2]) ? '_' . strtoupper($matches[2]) : '');
|
foreach ($matches as $match) {
|
||||||
$attempts = array(
|
$first = [strtolower($match[1]), strtoupper($match[1])];
|
||||||
$loc.'.UTF-8', $loc, str_replace('_', '-', $loc).'.UTF-8', str_replace('_', '-', $loc),
|
$separators = ['_', '-'];
|
||||||
$loc . '_' . strtoupper($loc).'.UTF-8', $loc . '_' . strtoupper($loc),
|
$encodings = ['utf8', 'UTF-8'];
|
||||||
$loc . '_' . $loc.'.UTF-8', $loc . '_' . $loc, $loc . '-' . strtoupper($loc).'.UTF-8',
|
if (!empty($match[2])) {
|
||||||
$loc . '-' . strtoupper($loc), $loc . '-' . $loc.'.UTF-8', $loc . '-' . $loc
|
$second = [strtoupper($match[2]), strtolower($match[2])];
|
||||||
);
|
$items = [$first, $separators, $second, ['.'], $encodings];
|
||||||
|
} else {
|
||||||
|
$items = [$first, $separators, $first, ['.'], $encodings];
|
||||||
}
|
}
|
||||||
}
|
$attempts = array_merge($attempts, iterator_to_array(cartesian_product_generator($items)));
|
||||||
setlocale(LC_ALL, $attempts);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAllTheme()
|
if (! empty($attempts)) {
|
||||||
|
$locales = array_merge(array_map('implode', $attempts), $locales);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setlocale(LC_ALL, $locales);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Generator object representing the cartesian product from given $items.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* [['a'], ['b', 'c']]
|
||||||
|
* will generate:
|
||||||
|
* [
|
||||||
|
* ['a', 'b'],
|
||||||
|
* ['a', 'c'],
|
||||||
|
* ]
|
||||||
|
*
|
||||||
|
* @param array $items array of array of string
|
||||||
|
*
|
||||||
|
* @return Generator representing the cartesian product of given array.
|
||||||
|
*
|
||||||
|
* @see https://en.wikipedia.org/wiki/Cartesian_product
|
||||||
|
*/
|
||||||
|
function cartesian_product_generator($items)
|
||||||
{
|
{
|
||||||
$allTheme = glob('tpl/*', GLOB_ONLYDIR);
|
if (empty($items)) {
|
||||||
foreach ($allTheme as $value) {
|
yield [];
|
||||||
$themes[] = str_replace('tpl/', '', $value);
|
}
|
||||||
|
$subArray = array_pop($items);
|
||||||
|
if (empty($subArray)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (cartesian_product_generator($items) as $item) {
|
||||||
|
foreach ($subArray as $value) {
|
||||||
|
yield $item + [count($item) => $value];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $themes;
|
/**
|
||||||
|
* Generates a default API secret.
|
||||||
|
*
|
||||||
|
* Note that the random-ish methods used in this function are predictable,
|
||||||
|
* which makes them NOT suitable for crypto.
|
||||||
|
* BUT the random string is salted with the salt and hashed with the username.
|
||||||
|
* It makes the generated API secret secured enough for Shaarli.
|
||||||
|
*
|
||||||
|
* PHP 7 provides random_int(), designed for cryptography.
|
||||||
|
* More info: http://stackoverflow.com/questions/4356289/php-random-string-generator
|
||||||
|
|
||||||
|
* @param string $username Shaarli login username
|
||||||
|
* @param string $salt Shaarli password hash salt
|
||||||
|
*
|
||||||
|
* @return string|bool Generated API secret, 12 char length.
|
||||||
|
* Or false if invalid parameters are provided (which will make the API unusable).
|
||||||
|
*/
|
||||||
|
function generate_api_secret($username, $salt)
|
||||||
|
{
|
||||||
|
if (empty($username) || empty($salt)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str_shuffle(substr(hash_hmac('sha512', uniqid($salt), $username), 10, 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trim string, replace sequences of whitespaces by a single space.
|
||||||
|
* PHP equivalent to `normalize-space` XSLT function.
|
||||||
|
*
|
||||||
|
* @param string $string Input string.
|
||||||
|
*
|
||||||
|
* @return mixed Normalized string.
|
||||||
|
*/
|
||||||
|
function normalize_spaces($string)
|
||||||
|
{
|
||||||
|
return preg_replace('/\s{2,}/', ' ', trim($string ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the date according to the locale.
|
||||||
|
*
|
||||||
|
* Requires php-intl to display international datetimes,
|
||||||
|
* otherwise default format '%c' will be returned.
|
||||||
|
*
|
||||||
|
* @param DateTimeInterface $date to format.
|
||||||
|
* @param bool $time Displays time if true.
|
||||||
|
* @param bool $intl Use international format if true.
|
||||||
|
*
|
||||||
|
* @return bool|string Formatted date, or false if the input is invalid.
|
||||||
|
*/
|
||||||
|
function format_date($date, $time = true, $intl = true)
|
||||||
|
{
|
||||||
|
if (! $date instanceof DateTimeInterface) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $intl || ! class_exists('IntlDateFormatter')) {
|
||||||
|
$format = 'F j, Y';
|
||||||
|
if ($time) {
|
||||||
|
$format .= ' h:i:s A \G\M\TP';
|
||||||
|
}
|
||||||
|
return $date->format($format);
|
||||||
|
}
|
||||||
|
$formatter = new IntlDateFormatter(
|
||||||
|
setlocale(LC_TIME, 0),
|
||||||
|
IntlDateFormatter::LONG,
|
||||||
|
$time ? IntlDateFormatter::LONG : IntlDateFormatter::NONE
|
||||||
|
);
|
||||||
|
$formatter->setTimeZone($date->getTimezone());
|
||||||
|
|
||||||
|
return $formatter->format($date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format the date month according to the locale.
|
||||||
|
*
|
||||||
|
* @param DateTimeInterface $date to format.
|
||||||
|
*
|
||||||
|
* @return bool|string Formatted date, or false if the input is invalid.
|
||||||
|
*/
|
||||||
|
function format_month(DateTimeInterface $date)
|
||||||
|
{
|
||||||
|
if (! $date instanceof DateTimeInterface) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strftime('%B', $date->getTimestamp());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the input is an integer, no matter its real type.
|
||||||
|
*
|
||||||
|
* PHP is a bit messy regarding this:
|
||||||
|
* - is_int returns false if the input is a string
|
||||||
|
* - ctype_digit returns false if the input is an integer or negative
|
||||||
|
*
|
||||||
|
* @param mixed $input value
|
||||||
|
*
|
||||||
|
* @return bool true if the input is an integer, false otherwise
|
||||||
|
*/
|
||||||
|
function is_integer_mixed($input)
|
||||||
|
{
|
||||||
|
if (is_array($input) || is_bool($input) || is_object($input)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$input = strval($input);
|
||||||
|
return ctype_digit($input) || (startsWith($input, '-') && ctype_digit(substr($input, 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert post_max_size/upload_max_filesize (e.g. '16M') parameters to bytes.
|
||||||
|
*
|
||||||
|
* @param string $val Size expressed in string.
|
||||||
|
*
|
||||||
|
* @return int Size expressed in bytes.
|
||||||
|
*/
|
||||||
|
function return_bytes($val)
|
||||||
|
{
|
||||||
|
if (is_integer_mixed($val) || $val === '0' || empty($val)) {
|
||||||
|
return $val;
|
||||||
|
}
|
||||||
|
$val = trim($val);
|
||||||
|
$last = strtolower($val[strlen($val) - 1]);
|
||||||
|
$val = intval(substr($val, 0, -1));
|
||||||
|
switch ($last) {
|
||||||
|
case 'g':
|
||||||
|
$val *= 1024;
|
||||||
|
// do no break in order 1024^2 for each unit
|
||||||
|
case 'm':
|
||||||
|
$val *= 1024;
|
||||||
|
// do no break in order 1024^2 for each unit
|
||||||
|
case 'k':
|
||||||
|
$val *= 1024;
|
||||||
|
}
|
||||||
|
return $val;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a human readable size from bytes.
|
||||||
|
*
|
||||||
|
* @param int $bytes value
|
||||||
|
*
|
||||||
|
* @return string Human readable size
|
||||||
|
*/
|
||||||
|
function human_bytes($bytes)
|
||||||
|
{
|
||||||
|
if ($bytes === '') {
|
||||||
|
return t('Setting not set');
|
||||||
|
}
|
||||||
|
if (! is_integer_mixed($bytes)) {
|
||||||
|
return $bytes;
|
||||||
|
}
|
||||||
|
$bytes = intval($bytes);
|
||||||
|
if ($bytes === 0) {
|
||||||
|
return t('Unlimited');
|
||||||
|
}
|
||||||
|
|
||||||
|
$units = [t('B'), t('kiB'), t('MiB'), t('GiB')];
|
||||||
|
for ($i = 0; $i < count($units) && $bytes >= 1024; ++$i) {
|
||||||
|
$bytes /= 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($bytes) . $units[$i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to determine max file size for uploads (POST).
|
||||||
|
* Returns an integer (in bytes) or formatted depending on $format.
|
||||||
|
*
|
||||||
|
* @param mixed $limitPost post_max_size PHP setting
|
||||||
|
* @param mixed $limitUpload upload_max_filesize PHP setting
|
||||||
|
* @param bool $format Format max upload size to human readable size
|
||||||
|
*
|
||||||
|
* @return int|string max upload file size
|
||||||
|
*/
|
||||||
|
function get_max_upload_size($limitPost, $limitUpload, $format = true)
|
||||||
|
{
|
||||||
|
$size1 = return_bytes($limitPost);
|
||||||
|
$size2 = return_bytes($limitUpload);
|
||||||
|
// Return the smaller of two:
|
||||||
|
$maxsize = min($size1, $size2);
|
||||||
|
return $format ? human_bytes($maxsize) : $maxsize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort the given array alphabetically using php-intl if available.
|
||||||
|
* Case sensitive.
|
||||||
|
*
|
||||||
|
* Note: doesn't support multidimensional arrays
|
||||||
|
*
|
||||||
|
* @param array $data Input array, passed by reference
|
||||||
|
* @param bool $reverse Reverse sort if set to true
|
||||||
|
* @param bool $byKeys Sort the array by keys if set to true, by value otherwise.
|
||||||
|
*/
|
||||||
|
function alphabetical_sort(&$data, $reverse = false, $byKeys = false)
|
||||||
|
{
|
||||||
|
$callback = function ($a, $b) use ($reverse) {
|
||||||
|
// Collator is part of PHP intl.
|
||||||
|
if (class_exists('Collator')) {
|
||||||
|
$collator = new Collator(setlocale(LC_COLLATE, 0));
|
||||||
|
if (!intl_is_failure(intl_get_error_code())) {
|
||||||
|
return $collator->compare($a, $b) * ($reverse ? -1 : 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strcasecmp($a, $b) * ($reverse ? -1 : 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($byKeys) {
|
||||||
|
uksort($data, $callback);
|
||||||
|
} else {
|
||||||
|
usort($data, $callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper function for translation which match the API
|
||||||
|
* of gettext()/_() and ngettext().
|
||||||
|
*
|
||||||
|
* @param string $text Text to translate.
|
||||||
|
* @param string $nText The plural message ID.
|
||||||
|
* @param int $nb The number of items for plural forms.
|
||||||
|
* @param string $domain The domain where the translation is stored (default: shaarli).
|
||||||
|
* @param array $variables Associative array of variables to replace in translated text.
|
||||||
|
* @param bool $fixCase Apply `ucfirst` on the translated string, might be useful for strings with variables.
|
||||||
|
*
|
||||||
|
* @return string Text translated.
|
||||||
|
*/
|
||||||
|
function t($text, $nText = '', $nb = 1, $domain = 'shaarli', $variables = [], $fixCase = false)
|
||||||
|
{
|
||||||
|
$postFunction = $fixCase ? 'ucfirst' : function ($input) {
|
||||||
|
return $input;
|
||||||
|
};
|
||||||
|
|
||||||
|
return $postFunction(dn__($domain, $text, $nText, $nb, $variables));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an exception into a printable stack trace string.
|
||||||
|
*/
|
||||||
|
function exception2text(Throwable $e): string
|
||||||
|
{
|
||||||
|
return $e->getMessage() . PHP_EOL . $e->getFile() . $e->getLine() . PHP_EOL . $e->getTraceAsString();
|
||||||
}
|
}
|
||||||
|
|
155
application/api/ApiMiddleware.php
Normal file
155
application/api/ApiMiddleware.php
Normal file
|
@ -0,0 +1,155 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api;
|
||||||
|
|
||||||
|
use malkusch\lock\mutex\FlockMutex;
|
||||||
|
use Shaarli\Api\Exceptions\ApiAuthorizationException;
|
||||||
|
use Shaarli\Api\Exceptions\ApiException;
|
||||||
|
use Shaarli\Bookmark\BookmarkFileService;
|
||||||
|
use Shaarli\Config\ConfigManager;
|
||||||
|
use Slim\Container;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ApiMiddleware
|
||||||
|
*
|
||||||
|
* This will be called before accessing any API Controller.
|
||||||
|
* Its role is to make sure that the API is enabled, configured, and to validate the JWT token.
|
||||||
|
*
|
||||||
|
* If the request is validated, the controller is called, otherwise a JSON error response is returned.
|
||||||
|
*
|
||||||
|
* @package Api
|
||||||
|
*/
|
||||||
|
class ApiMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var int JWT token validity in seconds (9 min).
|
||||||
|
*/
|
||||||
|
public static $TOKEN_DURATION = 540;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Container: contains conf, plugins, etc.
|
||||||
|
*/
|
||||||
|
protected $container;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ConfigManager instance.
|
||||||
|
*/
|
||||||
|
protected $conf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ApiMiddleware constructor.
|
||||||
|
*
|
||||||
|
* @param Container $container instance.
|
||||||
|
*/
|
||||||
|
public function __construct($container)
|
||||||
|
{
|
||||||
|
$this->container = $container;
|
||||||
|
$this->conf = $this->container->get('conf');
|
||||||
|
$this->setLinkDb($this->conf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware execution:
|
||||||
|
* - check the API request
|
||||||
|
* - execute the controller
|
||||||
|
* - return the response
|
||||||
|
*
|
||||||
|
* @param Request $request Slim request
|
||||||
|
* @param Response $response Slim response
|
||||||
|
* @param callable $next Next action
|
||||||
|
*
|
||||||
|
* @return Response response.
|
||||||
|
*/
|
||||||
|
public function __invoke($request, $response, $next)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->checkRequest($request);
|
||||||
|
$response = $next($request, $response);
|
||||||
|
} catch (ApiException $e) {
|
||||||
|
$e->setResponse($response);
|
||||||
|
$e->setDebug($this->conf->get('dev.debug', false));
|
||||||
|
$response = $e->getApiResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response
|
||||||
|
->withHeader('Access-Control-Allow-Origin', '*')
|
||||||
|
->withHeader(
|
||||||
|
'Access-Control-Allow-Headers',
|
||||||
|
'X-Requested-With, Content-Type, Accept, Origin, Authorization'
|
||||||
|
)
|
||||||
|
->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the request validity (HTTP method, request value, etc.),
|
||||||
|
* that the API is enabled, and the JWT token validity.
|
||||||
|
*
|
||||||
|
* @param Request $request Slim request
|
||||||
|
*
|
||||||
|
* @throws ApiAuthorizationException The API is disabled or the token is invalid.
|
||||||
|
*/
|
||||||
|
protected function checkRequest($request)
|
||||||
|
{
|
||||||
|
if (! $this->conf->get('api.enabled', true)) {
|
||||||
|
throw new ApiAuthorizationException('API is disabled');
|
||||||
|
}
|
||||||
|
$this->checkToken($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check that the JWT token is set and valid.
|
||||||
|
* The API secret setting must be set.
|
||||||
|
*
|
||||||
|
* @param Request $request Slim request
|
||||||
|
*
|
||||||
|
* @throws ApiAuthorizationException The token couldn't be validated.
|
||||||
|
*/
|
||||||
|
protected function checkToken($request)
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
!$request->hasHeader('Authorization')
|
||||||
|
&& !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])
|
||||||
|
) {
|
||||||
|
throw new ApiAuthorizationException('JWT token not provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($this->conf->get('api.secret'))) {
|
||||||
|
throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])) {
|
||||||
|
$authorization = $this->container->environment['REDIRECT_HTTP_AUTHORIZATION'];
|
||||||
|
} else {
|
||||||
|
$authorization = $request->getHeaderLine('Authorization');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! preg_match('/^Bearer (.*)/i', $authorization, $matches)) {
|
||||||
|
throw new ApiAuthorizationException('Invalid JWT header');
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiUtils::validateJwtToken($matches[1], $this->conf->get('api.secret'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instantiate a new LinkDB including private bookmarks,
|
||||||
|
* and load in the Slim container.
|
||||||
|
*
|
||||||
|
* FIXME! LinkDB could use a refactoring to avoid this trick.
|
||||||
|
*
|
||||||
|
* @param ConfigManager $conf instance.
|
||||||
|
*/
|
||||||
|
protected function setLinkDb($conf)
|
||||||
|
{
|
||||||
|
$linkDb = new BookmarkFileService(
|
||||||
|
$conf,
|
||||||
|
$this->container->get('pluginManager'),
|
||||||
|
$this->container->get('history'),
|
||||||
|
new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
$this->container['db'] = $linkDb;
|
||||||
|
}
|
||||||
|
}
|
174
application/api/ApiUtils.php
Normal file
174
application/api/ApiUtils.php
Normal file
|
@ -0,0 +1,174 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api;
|
||||||
|
|
||||||
|
use Shaarli\Api\Exceptions\ApiAuthorizationException;
|
||||||
|
use Shaarli\Bookmark\Bookmark;
|
||||||
|
use Shaarli\Http\Base64Url;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST API utilities
|
||||||
|
*/
|
||||||
|
class ApiUtils
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Validates a JWT token authenticity.
|
||||||
|
*
|
||||||
|
* @param string $token JWT token extracted from the headers.
|
||||||
|
* @param string $secret API secret set in the settings.
|
||||||
|
*
|
||||||
|
* @return bool true on success
|
||||||
|
*
|
||||||
|
* @throws ApiAuthorizationException the token is not valid.
|
||||||
|
*/
|
||||||
|
public static function validateJwtToken($token, $secret)
|
||||||
|
{
|
||||||
|
$parts = explode('.', $token);
|
||||||
|
if (count($parts) != 3 || strlen($parts[0]) == 0 || strlen($parts[1]) == 0) {
|
||||||
|
throw new ApiAuthorizationException('Malformed JWT token');
|
||||||
|
}
|
||||||
|
|
||||||
|
$genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] . '.' . $parts[1], $secret, true));
|
||||||
|
if ($parts[2] != $genSign) {
|
||||||
|
throw new ApiAuthorizationException('Invalid JWT signature');
|
||||||
|
}
|
||||||
|
|
||||||
|
$header = json_decode(Base64Url::decode($parts[0]));
|
||||||
|
if ($header === null) {
|
||||||
|
throw new ApiAuthorizationException('Invalid JWT header');
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_decode(Base64Url::decode($parts[1]));
|
||||||
|
if ($payload === null) {
|
||||||
|
throw new ApiAuthorizationException('Invalid JWT payload');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
empty($payload->iat)
|
||||||
|
|| $payload->iat > time()
|
||||||
|
|| time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION
|
||||||
|
) {
|
||||||
|
throw new ApiAuthorizationException('Invalid JWT issued time');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Link for the REST API.
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark Bookmark data read from the datastore.
|
||||||
|
* @param string $indexUrl Shaarli's index URL (used for relative URL).
|
||||||
|
*
|
||||||
|
* @return array Link data formatted for the REST API.
|
||||||
|
*/
|
||||||
|
public static function formatLink($bookmark, $indexUrl)
|
||||||
|
{
|
||||||
|
$out['id'] = $bookmark->getId();
|
||||||
|
// Not an internal link
|
||||||
|
if (! $bookmark->isNote()) {
|
||||||
|
$out['url'] = $bookmark->getUrl();
|
||||||
|
} else {
|
||||||
|
$out['url'] = rtrim($indexUrl, '/') . '/' . ltrim($bookmark->getUrl(), '/');
|
||||||
|
}
|
||||||
|
$out['shorturl'] = $bookmark->getShortUrl();
|
||||||
|
$out['title'] = $bookmark->getTitle();
|
||||||
|
$out['description'] = $bookmark->getDescription();
|
||||||
|
$out['tags'] = $bookmark->getTags();
|
||||||
|
$out['private'] = $bookmark->isPrivate();
|
||||||
|
$out['created'] = $bookmark->getCreated()->format(\DateTime::ATOM);
|
||||||
|
if (! empty($bookmark->getUpdated())) {
|
||||||
|
$out['updated'] = $bookmark->getUpdated()->format(\DateTime::ATOM);
|
||||||
|
} else {
|
||||||
|
$out['updated'] = '';
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a link given through a request, to a valid Bookmark for the datastore.
|
||||||
|
*
|
||||||
|
* If no URL is provided, it will generate a local note URL.
|
||||||
|
* If no title is provided, it will use the URL as title.
|
||||||
|
*
|
||||||
|
* @param array|null $input Request Link.
|
||||||
|
* @param bool $defaultPrivate Setting defined if a bookmark is private by default.
|
||||||
|
* @param string $tagsSeparator Tags separator loaded from the config file.
|
||||||
|
*
|
||||||
|
* @return Bookmark instance.
|
||||||
|
*/
|
||||||
|
public static function buildBookmarkFromRequest(
|
||||||
|
?array $input,
|
||||||
|
bool $defaultPrivate,
|
||||||
|
string $tagsSeparator
|
||||||
|
): Bookmark {
|
||||||
|
$bookmark = new Bookmark();
|
||||||
|
$url = ! empty($input['url']) ? cleanup_url($input['url']) : '';
|
||||||
|
if (isset($input['private'])) {
|
||||||
|
$private = filter_var($input['private'], FILTER_VALIDATE_BOOLEAN);
|
||||||
|
} else {
|
||||||
|
$private = $defaultPrivate;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bookmark->setTitle(! empty($input['title']) ? $input['title'] : '');
|
||||||
|
$bookmark->setUrl($url);
|
||||||
|
$bookmark->setDescription(! empty($input['description']) ? $input['description'] : '');
|
||||||
|
|
||||||
|
// Be permissive with provided tags format
|
||||||
|
if (is_string($input['tags'] ?? null)) {
|
||||||
|
$input['tags'] = tags_str2array($input['tags'], $tagsSeparator);
|
||||||
|
}
|
||||||
|
if (is_array($input['tags'] ?? null) && count($input['tags']) === 1 && is_string($input['tags'][0])) {
|
||||||
|
$input['tags'] = tags_str2array($input['tags'][0], $tagsSeparator);
|
||||||
|
}
|
||||||
|
|
||||||
|
$bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []);
|
||||||
|
$bookmark->setPrivate($private);
|
||||||
|
|
||||||
|
$created = \DateTime::createFromFormat(\DateTime::ATOM, $input['created'] ?? '');
|
||||||
|
if ($created instanceof \DateTimeInterface) {
|
||||||
|
$bookmark->setCreated($created);
|
||||||
|
}
|
||||||
|
$updated = \DateTime::createFromFormat(\DateTime::ATOM, $input['updated'] ?? '');
|
||||||
|
if ($updated instanceof \DateTimeInterface) {
|
||||||
|
$bookmark->setUpdated($updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bookmark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update link fields using an updated link object.
|
||||||
|
*
|
||||||
|
* @param Bookmark $oldLink data
|
||||||
|
* @param Bookmark $newLink data
|
||||||
|
*
|
||||||
|
* @return Bookmark $oldLink updated with $newLink values
|
||||||
|
*/
|
||||||
|
public static function updateLink($oldLink, $newLink)
|
||||||
|
{
|
||||||
|
$oldLink->setTitle($newLink->getTitle());
|
||||||
|
$oldLink->setUrl($newLink->getUrl());
|
||||||
|
$oldLink->setDescription($newLink->getDescription());
|
||||||
|
$oldLink->setTags($newLink->getTags());
|
||||||
|
$oldLink->setPrivate($newLink->isPrivate());
|
||||||
|
|
||||||
|
return $oldLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Tag for the REST API.
|
||||||
|
*
|
||||||
|
* @param string $tag Tag name
|
||||||
|
* @param int $occurrences Number of bookmarks using this tag
|
||||||
|
*
|
||||||
|
* @return array Link data formatted for the REST API.
|
||||||
|
*/
|
||||||
|
public static function formatTag($tag, $occurences)
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $tag,
|
||||||
|
'occurrences' => $occurences,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
73
application/api/controllers/ApiController.php
Normal file
73
application/api/controllers/ApiController.php
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api\Controllers;
|
||||||
|
|
||||||
|
use Shaarli\Bookmark\BookmarkServiceInterface;
|
||||||
|
use Shaarli\Config\ConfigManager;
|
||||||
|
use Shaarli\History;
|
||||||
|
use Slim\Container;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract Class ApiController
|
||||||
|
*
|
||||||
|
* Defines REST API Controller dependencies injected from the container.
|
||||||
|
*
|
||||||
|
* @package Api\Controllers
|
||||||
|
*/
|
||||||
|
abstract class ApiController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var Container
|
||||||
|
*/
|
||||||
|
protected $ci;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ConfigManager
|
||||||
|
*/
|
||||||
|
protected $conf;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var BookmarkServiceInterface
|
||||||
|
*/
|
||||||
|
protected $bookmarkService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var History
|
||||||
|
*/
|
||||||
|
protected $history;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int|null JSON style option.
|
||||||
|
*/
|
||||||
|
protected $jsonStyle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ApiController constructor.
|
||||||
|
*
|
||||||
|
* Note: enabling debug mode displays JSON with readable formatting.
|
||||||
|
*
|
||||||
|
* @param Container $ci Slim container.
|
||||||
|
*/
|
||||||
|
public function __construct(Container $ci)
|
||||||
|
{
|
||||||
|
$this->ci = $ci;
|
||||||
|
$this->conf = $ci->get('conf');
|
||||||
|
$this->bookmarkService = $ci->get('db');
|
||||||
|
$this->history = $ci->get('history');
|
||||||
|
if ($this->conf->get('dev.debug', false)) {
|
||||||
|
$this->jsonStyle = JSON_PRETTY_PRINT;
|
||||||
|
} else {
|
||||||
|
$this->jsonStyle = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the container.
|
||||||
|
*
|
||||||
|
* @return Container
|
||||||
|
*/
|
||||||
|
public function getCi()
|
||||||
|
{
|
||||||
|
return $this->ci;
|
||||||
|
}
|
||||||
|
}
|
68
application/api/controllers/HistoryController.php
Normal file
68
application/api/controllers/HistoryController.php
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api\Controllers;
|
||||||
|
|
||||||
|
use Shaarli\Api\Exceptions\ApiBadParametersException;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class History
|
||||||
|
*
|
||||||
|
* REST API Controller: /history
|
||||||
|
*
|
||||||
|
* @package Shaarli\Api\Controllers
|
||||||
|
*/
|
||||||
|
class HistoryController extends ApiController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Service providing operation regarding Shaarli datastore and settings.
|
||||||
|
*
|
||||||
|
* @param Request $request Slim request.
|
||||||
|
* @param Response $response Slim response.
|
||||||
|
*
|
||||||
|
* @return Response response.
|
||||||
|
*
|
||||||
|
* @throws ApiBadParametersException Invalid parameters.
|
||||||
|
*/
|
||||||
|
public function getHistory($request, $response)
|
||||||
|
{
|
||||||
|
$history = $this->history->getHistory();
|
||||||
|
|
||||||
|
// Return history operations from the {offset}th, starting from {since}.
|
||||||
|
$since = \DateTime::createFromFormat(\DateTime::ATOM, $request->getParam('since', ''));
|
||||||
|
$offset = $request->getParam('offset');
|
||||||
|
if (empty($offset)) {
|
||||||
|
$offset = 0;
|
||||||
|
} elseif (ctype_digit($offset)) {
|
||||||
|
$offset = (int) $offset;
|
||||||
|
} else {
|
||||||
|
throw new ApiBadParametersException('Invalid offset');
|
||||||
|
}
|
||||||
|
|
||||||
|
// limit parameter is either a number of bookmarks or 'all' for everything.
|
||||||
|
$limit = $request->getParam('limit');
|
||||||
|
if (empty($limit)) {
|
||||||
|
$limit = count($history);
|
||||||
|
} elseif (ctype_digit($limit)) {
|
||||||
|
$limit = (int) $limit;
|
||||||
|
} else {
|
||||||
|
throw new ApiBadParametersException('Invalid limit');
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
$i = 0;
|
||||||
|
foreach ($history as $entry) {
|
||||||
|
if ((! empty($since) && $entry['datetime'] <= $since) || count($out) >= $limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (++$i > $offset) {
|
||||||
|
$out[$i] = $entry;
|
||||||
|
$out[$i]['datetime'] = $out[$i]['datetime']->format(\DateTime::ATOM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$out = array_values($out);
|
||||||
|
|
||||||
|
return $response->withJson($out, 200, $this->jsonStyle);
|
||||||
|
}
|
||||||
|
}
|
43
application/api/controllers/Info.php
Normal file
43
application/api/controllers/Info.php
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api\Controllers;
|
||||||
|
|
||||||
|
use Shaarli\Bookmark\BookmarkFilter;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class Info
|
||||||
|
*
|
||||||
|
* REST API Controller: /info
|
||||||
|
*
|
||||||
|
* @package Api\Controllers
|
||||||
|
* @see http://shaarli.github.io/api-documentation/#links-instance-information-get
|
||||||
|
*/
|
||||||
|
class Info extends ApiController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Service providing various information about Shaarli instance.
|
||||||
|
*
|
||||||
|
* @param Request $request Slim request.
|
||||||
|
* @param Response $response Slim response.
|
||||||
|
*
|
||||||
|
* @return Response response.
|
||||||
|
*/
|
||||||
|
public function getInfo($request, $response)
|
||||||
|
{
|
||||||
|
$info = [
|
||||||
|
'global_counter' => $this->bookmarkService->count(),
|
||||||
|
'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE),
|
||||||
|
'settings' => [
|
||||||
|
'title' => $this->conf->get('general.title', 'Shaarli'),
|
||||||
|
'header_link' => $this->conf->get('general.header_link', '?'),
|
||||||
|
'timezone' => $this->conf->get('general.timezone', 'UTC'),
|
||||||
|
'enabled_plugins' => $this->conf->get('general.enabled_plugins', []),
|
||||||
|
'default_private_links' => $this->conf->get('privacy.default_private_links', false),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $response->withJson($info, 200, $this->jsonStyle);
|
||||||
|
}
|
||||||
|
}
|
213
application/api/controllers/Links.php
Normal file
213
application/api/controllers/Links.php
Normal file
|
@ -0,0 +1,213 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api\Controllers;
|
||||||
|
|
||||||
|
use Shaarli\Api\ApiUtils;
|
||||||
|
use Shaarli\Api\Exceptions\ApiBadParametersException;
|
||||||
|
use Shaarli\Api\Exceptions\ApiLinkNotFoundException;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class Links
|
||||||
|
*
|
||||||
|
* REST API Controller: all services related to bookmarks collection.
|
||||||
|
*
|
||||||
|
* @package Api\Controllers
|
||||||
|
* @see http://shaarli.github.io/api-documentation/#links-links-collection
|
||||||
|
*/
|
||||||
|
class Links extends ApiController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var int Number of bookmarks returned if no limit is provided.
|
||||||
|
*/
|
||||||
|
public static $DEFAULT_LIMIT = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve a list of bookmarks, allowing different filters.
|
||||||
|
*
|
||||||
|
* @param Request $request Slim request.
|
||||||
|
* @param Response $response Slim response.
|
||||||
|
*
|
||||||
|
* @return Response response.
|
||||||
|
*
|
||||||
|
* @throws ApiBadParametersException Invalid parameters.
|
||||||
|
*/
|
||||||
|
public function getLinks($request, $response)
|
||||||
|
{
|
||||||
|
$private = $request->getParam('visibility');
|
||||||
|
|
||||||
|
// Return bookmarks from the {offset}th link, starting from 0.
|
||||||
|
$offset = $request->getParam('offset');
|
||||||
|
if (! empty($offset) && ! ctype_digit($offset)) {
|
||||||
|
throw new ApiBadParametersException('Invalid offset');
|
||||||
|
}
|
||||||
|
$offset = ! empty($offset) ? intval($offset) : 0;
|
||||||
|
|
||||||
|
// limit parameter is either a number of bookmarks or 'all' for everything.
|
||||||
|
$limit = $request->getParam('limit');
|
||||||
|
if (empty($limit)) {
|
||||||
|
$limit = self::$DEFAULT_LIMIT;
|
||||||
|
} elseif (ctype_digit($limit)) {
|
||||||
|
$limit = intval($limit);
|
||||||
|
} elseif ($limit === 'all') {
|
||||||
|
$limit = null;
|
||||||
|
} else {
|
||||||
|
throw new ApiBadParametersException('Invalid limit');
|
||||||
|
}
|
||||||
|
|
||||||
|
$searchResult = $this->bookmarkService->search(
|
||||||
|
[
|
||||||
|
'searchtags' => $request->getParam('searchtags', ''),
|
||||||
|
'searchterm' => $request->getParam('searchterm', ''),
|
||||||
|
],
|
||||||
|
$private,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
[
|
||||||
|
'limit' => $limit,
|
||||||
|
'offset' => $offset,
|
||||||
|
'allowOutOfBounds' => true,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 'environment' is set by Slim and encapsulate $_SERVER.
|
||||||
|
$indexUrl = index_url($this->ci['environment']);
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($searchResult->getBookmarks() as $bookmark) {
|
||||||
|
$out[] = ApiUtils::formatLink($bookmark, $indexUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->withJson($out, 200, $this->jsonStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a single formatted link by its ID.
|
||||||
|
*
|
||||||
|
* @param Request $request Slim request.
|
||||||
|
* @param Response $response Slim response.
|
||||||
|
* @param array $args Path parameters. including the ID.
|
||||||
|
*
|
||||||
|
* @return Response containing the link array.
|
||||||
|
*
|
||||||
|
* @throws ApiLinkNotFoundException generating a 404 error.
|
||||||
|
*/
|
||||||
|
public function getLink($request, $response, $args)
|
||||||
|
{
|
||||||
|
$id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
|
||||||
|
if ($id === null || ! $this->bookmarkService->exists($id)) {
|
||||||
|
throw new ApiLinkNotFoundException();
|
||||||
|
}
|
||||||
|
$index = index_url($this->ci['environment']);
|
||||||
|
$out = ApiUtils::formatLink($this->bookmarkService->get($id), $index);
|
||||||
|
|
||||||
|
return $response->withJson($out, 200, $this->jsonStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new link from posted request body.
|
||||||
|
*
|
||||||
|
* @param Request $request Slim request.
|
||||||
|
* @param Response $response Slim response.
|
||||||
|
*
|
||||||
|
* @return Response response.
|
||||||
|
*/
|
||||||
|
public function postLink($request, $response)
|
||||||
|
{
|
||||||
|
$data = (array) ($request->getParsedBody() ?? []);
|
||||||
|
$bookmark = ApiUtils::buildBookmarkFromRequest(
|
||||||
|
$data,
|
||||||
|
$this->conf->get('privacy.default_private_links'),
|
||||||
|
$this->conf->get('general.tags_separator', ' ')
|
||||||
|
);
|
||||||
|
// duplicate by URL, return 409 Conflict
|
||||||
|
if (
|
||||||
|
! empty($bookmark->getUrl())
|
||||||
|
&& ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl()))
|
||||||
|
) {
|
||||||
|
return $response->withJson(
|
||||||
|
ApiUtils::formatLink($dup, index_url($this->ci['environment'])),
|
||||||
|
409,
|
||||||
|
$this->jsonStyle
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->bookmarkService->add($bookmark);
|
||||||
|
$out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment']));
|
||||||
|
$redirect = $this->ci->router->pathFor('getLink', ['id' => $bookmark->getId()]);
|
||||||
|
return $response->withAddedHeader('Location', $redirect)
|
||||||
|
->withJson($out, 201, $this->jsonStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing link from posted request body.
|
||||||
|
*
|
||||||
|
* @param Request $request Slim request.
|
||||||
|
* @param Response $response Slim response.
|
||||||
|
* @param array $args Path parameters. including the ID.
|
||||||
|
*
|
||||||
|
* @return Response response.
|
||||||
|
*
|
||||||
|
* @throws ApiLinkNotFoundException generating a 404 error.
|
||||||
|
*/
|
||||||
|
public function putLink($request, $response, $args)
|
||||||
|
{
|
||||||
|
$id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
|
||||||
|
if ($id === null || !$this->bookmarkService->exists($id)) {
|
||||||
|
throw new ApiLinkNotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$index = index_url($this->ci['environment']);
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
|
||||||
|
$requestBookmark = ApiUtils::buildBookmarkFromRequest(
|
||||||
|
$data,
|
||||||
|
$this->conf->get('privacy.default_private_links'),
|
||||||
|
$this->conf->get('general.tags_separator', ' ')
|
||||||
|
);
|
||||||
|
// duplicate URL on a different link, return 409 Conflict
|
||||||
|
if (
|
||||||
|
! empty($requestBookmark->getUrl())
|
||||||
|
&& ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl()))
|
||||||
|
&& $dup->getId() != $id
|
||||||
|
) {
|
||||||
|
return $response->withJson(
|
||||||
|
ApiUtils::formatLink($dup, $index),
|
||||||
|
409,
|
||||||
|
$this->jsonStyle
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$responseBookmark = $this->bookmarkService->get($id);
|
||||||
|
$responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark);
|
||||||
|
$this->bookmarkService->set($responseBookmark);
|
||||||
|
|
||||||
|
$out = ApiUtils::formatLink($responseBookmark, $index);
|
||||||
|
return $response->withJson($out, 200, $this->jsonStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an existing link by its ID.
|
||||||
|
*
|
||||||
|
* @param Request $request Slim request.
|
||||||
|
* @param Response $response Slim response.
|
||||||
|
* @param array $args Path parameters. including the ID.
|
||||||
|
*
|
||||||
|
* @return Response response.
|
||||||
|
*
|
||||||
|
* @throws ApiLinkNotFoundException generating a 404 error.
|
||||||
|
*/
|
||||||
|
public function deleteLink($request, $response, $args)
|
||||||
|
{
|
||||||
|
$id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
|
||||||
|
if ($id === null || !$this->bookmarkService->exists($id)) {
|
||||||
|
throw new ApiLinkNotFoundException();
|
||||||
|
}
|
||||||
|
$bookmark = $this->bookmarkService->get($id);
|
||||||
|
$this->bookmarkService->remove($bookmark);
|
||||||
|
|
||||||
|
return $response->withStatus(204);
|
||||||
|
}
|
||||||
|
}
|
174
application/api/controllers/Tags.php
Normal file
174
application/api/controllers/Tags.php
Normal file
|
@ -0,0 +1,174 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api\Controllers;
|
||||||
|
|
||||||
|
use Shaarli\Api\ApiUtils;
|
||||||
|
use Shaarli\Api\Exceptions\ApiBadParametersException;
|
||||||
|
use Shaarli\Api\Exceptions\ApiTagNotFoundException;
|
||||||
|
use Shaarli\Bookmark\BookmarkFilter;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class Tags
|
||||||
|
*
|
||||||
|
* REST API Controller: all services related to tags collection.
|
||||||
|
*
|
||||||
|
* @package Api\Controllers
|
||||||
|
*/
|
||||||
|
class Tags extends ApiController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var int Number of bookmarks returned if no limit is provided.
|
||||||
|
*/
|
||||||
|
public static $DEFAULT_LIMIT = 'all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve a list of tags, allowing different filters.
|
||||||
|
*
|
||||||
|
* @param Request $request Slim request.
|
||||||
|
* @param Response $response Slim response.
|
||||||
|
*
|
||||||
|
* @return Response response.
|
||||||
|
*
|
||||||
|
* @throws ApiBadParametersException Invalid parameters.
|
||||||
|
*/
|
||||||
|
public function getTags($request, $response)
|
||||||
|
{
|
||||||
|
$visibility = $request->getParam('visibility');
|
||||||
|
$tags = $this->bookmarkService->bookmarksCountPerTag([], $visibility);
|
||||||
|
|
||||||
|
// Return tags from the {offset}th tag, starting from 0.
|
||||||
|
$offset = $request->getParam('offset');
|
||||||
|
if (! empty($offset) && ! ctype_digit($offset)) {
|
||||||
|
throw new ApiBadParametersException('Invalid offset');
|
||||||
|
}
|
||||||
|
$offset = ! empty($offset) ? intval($offset) : 0;
|
||||||
|
if ($offset > count($tags)) {
|
||||||
|
return $response->withJson([], 200, $this->jsonStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// limit parameter is either a number of bookmarks or 'all' for everything.
|
||||||
|
$limit = $request->getParam('limit');
|
||||||
|
if (empty($limit)) {
|
||||||
|
$limit = self::$DEFAULT_LIMIT;
|
||||||
|
}
|
||||||
|
if (ctype_digit($limit)) {
|
||||||
|
$limit = intval($limit);
|
||||||
|
} elseif ($limit === 'all') {
|
||||||
|
$limit = count($tags);
|
||||||
|
} else {
|
||||||
|
throw new ApiBadParametersException('Invalid limit');
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
$index = 0;
|
||||||
|
foreach ($tags as $tag => $occurrences) {
|
||||||
|
if (count($out) >= $limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ($index++ >= $offset) {
|
||||||
|
$out[] = ApiUtils::formatTag($tag, $occurrences);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->withJson($out, 200, $this->jsonStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a single formatted tag by its name.
|
||||||
|
*
|
||||||
|
* @param Request $request Slim request.
|
||||||
|
* @param Response $response Slim response.
|
||||||
|
* @param array $args Path parameters. including the tag name.
|
||||||
|
*
|
||||||
|
* @return Response containing the link array.
|
||||||
|
*
|
||||||
|
* @throws ApiTagNotFoundException generating a 404 error.
|
||||||
|
*/
|
||||||
|
public function getTag($request, $response, $args)
|
||||||
|
{
|
||||||
|
$tags = $this->bookmarkService->bookmarksCountPerTag();
|
||||||
|
if (!isset($tags[$args['tagName']])) {
|
||||||
|
throw new ApiTagNotFoundException();
|
||||||
|
}
|
||||||
|
$out = ApiUtils::formatTag($args['tagName'], $tags[$args['tagName']]);
|
||||||
|
|
||||||
|
return $response->withJson($out, 200, $this->jsonStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a tag from the given name.
|
||||||
|
* If the new name provided matches an existing tag, they will be merged.
|
||||||
|
*
|
||||||
|
* @param Request $request Slim request.
|
||||||
|
* @param Response $response Slim response.
|
||||||
|
* @param array $args Path parameters. including the tag name.
|
||||||
|
*
|
||||||
|
* @return Response response.
|
||||||
|
*
|
||||||
|
* @throws ApiTagNotFoundException generating a 404 error.
|
||||||
|
* @throws ApiBadParametersException new tag name not provided
|
||||||
|
*/
|
||||||
|
public function putTag($request, $response, $args)
|
||||||
|
{
|
||||||
|
$tags = $this->bookmarkService->bookmarksCountPerTag();
|
||||||
|
if (! isset($tags[$args['tagName']])) {
|
||||||
|
throw new ApiTagNotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
if (empty($data['name'])) {
|
||||||
|
throw new ApiBadParametersException('New tag name is required in the request body');
|
||||||
|
}
|
||||||
|
|
||||||
|
$searchResult = $this->bookmarkService->search(
|
||||||
|
['searchtags' => $args['tagName']],
|
||||||
|
BookmarkFilter::$ALL,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
foreach ($searchResult->getBookmarks() as $bookmark) {
|
||||||
|
$bookmark->renameTag($args['tagName'], $data['name']);
|
||||||
|
$this->bookmarkService->set($bookmark, false);
|
||||||
|
$this->history->updateLink($bookmark);
|
||||||
|
}
|
||||||
|
$this->bookmarkService->save();
|
||||||
|
|
||||||
|
$tags = $this->bookmarkService->bookmarksCountPerTag();
|
||||||
|
$out = ApiUtils::formatTag($data['name'], $tags[$data['name']]);
|
||||||
|
return $response->withJson($out, 200, $this->jsonStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an existing tag by its name.
|
||||||
|
*
|
||||||
|
* @param Request $request Slim request.
|
||||||
|
* @param Response $response Slim response.
|
||||||
|
* @param array $args Path parameters. including the tag name.
|
||||||
|
*
|
||||||
|
* @return Response response.
|
||||||
|
*
|
||||||
|
* @throws ApiTagNotFoundException generating a 404 error.
|
||||||
|
*/
|
||||||
|
public function deleteTag($request, $response, $args)
|
||||||
|
{
|
||||||
|
$tags = $this->bookmarkService->bookmarksCountPerTag();
|
||||||
|
if (! isset($tags[$args['tagName']])) {
|
||||||
|
throw new ApiTagNotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$searchResult = $this->bookmarkService->search(
|
||||||
|
['searchtags' => $args['tagName']],
|
||||||
|
BookmarkFilter::$ALL,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
foreach ($searchResult->getBookmarks() as $bookmark) {
|
||||||
|
$bookmark->deleteTag($args['tagName']);
|
||||||
|
$this->bookmarkService->set($bookmark, false);
|
||||||
|
$this->history->updateLink($bookmark);
|
||||||
|
}
|
||||||
|
$this->bookmarkService->save();
|
||||||
|
|
||||||
|
return $response->withStatus(204);
|
||||||
|
}
|
||||||
|
}
|
34
application/api/exceptions/ApiAuthorizationException.php
Normal file
34
application/api/exceptions/ApiAuthorizationException.php
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api\Exceptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ApiAuthorizationException
|
||||||
|
*
|
||||||
|
* Request not authorized, return a 401 HTTP code.
|
||||||
|
*/
|
||||||
|
class ApiAuthorizationException extends ApiException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function getApiResponse()
|
||||||
|
{
|
||||||
|
$this->setMessage('Not authorized');
|
||||||
|
return $this->buildApiResponse(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the exception message.
|
||||||
|
*
|
||||||
|
* We only return a generic error message in production mode to avoid giving
|
||||||
|
* to much security information.
|
||||||
|
*
|
||||||
|
* @param $message string the exception message.
|
||||||
|
*/
|
||||||
|
public function setMessage($message)
|
||||||
|
{
|
||||||
|
$original = $this->debug === true ? ': ' . $this->getMessage() : '';
|
||||||
|
$this->message = $message . $original;
|
||||||
|
}
|
||||||
|
}
|
19
application/api/exceptions/ApiBadParametersException.php
Normal file
19
application/api/exceptions/ApiBadParametersException.php
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api\Exceptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ApiBadParametersException
|
||||||
|
*
|
||||||
|
* Invalid request exception, return a 400 HTTP code.
|
||||||
|
*/
|
||||||
|
class ApiBadParametersException extends ApiException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function getApiResponse()
|
||||||
|
{
|
||||||
|
return $this->buildApiResponse(400);
|
||||||
|
}
|
||||||
|
}
|
78
application/api/exceptions/ApiException.php
Normal file
78
application/api/exceptions/ApiException.php
Normal file
|
@ -0,0 +1,78 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api\Exceptions;
|
||||||
|
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract class ApiException
|
||||||
|
*
|
||||||
|
* Parent Exception related to the API, able to generate a valid Response (ResponseInterface).
|
||||||
|
* Also can include various information in debug mode.
|
||||||
|
*/
|
||||||
|
abstract class ApiException extends \Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var Response instance from Slim.
|
||||||
|
*/
|
||||||
|
protected $response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool Debug mode enabled/disabled.
|
||||||
|
*/
|
||||||
|
protected $debug;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the final response.
|
||||||
|
*
|
||||||
|
* @return Response Final response to give.
|
||||||
|
*/
|
||||||
|
abstract public function getApiResponse();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates ApiResponse body.
|
||||||
|
* In production mode, it will only return the exception message,
|
||||||
|
* but in dev mode, it includes additional information in an array.
|
||||||
|
*
|
||||||
|
* @return array|string response body
|
||||||
|
*/
|
||||||
|
protected function getApiResponseBody()
|
||||||
|
{
|
||||||
|
if ($this->debug !== true) {
|
||||||
|
return $this->getMessage();
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'message' => $this->getMessage(),
|
||||||
|
'stacktrace' => get_class($this) . ': ' . $this->getTraceAsString()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the Response object to return.
|
||||||
|
*
|
||||||
|
* @param int $code HTTP status.
|
||||||
|
*
|
||||||
|
* @return Response with status + body.
|
||||||
|
*/
|
||||||
|
protected function buildApiResponse($code)
|
||||||
|
{
|
||||||
|
$style = $this->debug ? JSON_PRETTY_PRINT : null;
|
||||||
|
return $this->response->withJson($this->getApiResponseBody(), $code, $style);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Response $response
|
||||||
|
*/
|
||||||
|
public function setResponse($response)
|
||||||
|
{
|
||||||
|
$this->response = $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param bool $debug
|
||||||
|
*/
|
||||||
|
public function setDebug($debug)
|
||||||
|
{
|
||||||
|
$this->debug = $debug;
|
||||||
|
}
|
||||||
|
}
|
19
application/api/exceptions/ApiInternalException.php
Normal file
19
application/api/exceptions/ApiInternalException.php
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api\Exceptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ApiInternalException
|
||||||
|
*
|
||||||
|
* Generic exception, return a 500 HTTP code.
|
||||||
|
*/
|
||||||
|
class ApiInternalException extends ApiException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
public function getApiResponse()
|
||||||
|
{
|
||||||
|
return $this->buildApiResponse(500);
|
||||||
|
}
|
||||||
|
}
|
29
application/api/exceptions/ApiLinkNotFoundException.php
Normal file
29
application/api/exceptions/ApiLinkNotFoundException.php
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api\Exceptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ApiLinkNotFoundException
|
||||||
|
*
|
||||||
|
* Link selected by ID couldn't be found, results in a 404 error.
|
||||||
|
*
|
||||||
|
* @package Shaarli\Api\Exceptions
|
||||||
|
*/
|
||||||
|
class ApiLinkNotFoundException extends ApiException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* ApiLinkNotFoundException constructor.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->message = 'Link not found';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function getApiResponse()
|
||||||
|
{
|
||||||
|
return $this->buildApiResponse(404);
|
||||||
|
}
|
||||||
|
}
|
29
application/api/exceptions/ApiTagNotFoundException.php
Normal file
29
application/api/exceptions/ApiTagNotFoundException.php
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Api\Exceptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ApiTagNotFoundException
|
||||||
|
*
|
||||||
|
* Tag selected by name couldn't be found in the datastore, results in a 404 error.
|
||||||
|
*
|
||||||
|
* @package Shaarli\Api\Exceptions
|
||||||
|
*/
|
||||||
|
class ApiTagNotFoundException extends ApiException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* ApiLinkNotFoundException constructor.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->message = 'Tag not found';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritdoc}
|
||||||
|
*/
|
||||||
|
public function getApiResponse()
|
||||||
|
{
|
||||||
|
return $this->buildApiResponse(404);
|
||||||
|
}
|
||||||
|
}
|
542
application/bookmark/Bookmark.php
Normal file
542
application/bookmark/Bookmark.php
Normal file
|
@ -0,0 +1,542 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Bookmark;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use Shaarli\Bookmark\Exception\InvalidBookmarkException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class Bookmark
|
||||||
|
*
|
||||||
|
* This class represent a single Bookmark with all its attributes.
|
||||||
|
* Every bookmark should manipulated using this, before being formatted.
|
||||||
|
*
|
||||||
|
* @package Shaarli\Bookmark
|
||||||
|
*/
|
||||||
|
class Bookmark
|
||||||
|
{
|
||||||
|
/** @var string Date format used in string (former ID format) */
|
||||||
|
public const LINK_DATE_FORMAT = 'Ymd_His';
|
||||||
|
|
||||||
|
/** @var int Bookmark ID */
|
||||||
|
protected $id;
|
||||||
|
|
||||||
|
/** @var string Permalink identifier */
|
||||||
|
protected $shortUrl;
|
||||||
|
|
||||||
|
/** @var string Bookmark's URL - $shortUrl prefixed with `?` for notes */
|
||||||
|
protected $url;
|
||||||
|
|
||||||
|
/** @var string Bookmark's title */
|
||||||
|
protected $title;
|
||||||
|
|
||||||
|
/** @var string Raw bookmark's description */
|
||||||
|
protected $description;
|
||||||
|
|
||||||
|
/** @var array List of bookmark's tags */
|
||||||
|
protected $tags;
|
||||||
|
|
||||||
|
/** @var string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found */
|
||||||
|
protected $thumbnail;
|
||||||
|
|
||||||
|
/** @var bool Set to true if the bookmark is set as sticky */
|
||||||
|
protected $sticky;
|
||||||
|
|
||||||
|
/** @var DateTimeInterface Creation datetime */
|
||||||
|
protected $created;
|
||||||
|
|
||||||
|
/** @var DateTimeInterface datetime */
|
||||||
|
protected $updated;
|
||||||
|
|
||||||
|
/** @var bool True if the bookmark can only be seen while logged in */
|
||||||
|
protected $private;
|
||||||
|
|
||||||
|
/** @var mixed[] Available to store any additional content for a bookmark. Currently used for search highlight. */
|
||||||
|
protected $additionalContent = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize a link from array data. Especially useful to create a Bookmark from former link storage format.
|
||||||
|
*
|
||||||
|
* @param array $data
|
||||||
|
* @param string $tagsSeparator Tags separator loaded from the config file.
|
||||||
|
* This is a context data, and it should *never* be stored in the Bookmark object.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function fromArray(array $data, string $tagsSeparator = ' '): Bookmark
|
||||||
|
{
|
||||||
|
$this->id = $data['id'] ?? null;
|
||||||
|
$this->shortUrl = $data['shorturl'] ?? null;
|
||||||
|
$this->url = $data['url'] ?? null;
|
||||||
|
$this->title = $data['title'] ?? null;
|
||||||
|
$this->description = $data['description'] ?? null;
|
||||||
|
$this->thumbnail = $data['thumbnail'] ?? null;
|
||||||
|
$this->sticky = $data['sticky'] ?? false;
|
||||||
|
$this->created = $data['created'] ?? null;
|
||||||
|
if (is_array($data['tags'])) {
|
||||||
|
$this->tags = $data['tags'];
|
||||||
|
} else {
|
||||||
|
$this->tags = tags_str2array($data['tags'] ?? '', $tagsSeparator);
|
||||||
|
}
|
||||||
|
if (! empty($data['updated'])) {
|
||||||
|
$this->updated = $data['updated'];
|
||||||
|
}
|
||||||
|
$this->private = ($data['private'] ?? false) ? true : false;
|
||||||
|
$this->additionalContent = $data['additional_content'] ?? [];
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make sure that the current instance of Bookmark is valid and can be saved into the data store.
|
||||||
|
* A valid link requires:
|
||||||
|
* - an integer ID
|
||||||
|
* - a short URL (for permalinks)
|
||||||
|
* - a creation date
|
||||||
|
*
|
||||||
|
* This function also initialize optional empty fields:
|
||||||
|
* - the URL with the permalink
|
||||||
|
* - the title with the URL
|
||||||
|
*
|
||||||
|
* Also make sure that we do not save search highlights in the datastore.
|
||||||
|
*
|
||||||
|
* @throws InvalidBookmarkException
|
||||||
|
*/
|
||||||
|
public function validate(): void
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
$this->id === null
|
||||||
|
|| ! is_int($this->id)
|
||||||
|
|| empty($this->shortUrl)
|
||||||
|
|| empty($this->created)
|
||||||
|
) {
|
||||||
|
throw new InvalidBookmarkException($this);
|
||||||
|
}
|
||||||
|
if (empty($this->url)) {
|
||||||
|
$this->url = '/shaare/' . $this->shortUrl;
|
||||||
|
}
|
||||||
|
if (empty($this->title)) {
|
||||||
|
$this->title = $this->url;
|
||||||
|
}
|
||||||
|
if (array_key_exists('search_highlight', $this->additionalContent)) {
|
||||||
|
unset($this->additionalContent['search_highlight']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Id.
|
||||||
|
* If they're not already initialized, this function also set:
|
||||||
|
* - created: with the current datetime
|
||||||
|
* - shortUrl: with a generated small hash from the date and the given ID
|
||||||
|
*
|
||||||
|
* @param int|null $id
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setId(?int $id): Bookmark
|
||||||
|
{
|
||||||
|
$this->id = $id;
|
||||||
|
if (empty($this->created)) {
|
||||||
|
$this->created = new DateTime();
|
||||||
|
}
|
||||||
|
if (empty($this->shortUrl)) {
|
||||||
|
$this->shortUrl = link_small_hash($this->created, $this->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Id.
|
||||||
|
*
|
||||||
|
* @return int|null
|
||||||
|
*/
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the ShortUrl.
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function getShortUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->shortUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Url.
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function getUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Title.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getTitle(): ?string
|
||||||
|
{
|
||||||
|
return $this->title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Description.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return ! empty($this->description) ? $this->description : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Created.
|
||||||
|
*
|
||||||
|
* @return DateTimeInterface
|
||||||
|
*/
|
||||||
|
public function getCreated(): ?DateTimeInterface
|
||||||
|
{
|
||||||
|
return $this->created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Updated.
|
||||||
|
*
|
||||||
|
* @return DateTimeInterface
|
||||||
|
*/
|
||||||
|
public function getUpdated(): ?DateTimeInterface
|
||||||
|
{
|
||||||
|
return $this->updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the ShortUrl.
|
||||||
|
*
|
||||||
|
* @param string|null $shortUrl
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setShortUrl(?string $shortUrl): Bookmark
|
||||||
|
{
|
||||||
|
$this->shortUrl = $shortUrl;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Url.
|
||||||
|
*
|
||||||
|
* @param string|null $url
|
||||||
|
* @param string[] $allowedProtocols
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setUrl(?string $url, array $allowedProtocols = []): Bookmark
|
||||||
|
{
|
||||||
|
$url = $url !== null ? trim($url) : '';
|
||||||
|
if (! empty($url)) {
|
||||||
|
$url = whitelist_protocols($url, $allowedProtocols);
|
||||||
|
}
|
||||||
|
$this->url = $url;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Title.
|
||||||
|
*
|
||||||
|
* @param string|null $title
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setTitle(?string $title): Bookmark
|
||||||
|
{
|
||||||
|
$this->title = $title !== null ? trim($title) : '';
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Description.
|
||||||
|
*
|
||||||
|
* @param string|null $description
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setDescription(?string $description): Bookmark
|
||||||
|
{
|
||||||
|
$this->description = $description;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Created.
|
||||||
|
* Note: you shouldn't set this manually except for special cases (like bookmark import)
|
||||||
|
*
|
||||||
|
* @param DateTimeInterface|null $created
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setCreated(?DateTimeInterface $created): Bookmark
|
||||||
|
{
|
||||||
|
$this->created = $created;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Updated.
|
||||||
|
*
|
||||||
|
* @param DateTimeInterface|null $updated
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setUpdated(?DateTimeInterface $updated): Bookmark
|
||||||
|
{
|
||||||
|
$this->updated = $updated;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Private.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isPrivate(): bool
|
||||||
|
{
|
||||||
|
return $this->private ? true : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Private.
|
||||||
|
*
|
||||||
|
* @param bool|null $private
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setPrivate(?bool $private): Bookmark
|
||||||
|
{
|
||||||
|
$this->private = $private ? true : false;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Tags.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public function getTags(): array
|
||||||
|
{
|
||||||
|
return is_array($this->tags) ? $this->tags : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Tags.
|
||||||
|
*
|
||||||
|
* @param string[]|null $tags
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setTags(?array $tags): Bookmark
|
||||||
|
{
|
||||||
|
$this->tags = array_map(
|
||||||
|
function (string $tag): string {
|
||||||
|
return $tag[0] === '-' ? substr($tag, 1) : $tag;
|
||||||
|
},
|
||||||
|
tags_filter($tags, ' ')
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Thumbnail.
|
||||||
|
*
|
||||||
|
* @return string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found
|
||||||
|
*/
|
||||||
|
public function getThumbnail()
|
||||||
|
{
|
||||||
|
return !$this->isNote() ? $this->thumbnail : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Thumbnail.
|
||||||
|
*
|
||||||
|
* @param string|bool|null $thumbnail Thumbnail's URL - false if no thumbnail could be found
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setThumbnail($thumbnail): Bookmark
|
||||||
|
{
|
||||||
|
$this->thumbnail = $thumbnail;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true if:
|
||||||
|
* - the bookmark's thumbnail is not already set to false (= not found)
|
||||||
|
* - it's not a note
|
||||||
|
* - it's an HTTP(S) link
|
||||||
|
* - the thumbnail has not yet be retrieved (null) or its associated cache file doesn't exist anymore
|
||||||
|
*
|
||||||
|
* @return bool True if the bookmark's thumbnail needs to be retrieved.
|
||||||
|
*/
|
||||||
|
public function shouldUpdateThumbnail(): bool
|
||||||
|
{
|
||||||
|
return $this->thumbnail !== false
|
||||||
|
&& !$this->isNote()
|
||||||
|
&& startsWith(strtolower($this->url), 'http')
|
||||||
|
&& (null === $this->thumbnail || !is_file($this->thumbnail))
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Sticky.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isSticky(): bool
|
||||||
|
{
|
||||||
|
return $this->sticky ? true : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the Sticky.
|
||||||
|
*
|
||||||
|
* @param bool|null $sticky
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*/
|
||||||
|
public function setSticky(?bool $sticky): Bookmark
|
||||||
|
{
|
||||||
|
$this->sticky = $sticky ? true : false;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $separator Tags separator loaded from the config file.
|
||||||
|
*
|
||||||
|
* @return string Bookmark's tags as a string, separated by a separator
|
||||||
|
*/
|
||||||
|
public function getTagsString(string $separator = ' '): string
|
||||||
|
{
|
||||||
|
return tags_array2str($this->getTags(), $separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isNote(): bool
|
||||||
|
{
|
||||||
|
// We check empty value to get a valid result if the link has not been saved yet
|
||||||
|
return empty($this->url) || startsWith($this->url, '/shaare/') || $this->url[0] === '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set tags from a string.
|
||||||
|
* Note:
|
||||||
|
* - tags must be separated whether by a space or a comma
|
||||||
|
* - multiple spaces will be removed
|
||||||
|
* - trailing dash in tags will be removed
|
||||||
|
*
|
||||||
|
* @param string|null $tags
|
||||||
|
* @param string $separator Tags separator loaded from the config file.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setTagsString(?string $tags, string $separator = ' '): Bookmark
|
||||||
|
{
|
||||||
|
$this->setTags(tags_str2array($tags, $separator));
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get entire additionalContent array.
|
||||||
|
*
|
||||||
|
* @return mixed[]
|
||||||
|
*/
|
||||||
|
public function getAdditionalContent(): array
|
||||||
|
{
|
||||||
|
return $this->additionalContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a single entry in additionalContent, by key.
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @param mixed|null $value Any type of value can be set.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setAdditionalContentEntry(string $key, $value): self
|
||||||
|
{
|
||||||
|
$this->additionalContent[$key] = $value;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single entry in additionalContent, by key.
|
||||||
|
*
|
||||||
|
* @param string $key
|
||||||
|
* @param mixed|null $default
|
||||||
|
*
|
||||||
|
* @return mixed|null can be any type or even null.
|
||||||
|
*/
|
||||||
|
public function getAdditionalContentEntry(string $key, $default = null)
|
||||||
|
{
|
||||||
|
return array_key_exists($key, $this->additionalContent) ? $this->additionalContent[$key] : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a tag in tags list.
|
||||||
|
*
|
||||||
|
* @param string $fromTag
|
||||||
|
* @param string $toTag
|
||||||
|
*/
|
||||||
|
public function renameTag(string $fromTag, string $toTag): void
|
||||||
|
{
|
||||||
|
if (($pos = array_search($fromTag, $this->tags ?? [])) !== false) {
|
||||||
|
$this->tags[$pos] = trim($toTag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a tag in tags list.
|
||||||
|
*
|
||||||
|
* @param string $tag
|
||||||
|
*/
|
||||||
|
public function addTag(string $tag): self
|
||||||
|
{
|
||||||
|
return $this->setTags(array_unique(array_merge($this->getTags(), [$tag])));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a tag from tags list.
|
||||||
|
*
|
||||||
|
* @param string $tag
|
||||||
|
*/
|
||||||
|
public function deleteTag(string $tag): void
|
||||||
|
{
|
||||||
|
while (($pos = array_search($tag, $this->tags ?? [])) !== false) {
|
||||||
|
unset($this->tags[$pos]);
|
||||||
|
$this->tags = array_values($this->tags);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
264
application/bookmark/BookmarkArray.php
Normal file
264
application/bookmark/BookmarkArray.php
Normal file
|
@ -0,0 +1,264 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Bookmark;
|
||||||
|
|
||||||
|
use Shaarli\Bookmark\Exception\InvalidBookmarkException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class BookmarkArray
|
||||||
|
*
|
||||||
|
* Implementing ArrayAccess, this allows us to use the bookmark list
|
||||||
|
* as an array and iterate over it.
|
||||||
|
*
|
||||||
|
* @package Shaarli\Bookmark
|
||||||
|
*/
|
||||||
|
class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var Bookmark[]
|
||||||
|
*/
|
||||||
|
protected $bookmarks;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array List of all bookmarks IDS mapped with their array offset.
|
||||||
|
* Map: id->offset.
|
||||||
|
*/
|
||||||
|
protected $ids;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int Position in the $this->keys array (for the Iterator interface)
|
||||||
|
*/
|
||||||
|
protected $position;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array List of offset keys (for the Iterator interface implementation)
|
||||||
|
*/
|
||||||
|
protected $keys;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array List of all recorded URLs (key=url, value=bookmark offset)
|
||||||
|
* for fast reserve search (url-->bookmark offset)
|
||||||
|
*/
|
||||||
|
protected $urls;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->ids = [];
|
||||||
|
$this->bookmarks = [];
|
||||||
|
$this->keys = [];
|
||||||
|
$this->urls = [];
|
||||||
|
$this->position = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Countable - Counts elements of an object
|
||||||
|
*
|
||||||
|
* @return int Number of bookmarks
|
||||||
|
*/
|
||||||
|
public function count(): int
|
||||||
|
{
|
||||||
|
return count($this->bookmarks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ArrayAccess - Assigns a value to the specified offset
|
||||||
|
*
|
||||||
|
* @param int $offset Bookmark ID
|
||||||
|
* @param Bookmark $value instance
|
||||||
|
*
|
||||||
|
* @throws InvalidBookmarkException
|
||||||
|
*/
|
||||||
|
public function offsetSet($offset, $value): void
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
! $value instanceof Bookmark
|
||||||
|
|| $value->getId() === null || empty($value->getUrl())
|
||||||
|
|| ($offset !== null && ! is_int($offset)) || ! is_int($value->getId())
|
||||||
|
|| $offset !== null && $offset !== $value->getId()
|
||||||
|
) {
|
||||||
|
throw new InvalidBookmarkException($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the bookmark exists, we reuse the real offset, otherwise new entry
|
||||||
|
if ($offset !== null) {
|
||||||
|
$existing = $this->getBookmarkOffset($offset);
|
||||||
|
} else {
|
||||||
|
$existing = $this->getBookmarkOffset($value->getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existing !== null) {
|
||||||
|
$offset = $existing;
|
||||||
|
} else {
|
||||||
|
$offset = count($this->bookmarks);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->bookmarks[$offset] = $value;
|
||||||
|
$this->urls[$value->getUrl()] = $offset;
|
||||||
|
$this->ids[$value->getId()] = $offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ArrayAccess - Whether or not an offset exists
|
||||||
|
*
|
||||||
|
* @param int $offset Bookmark ID
|
||||||
|
*
|
||||||
|
* @return bool true if it exists, false otherwise
|
||||||
|
*/
|
||||||
|
public function offsetExists($offset): bool
|
||||||
|
{
|
||||||
|
return array_key_exists($this->getBookmarkOffset($offset), $this->bookmarks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ArrayAccess - Unsets an offset
|
||||||
|
*
|
||||||
|
* @param int $offset Bookmark ID
|
||||||
|
*/
|
||||||
|
public function offsetUnset($offset): void
|
||||||
|
{
|
||||||
|
$realOffset = $this->getBookmarkOffset($offset);
|
||||||
|
$url = $this->bookmarks[$realOffset]->getUrl();
|
||||||
|
unset($this->urls[$url]);
|
||||||
|
unset($this->ids[$offset]);
|
||||||
|
unset($this->bookmarks[$realOffset]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ArrayAccess - Returns the value at specified offset
|
||||||
|
*
|
||||||
|
* @param int $offset Bookmark ID
|
||||||
|
*
|
||||||
|
* @return Bookmark|null The Bookmark if found, null otherwise
|
||||||
|
*/
|
||||||
|
public function offsetGet($offset): ?Bookmark
|
||||||
|
{
|
||||||
|
$realOffset = $this->getBookmarkOffset($offset);
|
||||||
|
return isset($this->bookmarks[$realOffset]) ? $this->bookmarks[$realOffset] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterator - Returns the current element
|
||||||
|
*
|
||||||
|
* @return Bookmark corresponding to the current position
|
||||||
|
*/
|
||||||
|
public function current(): Bookmark
|
||||||
|
{
|
||||||
|
return $this[$this->keys[$this->position]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterator - Returns the key of the current element
|
||||||
|
*
|
||||||
|
* @return int Bookmark ID corresponding to the current position
|
||||||
|
*/
|
||||||
|
public function key(): int
|
||||||
|
{
|
||||||
|
return $this->keys[$this->position];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterator - Moves forward to next element
|
||||||
|
*/
|
||||||
|
public function next(): void
|
||||||
|
{
|
||||||
|
++$this->position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterator - Rewinds the Iterator to the first element
|
||||||
|
*
|
||||||
|
* Entries are sorted by date (latest first)
|
||||||
|
*/
|
||||||
|
public function rewind(): void
|
||||||
|
{
|
||||||
|
$this->keys = array_keys($this->ids);
|
||||||
|
$this->position = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterator - Checks if current position is valid
|
||||||
|
*
|
||||||
|
* @return bool true if the current Bookmark ID exists, false otherwise
|
||||||
|
*/
|
||||||
|
public function valid(): bool
|
||||||
|
{
|
||||||
|
return isset($this->keys[$this->position]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a bookmark offset in bookmarks array from its unique ID.
|
||||||
|
*
|
||||||
|
* @param int|null $id Persistent ID of a bookmark.
|
||||||
|
*
|
||||||
|
* @return int Real offset in local array, or null if doesn't exist.
|
||||||
|
*/
|
||||||
|
protected function getBookmarkOffset(?int $id): ?int
|
||||||
|
{
|
||||||
|
if ($id !== null && isset($this->ids[$id])) {
|
||||||
|
return $this->ids[$id];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the next key for bookmark creation.
|
||||||
|
* E.g. If the last ID is 597, the next will be 598.
|
||||||
|
*
|
||||||
|
* @return int next ID.
|
||||||
|
*/
|
||||||
|
public function getNextId(): int
|
||||||
|
{
|
||||||
|
if (!empty($this->ids)) {
|
||||||
|
return max(array_keys($this->ids)) + 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $url
|
||||||
|
*
|
||||||
|
* @return Bookmark|null
|
||||||
|
*/
|
||||||
|
public function getByUrl(string $url): ?Bookmark
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
! empty($url)
|
||||||
|
&& isset($this->urls[$url])
|
||||||
|
&& isset($this->bookmarks[$this->urls[$url]])
|
||||||
|
) {
|
||||||
|
return $this->bookmarks[$this->urls[$url]];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder links by creation date (newest first).
|
||||||
|
*
|
||||||
|
* Also update the urls and ids mapping arrays.
|
||||||
|
*
|
||||||
|
* @param string $order ASC|DESC
|
||||||
|
* @param bool $ignoreSticky If set to true, sticky bookmarks won't be first
|
||||||
|
*/
|
||||||
|
public function reorder(string $order = 'DESC', bool $ignoreSticky = false): void
|
||||||
|
{
|
||||||
|
$order = $order === 'ASC' ? -1 : 1;
|
||||||
|
// Reorder array by dates.
|
||||||
|
usort($this->bookmarks, function ($a, $b) use ($order, $ignoreSticky) {
|
||||||
|
/** @var $a Bookmark */
|
||||||
|
/** @var $b Bookmark */
|
||||||
|
if (false === $ignoreSticky && $a->isSticky() !== $b->isSticky()) {
|
||||||
|
return $a->isSticky() ? -1 : 1;
|
||||||
|
}
|
||||||
|
return $a->getCreated() < $b->getCreated() ? 1 * $order : -1 * $order;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->urls = [];
|
||||||
|
$this->ids = [];
|
||||||
|
foreach ($this->bookmarks as $key => $bookmark) {
|
||||||
|
$this->urls[$bookmark->getUrl()] = $key;
|
||||||
|
$this->ids[$bookmark->getId()] = $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
443
application/bookmark/BookmarkFileService.php
Normal file
443
application/bookmark/BookmarkFileService.php
Normal file
|
@ -0,0 +1,443 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Bookmark;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use Exception;
|
||||||
|
use malkusch\lock\mutex\Mutex;
|
||||||
|
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
|
||||||
|
use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
|
||||||
|
use Shaarli\Bookmark\Exception\EmptyDataStoreException;
|
||||||
|
use Shaarli\Config\ConfigManager;
|
||||||
|
use Shaarli\Formatter\BookmarkMarkdownFormatter;
|
||||||
|
use Shaarli\History;
|
||||||
|
use Shaarli\Legacy\LegacyLinkDB;
|
||||||
|
use Shaarli\Legacy\LegacyUpdater;
|
||||||
|
use Shaarli\Plugin\PluginManager;
|
||||||
|
use Shaarli\Render\PageCacheManager;
|
||||||
|
use Shaarli\Updater\UpdaterUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class BookmarksService
|
||||||
|
*
|
||||||
|
* This is the entry point to manipulate the bookmark DB.
|
||||||
|
* It manipulates loads links from a file data store containing all bookmarks.
|
||||||
|
*
|
||||||
|
* It also triggers the legacy format (bookmarks as arrays) migration.
|
||||||
|
*/
|
||||||
|
class BookmarkFileService implements BookmarkServiceInterface
|
||||||
|
{
|
||||||
|
/** @var Bookmark[] instance */
|
||||||
|
protected $bookmarks;
|
||||||
|
|
||||||
|
/** @var BookmarkIO instance */
|
||||||
|
protected $bookmarksIO;
|
||||||
|
|
||||||
|
/** @var BookmarkFilter */
|
||||||
|
protected $bookmarkFilter;
|
||||||
|
|
||||||
|
/** @var ConfigManager instance */
|
||||||
|
protected $conf;
|
||||||
|
|
||||||
|
/** @var PluginManager */
|
||||||
|
protected $pluginManager;
|
||||||
|
|
||||||
|
/** @var History instance */
|
||||||
|
protected $history;
|
||||||
|
|
||||||
|
/** @var PageCacheManager instance */
|
||||||
|
protected $pageCacheManager;
|
||||||
|
|
||||||
|
/** @var bool true for logged in users. Default value to retrieve private bookmarks. */
|
||||||
|
protected $isLoggedIn;
|
||||||
|
|
||||||
|
/** @var Mutex */
|
||||||
|
protected $mutex;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
ConfigManager $conf,
|
||||||
|
PluginManager $pluginManager,
|
||||||
|
History $history,
|
||||||
|
Mutex $mutex,
|
||||||
|
bool $isLoggedIn
|
||||||
|
) {
|
||||||
|
$this->conf = $conf;
|
||||||
|
$this->history = $history;
|
||||||
|
$this->mutex = $mutex;
|
||||||
|
$this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn);
|
||||||
|
$this->bookmarksIO = new BookmarkIO($this->conf, $this->mutex);
|
||||||
|
$this->isLoggedIn = $isLoggedIn;
|
||||||
|
|
||||||
|
if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) {
|
||||||
|
$this->bookmarks = new BookmarkArray();
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$this->bookmarks = $this->bookmarksIO->read();
|
||||||
|
} catch (EmptyDataStoreException | DatastoreNotInitializedException $e) {
|
||||||
|
$this->bookmarks = new BookmarkArray();
|
||||||
|
|
||||||
|
if ($this->isLoggedIn) {
|
||||||
|
// Datastore file does not exists, we initialize it with default bookmarks.
|
||||||
|
if ($e instanceof DatastoreNotInitializedException) {
|
||||||
|
$this->initialize();
|
||||||
|
} else {
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->bookmarks instanceof BookmarkArray) {
|
||||||
|
$this->migrate();
|
||||||
|
exit(
|
||||||
|
'Your data store has been migrated, please reload the page.' . PHP_EOL .
|
||||||
|
'If this message keeps showing up, please delete data/updates.txt file.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->pluginManager = $pluginManager;
|
||||||
|
$this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf, $this->pluginManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function findByHash(string $hash, string $privateKey = null): Bookmark
|
||||||
|
{
|
||||||
|
$bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash);
|
||||||
|
// PHP 7.3 introduced array_key_first() to avoid this hack
|
||||||
|
$first = reset($bookmark);
|
||||||
|
if (
|
||||||
|
!$this->isLoggedIn
|
||||||
|
&& $first->isPrivate()
|
||||||
|
&& (empty($privateKey) || $privateKey !== $first->getAdditionalContentEntry('private_key'))
|
||||||
|
) {
|
||||||
|
throw new BookmarkNotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function findByUrl(string $url): ?Bookmark
|
||||||
|
{
|
||||||
|
return $this->bookmarks->getByUrl($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function search(
|
||||||
|
array $request = [],
|
||||||
|
string $visibility = null,
|
||||||
|
bool $caseSensitive = false,
|
||||||
|
bool $untaggedOnly = false,
|
||||||
|
bool $ignoreSticky = false,
|
||||||
|
array $pagination = []
|
||||||
|
): SearchResult {
|
||||||
|
if ($visibility === null) {
|
||||||
|
$visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter bookmark database according to parameters.
|
||||||
|
$searchTags = isset($request['searchtags']) ? $request['searchtags'] : '';
|
||||||
|
$searchTerm = isset($request['searchterm']) ? $request['searchterm'] : '';
|
||||||
|
|
||||||
|
if ($ignoreSticky) {
|
||||||
|
$this->bookmarks->reorder('DESC', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$bookmarks = $this->bookmarkFilter->filter(
|
||||||
|
BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT,
|
||||||
|
[$searchTags, $searchTerm],
|
||||||
|
$caseSensitive,
|
||||||
|
$visibility,
|
||||||
|
$untaggedOnly
|
||||||
|
);
|
||||||
|
|
||||||
|
return SearchResult::getSearchResult(
|
||||||
|
$bookmarks,
|
||||||
|
$pagination['offset'] ?? 0,
|
||||||
|
$pagination['limit'] ?? null,
|
||||||
|
$pagination['allowOutOfBounds'] ?? false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function get(int $id, string $visibility = null): Bookmark
|
||||||
|
{
|
||||||
|
if (! isset($this->bookmarks[$id])) {
|
||||||
|
throw new BookmarkNotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($visibility === null) {
|
||||||
|
$visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bookmark = $this->bookmarks[$id];
|
||||||
|
if (
|
||||||
|
($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
|
||||||
|
|| (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
|
||||||
|
) {
|
||||||
|
throw new Exception('Unauthorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bookmark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function set(Bookmark $bookmark, bool $save = true): Bookmark
|
||||||
|
{
|
||||||
|
if (true !== $this->isLoggedIn) {
|
||||||
|
throw new Exception(t('You\'re not authorized to alter the datastore'));
|
||||||
|
}
|
||||||
|
if (! isset($this->bookmarks[$bookmark->getId()])) {
|
||||||
|
throw new BookmarkNotFoundException();
|
||||||
|
}
|
||||||
|
$bookmark->validate();
|
||||||
|
|
||||||
|
$bookmark->setUpdated(new DateTime());
|
||||||
|
$this->bookmarks[$bookmark->getId()] = $bookmark;
|
||||||
|
if ($save === true) {
|
||||||
|
$this->save();
|
||||||
|
$this->history->updateLink($bookmark);
|
||||||
|
}
|
||||||
|
return $this->bookmarks[$bookmark->getId()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function add(Bookmark $bookmark, bool $save = true): Bookmark
|
||||||
|
{
|
||||||
|
if (true !== $this->isLoggedIn) {
|
||||||
|
throw new Exception(t('You\'re not authorized to alter the datastore'));
|
||||||
|
}
|
||||||
|
if (!empty($bookmark->getId())) {
|
||||||
|
throw new Exception(t('This bookmarks already exists'));
|
||||||
|
}
|
||||||
|
$bookmark->setId($this->bookmarks->getNextId());
|
||||||
|
$bookmark->validate();
|
||||||
|
|
||||||
|
$this->bookmarks[$bookmark->getId()] = $bookmark;
|
||||||
|
if ($save === true) {
|
||||||
|
$this->save();
|
||||||
|
$this->history->addLink($bookmark);
|
||||||
|
}
|
||||||
|
return $this->bookmarks[$bookmark->getId()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark
|
||||||
|
{
|
||||||
|
if (true !== $this->isLoggedIn) {
|
||||||
|
throw new Exception(t('You\'re not authorized to alter the datastore'));
|
||||||
|
}
|
||||||
|
if ($bookmark->getId() === null) {
|
||||||
|
return $this->add($bookmark, $save);
|
||||||
|
}
|
||||||
|
return $this->set($bookmark, $save);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function remove(Bookmark $bookmark, bool $save = true): void
|
||||||
|
{
|
||||||
|
if (true !== $this->isLoggedIn) {
|
||||||
|
throw new Exception(t('You\'re not authorized to alter the datastore'));
|
||||||
|
}
|
||||||
|
if (! isset($this->bookmarks[$bookmark->getId()])) {
|
||||||
|
throw new BookmarkNotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($this->bookmarks[$bookmark->getId()]);
|
||||||
|
if ($save === true) {
|
||||||
|
$this->save();
|
||||||
|
$this->history->deleteLink($bookmark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function exists(int $id, string $visibility = null): bool
|
||||||
|
{
|
||||||
|
if (! isset($this->bookmarks[$id])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($visibility === null) {
|
||||||
|
$visibility = $this->isLoggedIn ? 'all' : 'public';
|
||||||
|
}
|
||||||
|
|
||||||
|
$bookmark = $this->bookmarks[$id];
|
||||||
|
if (
|
||||||
|
($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
|
||||||
|
|| (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function count(string $visibility = null): int
|
||||||
|
{
|
||||||
|
return $this->search([], $visibility)->getResultCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
if (true !== $this->isLoggedIn) {
|
||||||
|
// TODO: raise an Exception instead
|
||||||
|
die('You are not authorized to change the database.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->bookmarks->reorder();
|
||||||
|
$this->bookmarksIO->write($this->bookmarks);
|
||||||
|
$this->pageCacheManager->invalidateCaches();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function bookmarksCountPerTag(array $filteringTags = [], string $visibility = null): array
|
||||||
|
{
|
||||||
|
$searchResult = $this->search(['searchtags' => $filteringTags], $visibility);
|
||||||
|
$tags = [];
|
||||||
|
$caseMapping = [];
|
||||||
|
foreach ($searchResult->getBookmarks() as $bookmark) {
|
||||||
|
foreach ($bookmark->getTags() as $tag) {
|
||||||
|
if (
|
||||||
|
empty($tag)
|
||||||
|
|| (! $this->isLoggedIn && startsWith($tag, '.'))
|
||||||
|
|| $tag === BookmarkMarkdownFormatter::NO_MD_TAG
|
||||||
|
|| in_array($tag, $filteringTags, true)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first case found will be displayed.
|
||||||
|
if (!isset($caseMapping[strtolower($tag)])) {
|
||||||
|
$caseMapping[strtolower($tag)] = $tag;
|
||||||
|
$tags[$caseMapping[strtolower($tag)]] = 0;
|
||||||
|
}
|
||||||
|
$tags[$caseMapping[strtolower($tag)]]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Formerly used arsort(), which doesn't define the sort behaviour for equal values.
|
||||||
|
* Also, this function doesn't produce the same result between PHP 5.6 and 7.
|
||||||
|
*
|
||||||
|
* So we now use array_multisort() to sort tags by DESC occurrences,
|
||||||
|
* then ASC alphabetically for equal values.
|
||||||
|
*
|
||||||
|
* @see https://github.com/shaarli/Shaarli/issues/1142
|
||||||
|
*/
|
||||||
|
$keys = array_keys($tags);
|
||||||
|
$tmpTags = array_combine($keys, $keys);
|
||||||
|
array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
|
||||||
|
|
||||||
|
return $tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function findByDate(
|
||||||
|
\DateTimeInterface $from,
|
||||||
|
\DateTimeInterface $to,
|
||||||
|
?\DateTimeInterface &$previous,
|
||||||
|
?\DateTimeInterface &$next
|
||||||
|
): array {
|
||||||
|
$out = [];
|
||||||
|
$previous = null;
|
||||||
|
$next = null;
|
||||||
|
|
||||||
|
foreach ($this->search([], null, false, false, true)->getBookmarks() as $bookmark) {
|
||||||
|
if ($to < $bookmark->getCreated()) {
|
||||||
|
$next = $bookmark->getCreated();
|
||||||
|
} elseif ($from < $bookmark->getCreated() && $to > $bookmark->getCreated()) {
|
||||||
|
$out[] = $bookmark;
|
||||||
|
} else {
|
||||||
|
if ($previous !== null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$previous = $bookmark->getCreated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function getLatest(): ?Bookmark
|
||||||
|
{
|
||||||
|
foreach ($this->search([], null, false, false, true)->getBookmarks() as $bookmark) {
|
||||||
|
return $bookmark;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
public function initialize(): void
|
||||||
|
{
|
||||||
|
$initializer = new BookmarkInitializer($this);
|
||||||
|
$initializer->initialize();
|
||||||
|
|
||||||
|
if (true === $this->isLoggedIn) {
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles migration to the new database format (BookmarksArray).
|
||||||
|
*/
|
||||||
|
protected function migrate(): void
|
||||||
|
{
|
||||||
|
$bookmarkDb = new LegacyLinkDB(
|
||||||
|
$this->conf->get('resource.datastore'),
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
$updater = new LegacyUpdater(
|
||||||
|
UpdaterUtils::readUpdatesFile($this->conf->get('resource.updates')),
|
||||||
|
$bookmarkDb,
|
||||||
|
$this->conf,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
$newUpdates = $updater->update();
|
||||||
|
if (! empty($newUpdates)) {
|
||||||
|
UpdaterUtils::writeUpdatesFile(
|
||||||
|
$this->conf->get('resource.updates'),
|
||||||
|
$updater->getDoneUpdates()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
635
application/bookmark/BookmarkFilter.php
Normal file
635
application/bookmark/BookmarkFilter.php
Normal file
|
@ -0,0 +1,635 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Bookmark;
|
||||||
|
|
||||||
|
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
|
||||||
|
use Shaarli\Config\ConfigManager;
|
||||||
|
use Shaarli\Plugin\PluginManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class LinkFilter.
|
||||||
|
*
|
||||||
|
* Perform search and filter operation on link data list.
|
||||||
|
*/
|
||||||
|
class BookmarkFilter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string permalinks.
|
||||||
|
*/
|
||||||
|
public static $FILTER_HASH = 'permalink';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string text search.
|
||||||
|
*/
|
||||||
|
public static $FILTER_TEXT = 'fulltext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string tag filter.
|
||||||
|
*/
|
||||||
|
public static $FILTER_TAG = 'tags';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string filter by day.
|
||||||
|
*/
|
||||||
|
public static $DEFAULT = 'NO_FILTER';
|
||||||
|
|
||||||
|
/** @var string Visibility: all */
|
||||||
|
public static $ALL = 'all';
|
||||||
|
|
||||||
|
/** @var string Visibility: public */
|
||||||
|
public static $PUBLIC = 'public';
|
||||||
|
|
||||||
|
/** @var string Visibility: private */
|
||||||
|
public static $PRIVATE = 'private';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string Allowed characters for hashtags (regex syntax).
|
||||||
|
*/
|
||||||
|
public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Bookmark[] all available bookmarks.
|
||||||
|
*/
|
||||||
|
private $bookmarks;
|
||||||
|
|
||||||
|
/** @var ConfigManager */
|
||||||
|
protected $conf;
|
||||||
|
|
||||||
|
/** @var PluginManager */
|
||||||
|
protected $pluginManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Bookmark[] $bookmarks initialization.
|
||||||
|
*/
|
||||||
|
public function __construct($bookmarks, ConfigManager $conf, PluginManager $pluginManager)
|
||||||
|
{
|
||||||
|
$this->bookmarks = $bookmarks;
|
||||||
|
$this->conf = $conf;
|
||||||
|
$this->pluginManager = $pluginManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter bookmarks according to parameters.
|
||||||
|
*
|
||||||
|
* @param string $type Type of filter (eg. tags, permalink, etc.).
|
||||||
|
* @param mixed $request Filter content.
|
||||||
|
* @param bool $casesensitive Optional: Perform case sensitive filter if true.
|
||||||
|
* @param string $visibility Optional: return only all/private/public bookmarks
|
||||||
|
* @param bool $untaggedonly Optional: return only untagged bookmarks. Applies only if $type includes FILTER_TAG
|
||||||
|
*
|
||||||
|
* @return Bookmark[] filtered bookmark list.
|
||||||
|
*
|
||||||
|
* @throws BookmarkNotFoundException
|
||||||
|
*/
|
||||||
|
public function filter(
|
||||||
|
string $type,
|
||||||
|
$request,
|
||||||
|
bool $casesensitive = false,
|
||||||
|
string $visibility = 'all',
|
||||||
|
bool $untaggedonly = false
|
||||||
|
) {
|
||||||
|
if (!in_array($visibility, ['all', 'public', 'private'])) {
|
||||||
|
$visibility = 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($type) {
|
||||||
|
case self::$FILTER_HASH:
|
||||||
|
return $this->filterSmallHash($request);
|
||||||
|
case self::$FILTER_TAG | self::$FILTER_TEXT: // == "vuotext"
|
||||||
|
$noRequest = empty($request) || (empty($request[0]) && empty($request[1]));
|
||||||
|
if ($noRequest) {
|
||||||
|
if ($untaggedonly) {
|
||||||
|
return $this->filterUntagged($visibility);
|
||||||
|
}
|
||||||
|
return $this->noFilter($visibility);
|
||||||
|
}
|
||||||
|
if ($untaggedonly) {
|
||||||
|
$filtered = $this->filterUntagged($visibility);
|
||||||
|
} else {
|
||||||
|
$filtered = $this->bookmarks;
|
||||||
|
}
|
||||||
|
if (!empty($request[0])) {
|
||||||
|
$filtered = (new BookmarkFilter($filtered, $this->conf, $this->pluginManager))
|
||||||
|
->filterTags($request[0], $casesensitive, $visibility)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
if (!empty($request[1])) {
|
||||||
|
$filtered = (new BookmarkFilter($filtered, $this->conf, $this->pluginManager))
|
||||||
|
->filterFulltext($request[1], $visibility)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
return $filtered;
|
||||||
|
case self::$FILTER_TEXT:
|
||||||
|
return $this->filterFulltext($request, $visibility);
|
||||||
|
case self::$FILTER_TAG:
|
||||||
|
if ($untaggedonly) {
|
||||||
|
return $this->filterUntagged($visibility);
|
||||||
|
} else {
|
||||||
|
return $this->filterTags($request, $casesensitive, $visibility);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return $this->noFilter($visibility);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unknown filter, but handle private only.
|
||||||
|
*
|
||||||
|
* @param string $visibility Optional: return only all/private/public bookmarks
|
||||||
|
*
|
||||||
|
* @return Bookmark[] filtered bookmarks.
|
||||||
|
*/
|
||||||
|
private function noFilter(string $visibility = 'all')
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach ($this->bookmarks as $key => $value) {
|
||||||
|
if (
|
||||||
|
!$this->pluginManager->filterSearchEntry(
|
||||||
|
$value,
|
||||||
|
['source' => 'no_filter', 'visibility' => $visibility]
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($visibility === 'all') {
|
||||||
|
$out[$key] = $value;
|
||||||
|
} elseif ($value->isPrivate() && $visibility === 'private') {
|
||||||
|
$out[$key] = $value;
|
||||||
|
} elseif (!$value->isPrivate() && $visibility === 'public') {
|
||||||
|
$out[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the shaare corresponding to a smallHash.
|
||||||
|
*
|
||||||
|
* @param string $smallHash permalink hash.
|
||||||
|
*
|
||||||
|
* @return Bookmark[] $filtered array containing permalink data.
|
||||||
|
*
|
||||||
|
* @throws BookmarkNotFoundException if the smallhash doesn't match any link.
|
||||||
|
*/
|
||||||
|
private function filterSmallHash(string $smallHash)
|
||||||
|
{
|
||||||
|
foreach ($this->bookmarks as $key => $l) {
|
||||||
|
if ($smallHash == $l->getShortUrl()) {
|
||||||
|
// Yes, this is ugly and slow
|
||||||
|
return [$key => $l];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BookmarkNotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list of bookmarks corresponding to a full-text search
|
||||||
|
*
|
||||||
|
* Searches:
|
||||||
|
* - in the URLs, title and description;
|
||||||
|
* - are case-insensitive;
|
||||||
|
* - terms surrounded by quotes " are exact terms search.
|
||||||
|
* - terms starting with a dash - are excluded (except exact terms).
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* print_r($mydb->filterFulltext('hollandais'));
|
||||||
|
*
|
||||||
|
* mb_convert_case($val, MB_CASE_LOWER, 'UTF-8')
|
||||||
|
* - allows to perform searches on Unicode text
|
||||||
|
* - see https://github.com/shaarli/Shaarli/issues/75 for examples
|
||||||
|
*
|
||||||
|
* @param string $searchterms search query.
|
||||||
|
* @param string $visibility Optional: return only all/private/public bookmarks.
|
||||||
|
*
|
||||||
|
* @return Bookmark[] search results.
|
||||||
|
*/
|
||||||
|
private function filterFulltext(string $searchterms, string $visibility = 'all')
|
||||||
|
{
|
||||||
|
if (empty($searchterms)) {
|
||||||
|
return $this->noFilter($visibility);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filtered = [];
|
||||||
|
$search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
|
||||||
|
$exactRegex = '/"([^"]+)"/';
|
||||||
|
// Retrieve exact search terms.
|
||||||
|
preg_match_all($exactRegex, $search, $exactSearch);
|
||||||
|
$exactSearch = array_values(array_filter($exactSearch[1]));
|
||||||
|
|
||||||
|
// Remove exact search terms to get AND terms search.
|
||||||
|
$explodedSearchAnd = explode(' ', trim(preg_replace($exactRegex, '', $search)));
|
||||||
|
$explodedSearchAnd = array_values(array_filter($explodedSearchAnd));
|
||||||
|
|
||||||
|
// Filter excluding terms and update andSearch.
|
||||||
|
$excludeSearch = [];
|
||||||
|
$andSearch = [];
|
||||||
|
foreach ($explodedSearchAnd as $needle) {
|
||||||
|
if ($needle[0] == '-' && strlen($needle) > 1) {
|
||||||
|
$excludeSearch[] = substr($needle, 1);
|
||||||
|
} else {
|
||||||
|
$andSearch[] = $needle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate over every stored link.
|
||||||
|
foreach ($this->bookmarks as $id => $bookmark) {
|
||||||
|
if (
|
||||||
|
!$this->pluginManager->filterSearchEntry(
|
||||||
|
$bookmark,
|
||||||
|
[
|
||||||
|
'source' => 'fulltext',
|
||||||
|
'searchterms' => $searchterms,
|
||||||
|
'andSearch' => $andSearch,
|
||||||
|
'exactSearch' => $exactSearch,
|
||||||
|
'excludeSearch' => $excludeSearch,
|
||||||
|
'visibility' => $visibility
|
||||||
|
]
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignore non private bookmarks when 'privatonly' is on.
|
||||||
|
if ($visibility !== 'all') {
|
||||||
|
if (!$bookmark->isPrivate() && $visibility === 'private') {
|
||||||
|
continue;
|
||||||
|
} elseif ($bookmark->isPrivate() && $visibility === 'public') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$lengths = [];
|
||||||
|
$content = $this->buildFullTextSearchableLink($bookmark, $lengths);
|
||||||
|
|
||||||
|
// Be optimistic
|
||||||
|
$found = true;
|
||||||
|
$foundPositions = [];
|
||||||
|
|
||||||
|
// First, we look for exact term search
|
||||||
|
// Then iterate over keywords, if keyword is not found,
|
||||||
|
// no need to check for the others. We want all or nothing.
|
||||||
|
foreach ([$exactSearch, $andSearch] as $search) {
|
||||||
|
for ($i = 0; $i < count($search) && $found !== false; $i++) {
|
||||||
|
$found = mb_strpos($content, $search[$i]);
|
||||||
|
if ($found === false) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$foundPositions[] = ['start' => $found, 'end' => $found + mb_strlen($search[$i])];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude terms.
|
||||||
|
for ($i = 0; $i < count($excludeSearch) && $found !== false; $i++) {
|
||||||
|
$found = strpos($content, $excludeSearch[$i]) === false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($found !== false) {
|
||||||
|
$bookmark->setAdditionalContentEntry(
|
||||||
|
'search_highlight',
|
||||||
|
$this->postProcessFoundPositions($lengths, $foundPositions)
|
||||||
|
);
|
||||||
|
|
||||||
|
$filtered[$id] = $bookmark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list of bookmarks associated with a given list of tags
|
||||||
|
*
|
||||||
|
* You can specify one or more tags, separated by space or a comma, e.g.
|
||||||
|
* print_r($mydb->filterTags('linux programming'));
|
||||||
|
*
|
||||||
|
* @param string|array $tags list of tags, separated by commas or blank spaces if passed as string.
|
||||||
|
* @param bool $casesensitive ignore case if false.
|
||||||
|
* @param string $visibility Optional: return only all/private/public bookmarks.
|
||||||
|
*
|
||||||
|
* @return Bookmark[] filtered bookmarks.
|
||||||
|
*/
|
||||||
|
public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all')
|
||||||
|
{
|
||||||
|
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
|
||||||
|
// get single tags (we may get passed an array, even though the docs say different)
|
||||||
|
$inputTags = $tags;
|
||||||
|
if (!is_array($tags)) {
|
||||||
|
// we got an input string, split tags
|
||||||
|
$inputTags = tags_str2array($inputTags, $tagsSeparator);
|
||||||
|
}
|
||||||
|
if (count($inputTags) === 0) {
|
||||||
|
// no input tags
|
||||||
|
return $this->noFilter($visibility);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we only have public visibility, we can't look for hidden tags
|
||||||
|
if ($visibility === self::$PUBLIC) {
|
||||||
|
$inputTags = array_values(array_filter($inputTags, function ($tag) {
|
||||||
|
return ! startsWith($tag, '.');
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (empty($inputTags)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// build regex from all tags
|
||||||
|
$re_and = implode(array_map([$this, 'tag2regex'], $inputTags));
|
||||||
|
$re = '/^' . $re_and;
|
||||||
|
|
||||||
|
$orTags = array_filter(array_map(function ($tag) {
|
||||||
|
return startsWith($tag, '~') ? substr($tag, 1) : null;
|
||||||
|
}, $inputTags));
|
||||||
|
|
||||||
|
$re_or = implode('|', array_map([$this, 'tag2matchterm'], $orTags));
|
||||||
|
if ($re_or) {
|
||||||
|
$re_or = '(' . $re_or . ')';
|
||||||
|
$re .= $this->term2match($re_or, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$re .= '.*$/';
|
||||||
|
if (!$casesensitive) {
|
||||||
|
// make regex case insensitive
|
||||||
|
$re .= 'i';
|
||||||
|
}
|
||||||
|
|
||||||
|
// create resulting array
|
||||||
|
$filtered = [];
|
||||||
|
|
||||||
|
// iterate over each link
|
||||||
|
foreach ($this->bookmarks as $key => $bookmark) {
|
||||||
|
if (
|
||||||
|
!$this->pluginManager->filterSearchEntry(
|
||||||
|
$bookmark,
|
||||||
|
[
|
||||||
|
'source' => 'tags',
|
||||||
|
'tags' => $tags,
|
||||||
|
'casesensitive' => $casesensitive,
|
||||||
|
'visibility' => $visibility
|
||||||
|
]
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check level of visibility
|
||||||
|
// ignore non private bookmarks when 'privateonly' is on.
|
||||||
|
if ($visibility !== 'all') {
|
||||||
|
if (!$bookmark->isPrivate() && $visibility === 'private') {
|
||||||
|
continue;
|
||||||
|
} elseif ($bookmark->isPrivate() && $visibility === 'public') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// build search string, start with tags of current link
|
||||||
|
$search = $bookmark->getTagsString($tagsSeparator);
|
||||||
|
if (strlen(trim($bookmark->getDescription())) && strpos($bookmark->getDescription(), '#') !== false) {
|
||||||
|
// description given and at least one possible tag found
|
||||||
|
$descTags = [];
|
||||||
|
// find all tags in the form of #tag in the description
|
||||||
|
preg_match_all(
|
||||||
|
'/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
|
||||||
|
$bookmark->getDescription(),
|
||||||
|
$descTags
|
||||||
|
);
|
||||||
|
if (count($descTags[1])) {
|
||||||
|
// there were some tags in the description, add them to the search string
|
||||||
|
$search .= $tagsSeparator . tags_array2str($descTags[1], $tagsSeparator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// match regular expression with search string
|
||||||
|
if (!preg_match($re, $search)) {
|
||||||
|
// this entry does _not_ match our regex
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$filtered[$key] = $bookmark;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return only bookmarks without any tag.
|
||||||
|
*
|
||||||
|
* @param string $visibility return only all/private/public bookmarks.
|
||||||
|
*
|
||||||
|
* @return Bookmark[] filtered bookmarks.
|
||||||
|
*/
|
||||||
|
public function filterUntagged(string $visibility)
|
||||||
|
{
|
||||||
|
$filtered = [];
|
||||||
|
foreach ($this->bookmarks as $key => $bookmark) {
|
||||||
|
if (
|
||||||
|
!$this->pluginManager->filterSearchEntry(
|
||||||
|
$bookmark,
|
||||||
|
['source' => 'untagged', 'visibility' => $visibility]
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($visibility !== 'all') {
|
||||||
|
if (!$bookmark->isPrivate() && $visibility === 'private') {
|
||||||
|
continue;
|
||||||
|
} elseif ($bookmark->isPrivate() && $visibility === 'public') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($bookmark->getTags())) {
|
||||||
|
$filtered[$key] = $bookmark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a list of tags (str) to an array. Also
|
||||||
|
* - handle case sensitivity.
|
||||||
|
* - accepts spaces commas as separator.
|
||||||
|
*
|
||||||
|
* @param string $tags string containing a list of tags.
|
||||||
|
* @param bool $casesensitive will convert everything to lowercase if false.
|
||||||
|
*
|
||||||
|
* @return string[] filtered tags string.
|
||||||
|
*/
|
||||||
|
public static function tagsStrToArray(string $tags, bool $casesensitive): array
|
||||||
|
{
|
||||||
|
// We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
|
||||||
|
$tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8');
|
||||||
|
$tagsOut = str_replace(',', ' ', $tagsOut);
|
||||||
|
|
||||||
|
return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* generate a regex fragment out of a tag
|
||||||
|
*
|
||||||
|
* @param string $tag to generate regexs from. may start with '-'
|
||||||
|
* to negate, contain '*' as wildcard. Tags starting with '~' are
|
||||||
|
* treated separately as an 'OR' clause.
|
||||||
|
*
|
||||||
|
* @return string generated regex fragment
|
||||||
|
*/
|
||||||
|
protected function tag2regex(string $tag): string
|
||||||
|
{
|
||||||
|
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
|
||||||
|
if (!$tag || $tag === "-" || $tag === "*" || $tag[0] === "~") {
|
||||||
|
// nothing to search, return empty regex
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$negate = false;
|
||||||
|
if ($tag[0] === "+" && $tag[1]) {
|
||||||
|
$tag = substr($tag, 1); // use offset to start after '+' character
|
||||||
|
}
|
||||||
|
if ($tag[0] === "-") {
|
||||||
|
// query is negated
|
||||||
|
$tag = substr($tag, 1); // use offset to start after '-' character
|
||||||
|
$negate = true;
|
||||||
|
}
|
||||||
|
$term = $this->tag2matchterm($tag);
|
||||||
|
|
||||||
|
return $this->term2match($term, $negate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* generate a regex match term fragment out of a tag
|
||||||
|
*
|
||||||
|
* @param string $tag to to generate regexs from. This function
|
||||||
|
* assumes any leading flags ('-', '~') have been stripped. The
|
||||||
|
* wildcard flag '*' is expanded by this function and any other
|
||||||
|
* regex characters are escaped.
|
||||||
|
*
|
||||||
|
* @return string generated regex match term fragment
|
||||||
|
*/
|
||||||
|
protected function tag2matchterm(string $tag): string
|
||||||
|
{
|
||||||
|
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
|
||||||
|
$len = strlen($tag);
|
||||||
|
$term = '';
|
||||||
|
// iterate over string, separating it into placeholder and content
|
||||||
|
$i = 0; // start at first character
|
||||||
|
for (; $i < $len; $i++) {
|
||||||
|
if ($tag[$i] === '*') {
|
||||||
|
// placeholder found
|
||||||
|
$term .= '[^' . $tagsSeparator . ']*?';
|
||||||
|
} else {
|
||||||
|
// regular characters
|
||||||
|
$offset = strpos($tag, '*', $i);
|
||||||
|
if ($offset === false) {
|
||||||
|
// no placeholder found, set offset to end of string
|
||||||
|
$offset = $len;
|
||||||
|
}
|
||||||
|
// subtract one, as we want to get before the placeholder or end of string
|
||||||
|
$offset -= 1;
|
||||||
|
// we got a tag name that we want to search for. escape any regex characters to prevent conflicts.
|
||||||
|
$term .= preg_quote(substr($tag, $i, $offset - $i + 1), '/');
|
||||||
|
// move $i on
|
||||||
|
$i = $offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $term;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* generate a regex fragment out of a match term
|
||||||
|
*
|
||||||
|
* @param string $term is the match term already generated by tag2matchterm
|
||||||
|
* @param bool $negate if true create a negative lookahead
|
||||||
|
*
|
||||||
|
* @return string generated regex fragment
|
||||||
|
*/
|
||||||
|
protected function term2match(string $term, bool $negate): string
|
||||||
|
{
|
||||||
|
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
|
||||||
|
$regex = $negate ? '(?!' : '(?='; // use negative or positive lookahead
|
||||||
|
|
||||||
|
// before tag may only be the separator or the beginning
|
||||||
|
$regex .= '.*(?:^|' . $tagsSeparator . ')';
|
||||||
|
|
||||||
|
$regex .= $term;
|
||||||
|
|
||||||
|
// after the tag may only be the separator or the end
|
||||||
|
$regex .= '(?:$|' . $tagsSeparator . '))';
|
||||||
|
return $regex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method finalize the content of the foundPositions array,
|
||||||
|
* by associated all search results to their associated bookmark field,
|
||||||
|
* making sure that there is no overlapping results, etc.
|
||||||
|
*
|
||||||
|
* @param array $fieldLengths Start and end positions of every bookmark fields in the aggregated bookmark content.
|
||||||
|
* @param array $foundPositions Positions where the search results were found in the aggregated content.
|
||||||
|
*
|
||||||
|
* @return array Updated $foundPositions, by bookmark field.
|
||||||
|
*/
|
||||||
|
protected function postProcessFoundPositions(array $fieldLengths, array $foundPositions): array
|
||||||
|
{
|
||||||
|
// Sort results by starting position ASC.
|
||||||
|
usort($foundPositions, function (array $entryA, array $entryB): int {
|
||||||
|
return $entryA['start'] > $entryB['start'] ? 1 : -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
$currentMax = -1;
|
||||||
|
foreach ($foundPositions as $foundPosition) {
|
||||||
|
// we do not allow overlapping highlights
|
||||||
|
if ($foundPosition['start'] < $currentMax) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentMax = $foundPosition['end'];
|
||||||
|
foreach ($fieldLengths as $part => $length) {
|
||||||
|
if ($foundPosition['start'] < $length['start'] || $foundPosition['start'] > $length['end']) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out[$part][] = [
|
||||||
|
'start' => $foundPosition['start'] - $length['start'],
|
||||||
|
'end' => $foundPosition['end'] - $length['start'],
|
||||||
|
];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concatenate link fields to search across fields. Adds a '\' separator for exact search terms.
|
||||||
|
* Also populate $length array with starting and ending positions of every bookmark field
|
||||||
|
* inside concatenated content.
|
||||||
|
*
|
||||||
|
* @param Bookmark $link
|
||||||
|
* @param array $lengths (by reference)
|
||||||
|
*
|
||||||
|
* @return string Lowercase concatenated fields content.
|
||||||
|
*/
|
||||||
|
protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string
|
||||||
|
{
|
||||||
|
$tagString = $link->getTagsString($this->conf->get('general.tags_separator', ' '));
|
||||||
|
$content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') . '\\';
|
||||||
|
$content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') . '\\';
|
||||||
|
$content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') . '\\';
|
||||||
|
$content .= mb_convert_case($tagString, MB_CASE_LOWER, 'UTF-8') . '\\';
|
||||||
|
|
||||||
|
$lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())];
|
||||||
|
$nextField = $lengths['title']['end'] + 1;
|
||||||
|
$lengths['description'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getDescription())];
|
||||||
|
$nextField = $lengths['description']['end'] + 1;
|
||||||
|
$lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())];
|
||||||
|
$nextField = $lengths['url']['end'] + 1;
|
||||||
|
$lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($tagString)];
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
}
|
177
application/bookmark/BookmarkIO.php
Normal file
177
application/bookmark/BookmarkIO.php
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Bookmark;
|
||||||
|
|
||||||
|
use malkusch\lock\exception\LockAcquireException;
|
||||||
|
use malkusch\lock\mutex\Mutex;
|
||||||
|
use malkusch\lock\mutex\NoMutex;
|
||||||
|
use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
|
||||||
|
use Shaarli\Bookmark\Exception\EmptyDataStoreException;
|
||||||
|
use Shaarli\Bookmark\Exception\InvalidWritableDataException;
|
||||||
|
use Shaarli\Bookmark\Exception\NotEnoughSpaceException;
|
||||||
|
use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
|
||||||
|
use Shaarli\Config\ConfigManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class BookmarkIO
|
||||||
|
*
|
||||||
|
* This class performs read/write operation to the file data store.
|
||||||
|
* Used by BookmarkFileService.
|
||||||
|
*
|
||||||
|
* @package Shaarli\Bookmark
|
||||||
|
*/
|
||||||
|
class BookmarkIO
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string Datastore file path
|
||||||
|
*/
|
||||||
|
protected $datastore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var ConfigManager instance
|
||||||
|
*/
|
||||||
|
protected $conf;
|
||||||
|
|
||||||
|
|
||||||
|
/** @var Mutex */
|
||||||
|
protected $mutex;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* string Datastore PHP prefix
|
||||||
|
*/
|
||||||
|
protected static $phpPrefix = '<?php /* ';
|
||||||
|
/**
|
||||||
|
* string Datastore PHP suffix
|
||||||
|
*/
|
||||||
|
protected static $phpSuffix = ' */ ?>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LinksIO constructor.
|
||||||
|
*
|
||||||
|
* @param ConfigManager $conf instance
|
||||||
|
*/
|
||||||
|
public function __construct(ConfigManager $conf, Mutex $mutex = null)
|
||||||
|
{
|
||||||
|
if ($mutex === null) {
|
||||||
|
// This should only happen with legacy classes
|
||||||
|
$mutex = new NoMutex();
|
||||||
|
}
|
||||||
|
$this->conf = $conf;
|
||||||
|
$this->datastore = $conf->get('resource.datastore');
|
||||||
|
$this->mutex = $mutex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads database from disk to memory
|
||||||
|
*
|
||||||
|
* @return Bookmark[]
|
||||||
|
*
|
||||||
|
* @throws NotWritableDataStoreException Data couldn't be loaded
|
||||||
|
* @throws EmptyDataStoreException Datastore file exists but does not contain any bookmark
|
||||||
|
* @throws DatastoreNotInitializedException File does not exists
|
||||||
|
*/
|
||||||
|
public function read()
|
||||||
|
{
|
||||||
|
if (! file_exists($this->datastore)) {
|
||||||
|
throw new DatastoreNotInitializedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_writable($this->datastore)) {
|
||||||
|
throw new NotWritableDataStoreException($this->datastore);
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = null;
|
||||||
|
$this->synchronized(function () use (&$content) {
|
||||||
|
$content = file_get_contents($this->datastore);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note that gzinflate is faster than gzuncompress.
|
||||||
|
// See: http://www.php.net/manual/en/function.gzdeflate.php#96439
|
||||||
|
$links = unserialize(gzinflate(base64_decode(
|
||||||
|
substr($content, strlen(self::$phpPrefix), -strlen(self::$phpSuffix))
|
||||||
|
)));
|
||||||
|
|
||||||
|
if (empty($links)) {
|
||||||
|
if (filesize($this->datastore) > 100) {
|
||||||
|
throw new NotWritableDataStoreException($this->datastore);
|
||||||
|
}
|
||||||
|
throw new EmptyDataStoreException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $links;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the database from memory to disk
|
||||||
|
*
|
||||||
|
* @param Bookmark[] $links
|
||||||
|
*
|
||||||
|
* @throws NotWritableDataStoreException the datastore is not writable
|
||||||
|
* @throws InvalidWritableDataException
|
||||||
|
*/
|
||||||
|
public function write($links)
|
||||||
|
{
|
||||||
|
if (is_file($this->datastore) && !is_writeable($this->datastore)) {
|
||||||
|
// The datastore exists but is not writeable
|
||||||
|
throw new NotWritableDataStoreException($this->datastore);
|
||||||
|
} elseif (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
|
||||||
|
// The datastore does not exist and its parent directory is not writeable
|
||||||
|
throw new NotWritableDataStoreException(dirname($this->datastore));
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = base64_encode(gzdeflate(serialize($links)));
|
||||||
|
|
||||||
|
if (empty($data)) {
|
||||||
|
throw new InvalidWritableDataException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = self::$phpPrefix . $data . self::$phpSuffix;
|
||||||
|
|
||||||
|
$this->synchronized(function () use ($data) {
|
||||||
|
if (!$this->checkDiskSpace($data)) {
|
||||||
|
throw new NotEnoughSpaceException();
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents(
|
||||||
|
$this->datastore,
|
||||||
|
$data
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper applying mutex to provided function.
|
||||||
|
* If the lock can't be acquired (e.g. some shared hosting provider), we execute the function without mutex.
|
||||||
|
*
|
||||||
|
* @see https://github.com/shaarli/Shaarli/issues/1650
|
||||||
|
*
|
||||||
|
* @param callable $function
|
||||||
|
*/
|
||||||
|
protected function synchronized(callable $function): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->mutex->synchronized($function);
|
||||||
|
} catch (LockAcquireException $exception) {
|
||||||
|
$function();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make sure that there is enough disk space available to save the current data store.
|
||||||
|
* We add an arbitrary margin of 500kB.
|
||||||
|
*
|
||||||
|
* @param string $data to be saved
|
||||||
|
*
|
||||||
|
* @return bool True if data can safely be saved
|
||||||
|
*/
|
||||||
|
public function checkDiskSpace(string $data): bool
|
||||||
|
{
|
||||||
|
if (function_exists('disk_free_space') === false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return disk_free_space(dirname($this->datastore)) > (strlen($data) + 1024 * 500);
|
||||||
|
}
|
||||||
|
}
|
115
application/bookmark/BookmarkInitializer.php
Normal file
115
application/bookmark/BookmarkInitializer.php
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Bookmark;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class BookmarkInitializer
|
||||||
|
*
|
||||||
|
* This class is used to initialized default bookmarks after a fresh install of Shaarli.
|
||||||
|
* It should be only called if the datastore file does not exist(users might want to delete the default bookmarks).
|
||||||
|
*
|
||||||
|
* To prevent data corruption, it does not overwrite existing bookmarks,
|
||||||
|
* even though there should not be any.
|
||||||
|
*
|
||||||
|
* We disable this because otherwise it creates indentation issues, and heredoc is not supported by PHP gettext.
|
||||||
|
* @phpcs:disable Generic.Files.LineLength.TooLong
|
||||||
|
*
|
||||||
|
* @package Shaarli\Bookmark
|
||||||
|
*/
|
||||||
|
class BookmarkInitializer
|
||||||
|
{
|
||||||
|
/** @var BookmarkServiceInterface */
|
||||||
|
protected $bookmarkService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BookmarkInitializer constructor.
|
||||||
|
*
|
||||||
|
* @param BookmarkServiceInterface $bookmarkService
|
||||||
|
*/
|
||||||
|
public function __construct(BookmarkServiceInterface $bookmarkService)
|
||||||
|
{
|
||||||
|
$this->bookmarkService = $bookmarkService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the data store with default bookmarks
|
||||||
|
*/
|
||||||
|
public function initialize(): void
|
||||||
|
{
|
||||||
|
$bookmark = new Bookmark();
|
||||||
|
$bookmark->setTitle('Calm Jazz Music - YouTube ' . t('(private bookmark with thumbnail demo)'));
|
||||||
|
$bookmark->setUrl('https://www.youtube.com/watch?v=DVEUcbPkb-c');
|
||||||
|
$bookmark->setDescription(t(
|
||||||
|
'Shaarli will automatically pick up the thumbnail for links to a variety of websites.
|
||||||
|
|
||||||
|
Explore your new Shaarli instance by trying out controls and menus.
|
||||||
|
Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli.
|
||||||
|
|
||||||
|
Now you can edit or delete the default shaares.
|
||||||
|
'
|
||||||
|
));
|
||||||
|
$bookmark->setTagsString('shaarli help thumbnail');
|
||||||
|
$bookmark->setPrivate(true);
|
||||||
|
$this->bookmarkService->add($bookmark, false);
|
||||||
|
|
||||||
|
$bookmark = new Bookmark();
|
||||||
|
$bookmark->setTitle(t('Note: Shaare descriptions'));
|
||||||
|
$bookmark->setDescription(t(
|
||||||
|
'Adding a shaare without entering a URL creates a text-only "note" post such as this one.
|
||||||
|
This note is private, so you are the only one able to see it while logged in.
|
||||||
|
|
||||||
|
You can use this to keep notes, post articles, code snippets, and much more.
|
||||||
|
|
||||||
|
The Markdown formatting setting allows you to format your notes and bookmark description:
|
||||||
|
|
||||||
|
### Title headings
|
||||||
|
|
||||||
|
#### Multiple headings levels
|
||||||
|
* bullet lists
|
||||||
|
* _italic_ text
|
||||||
|
* **bold** text
|
||||||
|
* ~~strike through~~ text
|
||||||
|
* `code` blocks
|
||||||
|
* images
|
||||||
|
* [links](https://en.wikipedia.org/wiki/Markdown)
|
||||||
|
|
||||||
|
Markdown also supports tables:
|
||||||
|
|
||||||
|
| Name | Type | Color | Qty |
|
||||||
|
| ------- | --------- | ------ | ----- |
|
||||||
|
| Orange | Fruit | Orange | 126 |
|
||||||
|
| Apple | Fruit | Any | 62 |
|
||||||
|
| Lemon | Fruit | Yellow | 30 |
|
||||||
|
| Carrot | Vegetable | Red | 14 |
|
||||||
|
'
|
||||||
|
));
|
||||||
|
$bookmark->setTagsString('shaarli help');
|
||||||
|
$bookmark->setPrivate(true);
|
||||||
|
$this->bookmarkService->add($bookmark, false);
|
||||||
|
|
||||||
|
$bookmark = new Bookmark();
|
||||||
|
$bookmark->setTitle(
|
||||||
|
'Shaarli - ' . t('The personal, minimalist, super fast, database-free, bookmarking service')
|
||||||
|
);
|
||||||
|
$bookmark->setDescription(t(
|
||||||
|
'Welcome to Shaarli!
|
||||||
|
|
||||||
|
Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately.
|
||||||
|
You can add a description to your bookmarks, such as this one, and tag them.
|
||||||
|
|
||||||
|
Create a new shaare by clicking the `+Shaare` button, or using any of the recommended tools (browser extension, mobile app, bookmarklet, REST API, etc.).
|
||||||
|
|
||||||
|
You can easily retrieve your links, even with thousands of them, using the internal search engine, or search through tags (e.g. this Shaare is tagged with `shaarli` and `help`).
|
||||||
|
Hashtags such as #shaarli #help are also supported.
|
||||||
|
You can also filter the available [RSS feed](/feed/atom) and picture wall by tag or plaintext search.
|
||||||
|
|
||||||
|
We hope that you will enjoy using Shaarli, maintained with ❤️ by the community!
|
||||||
|
Feel free to open [an issue](https://github.com/shaarli/Shaarli/issues) if you have a suggestion or encounter an issue.
|
||||||
|
'
|
||||||
|
));
|
||||||
|
$bookmark->setTagsString('shaarli help');
|
||||||
|
$this->bookmarkService->add($bookmark, false);
|
||||||
|
}
|
||||||
|
}
|
189
application/bookmark/BookmarkServiceInterface.php
Normal file
189
application/bookmark/BookmarkServiceInterface.php
Normal file
|
@ -0,0 +1,189 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Bookmark;
|
||||||
|
|
||||||
|
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
|
||||||
|
use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class BookmarksService
|
||||||
|
*
|
||||||
|
* This is the entry point to manipulate the bookmark DB.
|
||||||
|
*
|
||||||
|
* Regarding return types of a list of bookmarks, it can either be an array or an ArrayAccess implementation,
|
||||||
|
* so until PHP 8.0 is the minimal supported version with union return types it cannot be explicitly added.
|
||||||
|
*/
|
||||||
|
interface BookmarkServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Find a bookmark by hash
|
||||||
|
*
|
||||||
|
* @param string $hash Bookmark's hash
|
||||||
|
* @param string|null $privateKey Optional key used to access private links while logged out
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function findByHash(string $hash, string $privateKey = null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param $url
|
||||||
|
*
|
||||||
|
* @return Bookmark|null
|
||||||
|
*/
|
||||||
|
public function findByUrl(string $url): ?Bookmark;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search bookmarks
|
||||||
|
*
|
||||||
|
* @param array $request
|
||||||
|
* @param ?string $visibility
|
||||||
|
* @param bool $caseSensitive
|
||||||
|
* @param bool $untaggedOnly
|
||||||
|
* @param bool $ignoreSticky
|
||||||
|
* @param array $pagination This array can contain the following keys for pagination: limit, offset.
|
||||||
|
*
|
||||||
|
* @return SearchResult
|
||||||
|
*/
|
||||||
|
public function search(
|
||||||
|
array $request = [],
|
||||||
|
string $visibility = null,
|
||||||
|
bool $caseSensitive = false,
|
||||||
|
bool $untaggedOnly = false,
|
||||||
|
bool $ignoreSticky = false,
|
||||||
|
array $pagination = []
|
||||||
|
): SearchResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single bookmark by its ID.
|
||||||
|
*
|
||||||
|
* @param int $id Bookmark ID
|
||||||
|
* @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
|
||||||
|
* exception
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*
|
||||||
|
* @throws BookmarkNotFoundException
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function get(int $id, string $visibility = null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing bookmark (depending on its ID).
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark
|
||||||
|
* @param bool $save Writes to the datastore if set to true
|
||||||
|
*
|
||||||
|
* @return Bookmark Updated bookmark
|
||||||
|
*
|
||||||
|
* @throws BookmarkNotFoundException
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function set(Bookmark $bookmark, bool $save = true): Bookmark;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a new bookmark (the ID must be empty).
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark
|
||||||
|
* @param bool $save Writes to the datastore if set to true
|
||||||
|
*
|
||||||
|
* @return Bookmark new bookmark
|
||||||
|
*
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function add(Bookmark $bookmark, bool $save = true): Bookmark;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds or updates a bookmark depending on its ID:
|
||||||
|
* - a Bookmark without ID will be added
|
||||||
|
* - a Bookmark with an existing ID will be updated
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark
|
||||||
|
* @param bool $save
|
||||||
|
*
|
||||||
|
* @return Bookmark
|
||||||
|
*
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a bookmark.
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark
|
||||||
|
* @param bool $save
|
||||||
|
*
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function remove(Bookmark $bookmark, bool $save = true): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single bookmark by its ID.
|
||||||
|
*
|
||||||
|
* @param int $id Bookmark ID
|
||||||
|
* @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
|
||||||
|
* exception
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function exists(int $id, string $visibility = null): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the number of available bookmarks for given visibility.
|
||||||
|
*
|
||||||
|
* @param ?string $visibility public|private|all
|
||||||
|
*
|
||||||
|
* @return int Number of bookmarks
|
||||||
|
*/
|
||||||
|
public function count(string $visibility = null): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the datastore.
|
||||||
|
*
|
||||||
|
* @throws NotWritableDataStoreException
|
||||||
|
*/
|
||||||
|
public function save(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list tags appearing in the bookmarks with the given tags
|
||||||
|
*
|
||||||
|
* @param array|null $filteringTags tags selecting the bookmarks to consider
|
||||||
|
* @param string|null $visibility process only all/private/public bookmarks
|
||||||
|
*
|
||||||
|
* @return array tag => bookmarksCount
|
||||||
|
*/
|
||||||
|
public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a list of bookmark matching provided period of time.
|
||||||
|
* It also update directly previous and next date outside of given period found in the datastore.
|
||||||
|
*
|
||||||
|
* @param \DateTimeInterface $from Starting date.
|
||||||
|
* @param \DateTimeInterface $to Ending date.
|
||||||
|
* @param \DateTimeInterface|null $previous (by reference) updated with first created date found before $from.
|
||||||
|
* @param \DateTimeInterface|null $next (by reference) updated with first created date found after $to.
|
||||||
|
*
|
||||||
|
* @return array List of bookmarks matching provided period of time.
|
||||||
|
*/
|
||||||
|
public function findByDate(
|
||||||
|
\DateTimeInterface $from,
|
||||||
|
\DateTimeInterface $to,
|
||||||
|
?\DateTimeInterface &$previous,
|
||||||
|
?\DateTimeInterface &$next
|
||||||
|
): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the latest bookmark by creation date.
|
||||||
|
*
|
||||||
|
* @return Bookmark|null Found Bookmark or null if the datastore is empty.
|
||||||
|
*/
|
||||||
|
public function getLatest(): ?Bookmark;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the default database after a fresh install.
|
||||||
|
*/
|
||||||
|
public function initialize(): void;
|
||||||
|
}
|
253
application/bookmark/LinkUtils.php
Normal file
253
application/bookmark/LinkUtils.php
Normal 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 where relevant so that multiple spaces are properly displayed in HTML
|
||||||
|
* even in the absence of <pre> (This is used in description to keep text formatting).
|
||||||
|
*
|
||||||
|
* @param string $text input text.
|
||||||
|
*
|
||||||
|
* @return string formatted text.
|
||||||
|
*/
|
||||||
|
function space2nbsp($text)
|
||||||
|
{
|
||||||
|
return preg_replace('/(^| ) /m', '$1 ', $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Shaarli's description
|
||||||
|
*
|
||||||
|
* @param string $description shaare's description.
|
||||||
|
* @param string $indexUrl URL to Shaarli's index.
|
||||||
|
* @param bool $autolink Turn on/off automatic linkifications of URLs and hashtags
|
||||||
|
*
|
||||||
|
* @return string formatted description.
|
||||||
|
*/
|
||||||
|
function format_description($description, $indexUrl = '', $autolink = true)
|
||||||
|
{
|
||||||
|
if ($autolink) {
|
||||||
|
$description = hashtag_autolink(text2clickable($description), $indexUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return nl2br(space2nbsp($description));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a small hash for a link.
|
||||||
|
*
|
||||||
|
* @param DateTime $date Link creation date.
|
||||||
|
* @param int $id Link ID.
|
||||||
|
*
|
||||||
|
* @return string the small hash generated from link data.
|
||||||
|
*/
|
||||||
|
function link_small_hash($date, $id)
|
||||||
|
{
|
||||||
|
return smallHash($date->format(Bookmark::LINK_DATE_FORMAT) . $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether or not the link is an internal note.
|
||||||
|
* Its URL starts by `?` because it's actually a permalink.
|
||||||
|
*
|
||||||
|
* @param string $linkUrl
|
||||||
|
*
|
||||||
|
* @return bool true if internal note, false otherwise.
|
||||||
|
*/
|
||||||
|
function is_note($linkUrl)
|
||||||
|
{
|
||||||
|
return isset($linkUrl[0]) && $linkUrl[0] === '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract an array of tags from a given tag string, with provided separator.
|
||||||
|
*
|
||||||
|
* @param string|null $tags String containing a list of tags separated by $separator.
|
||||||
|
* @param string $separator Shaarli's default: ' ' (whitespace)
|
||||||
|
*
|
||||||
|
* @return array List of tags
|
||||||
|
*/
|
||||||
|
function tags_str2array(?string $tags, string $separator): array
|
||||||
|
{
|
||||||
|
// For whitespaces, we use the special \s regex character
|
||||||
|
$separator = str_replace([' ', '/'], ['\s', '\/'], $separator);
|
||||||
|
|
||||||
|
return preg_split('/\s*' . $separator . '+\s*/', trim($tags ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a tag string with provided separator from a list of tags.
|
||||||
|
* Note that given array is clean up by tags_filter().
|
||||||
|
*
|
||||||
|
* @param array|null $tags List of tags
|
||||||
|
* @param string $separator
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
function tags_array2str(?array $tags, string $separator): string
|
||||||
|
{
|
||||||
|
return implode($separator, tags_filter($tags, $separator));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean an array of tags: trim + remove empty entries
|
||||||
|
*
|
||||||
|
* @param array|null $tags List of tags
|
||||||
|
* @param string $separator
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
function tags_filter(?array $tags, string $separator): array
|
||||||
|
{
|
||||||
|
$trimDefault = " \t\n\r\0\x0B";
|
||||||
|
return array_values(array_filter(array_map(function (string $entry) use ($separator, $trimDefault): string {
|
||||||
|
return trim($entry, $trimDefault . $separator);
|
||||||
|
}, $tags ?? [])));
|
||||||
|
}
|
136
application/bookmark/SearchResult.php
Normal file
136
application/bookmark/SearchResult.php
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Bookmark;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only class used to represent search result, including pagination.
|
||||||
|
*/
|
||||||
|
class SearchResult
|
||||||
|
{
|
||||||
|
/** @var Bookmark[] List of result bookmarks with pagination applied */
|
||||||
|
protected $bookmarks;
|
||||||
|
|
||||||
|
/** @var int number of Bookmarks found, with pagination applied */
|
||||||
|
protected $resultCount;
|
||||||
|
|
||||||
|
/** @var int total number of result found */
|
||||||
|
protected $totalCount;
|
||||||
|
|
||||||
|
/** @var int pagination: limit number of result bookmarks */
|
||||||
|
protected $limit;
|
||||||
|
|
||||||
|
/** @var int pagination: offset to apply to complete result list */
|
||||||
|
protected $offset;
|
||||||
|
|
||||||
|
public function __construct(array $bookmarks, int $totalCount, int $offset, ?int $limit)
|
||||||
|
{
|
||||||
|
$this->bookmarks = $bookmarks;
|
||||||
|
$this->resultCount = count($bookmarks);
|
||||||
|
$this->totalCount = $totalCount;
|
||||||
|
$this->limit = $limit;
|
||||||
|
$this->offset = $offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a SearchResult from provided full result set and pagination settings.
|
||||||
|
*
|
||||||
|
* @param Bookmark[] $bookmarks Full set of result which will be filtered
|
||||||
|
* @param int $offset Start recording results from $offset
|
||||||
|
* @param int|null $limit End recording results after $limit bookmarks is reached
|
||||||
|
* @param bool $allowOutOfBounds Set to false to display the last page if the offset is out of bound,
|
||||||
|
* return empty result set otherwise (default: false)
|
||||||
|
*
|
||||||
|
* @return SearchResult
|
||||||
|
*/
|
||||||
|
public static function getSearchResult(
|
||||||
|
$bookmarks,
|
||||||
|
int $offset = 0,
|
||||||
|
?int $limit = null,
|
||||||
|
bool $allowOutOfBounds = false
|
||||||
|
): self {
|
||||||
|
$totalCount = count($bookmarks);
|
||||||
|
if (!$allowOutOfBounds && $offset > $totalCount) {
|
||||||
|
$offset = $limit === null ? 0 : $limit * -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bookmarks instanceof BookmarkArray) {
|
||||||
|
$buffer = [];
|
||||||
|
foreach ($bookmarks as $key => $value) {
|
||||||
|
$buffer[$key] = $value;
|
||||||
|
}
|
||||||
|
$bookmarks = $buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new static(
|
||||||
|
array_slice($bookmarks, $offset, $limit, true),
|
||||||
|
$totalCount,
|
||||||
|
$offset,
|
||||||
|
$limit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Bookmark[] List of result bookmarks with pagination applied */
|
||||||
|
public function getBookmarks(): array
|
||||||
|
{
|
||||||
|
return $this->bookmarks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return int number of Bookmarks found, with pagination applied */
|
||||||
|
public function getResultCount(): int
|
||||||
|
{
|
||||||
|
return $this->resultCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return int total number of result found */
|
||||||
|
public function getTotalCount(): int
|
||||||
|
{
|
||||||
|
return $this->totalCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return int pagination: limit number of result bookmarks */
|
||||||
|
public function getLimit(): ?int
|
||||||
|
{
|
||||||
|
return $this->limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return int pagination: offset to apply to complete result list */
|
||||||
|
public function getOffset(): int
|
||||||
|
{
|
||||||
|
return $this->offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return int Current page of result set in complete results */
|
||||||
|
public function getPage(): int
|
||||||
|
{
|
||||||
|
if (empty($this->limit)) {
|
||||||
|
return $this->offset === 0 ? 1 : 2;
|
||||||
|
}
|
||||||
|
$base = $this->offset >= 0 ? $this->offset : $this->totalCount + $this->offset;
|
||||||
|
|
||||||
|
return (int) ceil($base / $this->limit) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return int Get the # of the last page */
|
||||||
|
public function getLastPage(): int
|
||||||
|
{
|
||||||
|
if (empty($this->limit)) {
|
||||||
|
return $this->offset === 0 ? 1 : 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) ceil($this->totalCount / $this->limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return bool Either the current page is the last one or not */
|
||||||
|
public function isLastPage(): bool
|
||||||
|
{
|
||||||
|
return $this->getPage() === $this->getLastPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return bool Either the current page is the first one or not */
|
||||||
|
public function isFirstPage(): bool
|
||||||
|
{
|
||||||
|
return $this->offset === 0;
|
||||||
|
}
|
||||||
|
}
|
16
application/bookmark/exception/BookmarkNotFoundException.php
Normal file
16
application/bookmark/exception/BookmarkNotFoundException.php
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Bookmark\Exception;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class BookmarkNotFoundException extends Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* LinkNotFoundException constructor.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->message = t('The link you are trying to reach does not exist or has been deleted.');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Bookmark\Exception;
|
||||||
|
|
||||||
|
class DatastoreNotInitializedException extends \Exception
|
||||||
|
{
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Bookmark\Exception;
|
||||||
|
|
||||||
|
class EmptyDataStoreException extends \Exception
|
||||||
|
{
|
||||||
|
}
|
30
application/bookmark/exception/InvalidBookmarkException.php
Normal file
30
application/bookmark/exception/InvalidBookmarkException.php
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Bookmark\Exception;
|
||||||
|
|
||||||
|
use Shaarli\Bookmark\Bookmark;
|
||||||
|
|
||||||
|
class InvalidBookmarkException extends \Exception
|
||||||
|
{
|
||||||
|
public function __construct($bookmark)
|
||||||
|
{
|
||||||
|
if ($bookmark instanceof Bookmark) {
|
||||||
|
if ($bookmark->getCreated() instanceof \DateTime) {
|
||||||
|
$created = $bookmark->getCreated()->format(\DateTime::ATOM);
|
||||||
|
} elseif (empty($bookmark->getCreated())) {
|
||||||
|
$created = '';
|
||||||
|
} else {
|
||||||
|
$created = 'Not a DateTime object';
|
||||||
|
}
|
||||||
|
$this->message = 'This bookmark is not valid' . PHP_EOL;
|
||||||
|
$this->message .= ' - ID: ' . $bookmark->getId() . PHP_EOL;
|
||||||
|
$this->message .= ' - Title: ' . $bookmark->getTitle() . PHP_EOL;
|
||||||
|
$this->message .= ' - Url: ' . $bookmark->getUrl() . PHP_EOL;
|
||||||
|
$this->message .= ' - ShortUrl: ' . $bookmark->getShortUrl() . PHP_EOL;
|
||||||
|
$this->message .= ' - Created: ' . $created . PHP_EOL;
|
||||||
|
} else {
|
||||||
|
$this->message = 'The provided data is not a bookmark' . PHP_EOL;
|
||||||
|
$this->message .= var_export($bookmark, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Bookmark\Exception;
|
||||||
|
|
||||||
|
class InvalidWritableDataException extends \Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* InvalidWritableDataException constructor.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->message = 'Couldn\'t generate bookmark data to store in the datastore. Skipping file writing.';
|
||||||
|
}
|
||||||
|
}
|
14
application/bookmark/exception/NotEnoughSpaceException.php
Normal file
14
application/bookmark/exception/NotEnoughSpaceException.php
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Bookmark\Exception;
|
||||||
|
|
||||||
|
class NotEnoughSpaceException extends \Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* NotEnoughSpaceException constructor.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->message = 'Not enough available disk space to save the datastore.';
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Bookmark\Exception;
|
||||||
|
|
||||||
|
class NotWritableDataStoreException extends \Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* NotReadableDataStore constructor.
|
||||||
|
*
|
||||||
|
* @param string $dataStore file path
|
||||||
|
*/
|
||||||
|
public function __construct($dataStore)
|
||||||
|
{
|
||||||
|
$this->message = 'Couldn\'t load data from the data store file "' . $dataStore . '". ' .
|
||||||
|
'Your data might be corrupted, or your file isn\'t readable.';
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Config;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface ConfigIO
|
* Interface ConfigIO
|
||||||
*
|
*
|
||||||
|
@ -14,7 +16,7 @@ interface ConfigIO
|
||||||
*
|
*
|
||||||
* @return array All configuration in an array.
|
* @return array All configuration in an array.
|
||||||
*/
|
*/
|
||||||
function read($filepath);
|
public function read($filepath);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write configuration.
|
* Write configuration.
|
||||||
|
@ -22,12 +24,12 @@ function read($filepath);
|
||||||
* @param string $filepath Config file absolute path.
|
* @param string $filepath Config file absolute path.
|
||||||
* @param array $conf All configuration in an array.
|
* @param array $conf All configuration in an array.
|
||||||
*/
|
*/
|
||||||
function write($filepath, $conf);
|
public function write($filepath, $conf);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get config file extension according to config type.
|
* Get config file extension according to config type.
|
||||||
*
|
*
|
||||||
* @return string Config file extension.
|
* @return string Config file extension.
|
||||||
*/
|
*/
|
||||||
function getExtension();
|
public function getExtension();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
namespace Shaarli\Config;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class ConfigJson (ConfigIO implementation)
|
* Class ConfigJson (ConfigIO implementation)
|
||||||
|
@ -10,7 +11,7 @@ class ConfigJson implements ConfigIO
|
||||||
/**
|
/**
|
||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
function read($filepath)
|
public function read($filepath)
|
||||||
{
|
{
|
||||||
if (! is_readable($filepath)) {
|
if (! is_readable($filepath)) {
|
||||||
return array();
|
return array();
|
||||||
|
@ -18,10 +19,21 @@ function read($filepath)
|
||||||
$data = file_get_contents($filepath);
|
$data = file_get_contents($filepath);
|
||||||
$data = str_replace(self::getPhpHeaders(), '', $data);
|
$data = str_replace(self::getPhpHeaders(), '', $data);
|
||||||
$data = str_replace(self::getPhpSuffix(), '', $data);
|
$data = str_replace(self::getPhpSuffix(), '', $data);
|
||||||
$data = json_decode($data, true);
|
$data = json_decode(trim($data), true);
|
||||||
if ($data === null) {
|
if ($data === null) {
|
||||||
$error = json_last_error();
|
$errorCode = json_last_error();
|
||||||
throw new Exception('An error occurred while parsing JSON file: error code #'. $error);
|
$error = sprintf(
|
||||||
|
'An error occurred while parsing JSON configuration file (%s): error code #%d',
|
||||||
|
$filepath,
|
||||||
|
$errorCode
|
||||||
|
);
|
||||||
|
$error .= '<br>➜ <code>' . json_last_error_msg() .'</code>';
|
||||||
|
if ($errorCode === JSON_ERROR_SYNTAX) {
|
||||||
|
$error .= '<br>';
|
||||||
|
$error .= 'Please check your JSON syntax (without PHP comment tags) using a JSON lint tool such as ';
|
||||||
|
$error .= '<a href="http://jsonlint.com/">jsonlint.com</a>.';
|
||||||
|
}
|
||||||
|
throw new \Exception($error);
|
||||||
}
|
}
|
||||||
return $data;
|
return $data;
|
||||||
}
|
}
|
||||||
|
@ -29,16 +41,16 @@ function read($filepath)
|
||||||
/**
|
/**
|
||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
function write($filepath, $conf)
|
public function write($filepath, $conf)
|
||||||
{
|
{
|
||||||
// JSON_PRETTY_PRINT is available from PHP 5.4.
|
// JSON_PRETTY_PRINT is available from PHP 5.4.
|
||||||
$print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0;
|
$print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0;
|
||||||
$data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix();
|
$data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix();
|
||||||
if (!file_put_contents($filepath, $data)) {
|
if (empty($filepath) || !file_put_contents($filepath, $data)) {
|
||||||
throw new IOException(
|
throw new \Shaarli\Exceptions\IOException(
|
||||||
$filepath,
|
$filepath,
|
||||||
'Shaarli could not create the config file.
|
t('Shaarli could not create the config file. '.
|
||||||
Please make sure Shaarli has the right to write in the folder is it installed in.'
|
'Please make sure Shaarli has the right to write in the folder is it installed in.')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -46,7 +58,7 @@ function write($filepath, $conf)
|
||||||
/**
|
/**
|
||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
function getExtension()
|
public function getExtension()
|
||||||
{
|
{
|
||||||
return '.json.php';
|
return '.json.php';
|
||||||
}
|
}
|
||||||
|
@ -61,7 +73,7 @@ function getExtension()
|
||||||
*/
|
*/
|
||||||
public static function getPhpHeaders()
|
public static function getPhpHeaders()
|
||||||
{
|
{
|
||||||
return '<?php /*'. PHP_EOL;
|
return '<?php /*';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -73,6 +85,6 @@ public static function getPhpHeaders()
|
||||||
*/
|
*/
|
||||||
public static function getPhpSuffix()
|
public static function getPhpSuffix()
|
||||||
{
|
{
|
||||||
return PHP_EOL . '*/ ?>';
|
return '*/ ?>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,18 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
// FIXME! Namespaces...
|
namespace Shaarli\Config;
|
||||||
require_once 'ConfigIO.php';
|
|
||||||
require_once 'ConfigJson.php';
|
use Shaarli\Config\Exception\MissingFieldConfigException;
|
||||||
require_once 'ConfigPhp.php';
|
use Shaarli\Config\Exception\UnauthorizedConfigException;
|
||||||
|
use Shaarli\Thumbnailer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class ConfigManager
|
* Class ConfigManager
|
||||||
*
|
*
|
||||||
* Manages all Shaarli's settings.
|
* Manages all Shaarli's settings.
|
||||||
* See the documentation for more information on settings:
|
* See the documentation for more information on settings:
|
||||||
* - doc/Shaarli-configuration.html
|
* - doc/md/Shaarli-configuration.md
|
||||||
* - https://github.com/shaarli/Shaarli/wiki/Shaarli-configuration
|
* - https://shaarli.readthedocs.io/en/master/Shaarli-configuration/#configuration
|
||||||
*/
|
*/
|
||||||
class ConfigManager
|
class ConfigManager
|
||||||
{
|
{
|
||||||
|
@ -20,6 +21,8 @@ class ConfigManager
|
||||||
*/
|
*/
|
||||||
protected static $NOT_FOUND = 'NOT_FOUND';
|
protected static $NOT_FOUND = 'NOT_FOUND';
|
||||||
|
|
||||||
|
public static $DEFAULT_PLUGINS = ['qrcode'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string Config folder.
|
* @var string Config folder.
|
||||||
*/
|
*/
|
||||||
|
@ -80,7 +83,11 @@ protected function initialize()
|
||||||
*/
|
*/
|
||||||
protected function load()
|
protected function load()
|
||||||
{
|
{
|
||||||
|
try {
|
||||||
$this->loadedConfig = $this->configIO->read($this->getConfigFileExt());
|
$this->loadedConfig = $this->configIO->read($this->getConfigFileExt());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
die($e->getMessage());
|
||||||
|
}
|
||||||
$this->setDefaultValues();
|
$this->setDefaultValues();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -118,16 +125,16 @@ public function get($setting, $default = '')
|
||||||
* Supports nested settings with dot separated keys.
|
* Supports nested settings with dot separated keys.
|
||||||
*
|
*
|
||||||
* @param string $setting Asked setting, keys separated with dots.
|
* @param string $setting Asked setting, keys separated with dots.
|
||||||
* @param string $value Value to set.
|
* @param mixed $value Value to set.
|
||||||
* @param bool $write Write the new setting in the config file, default false.
|
* @param bool $write Write the new setting in the config file, default false.
|
||||||
* @param bool $isLoggedIn User login state, default false.
|
* @param bool $isLoggedIn User login state, default false.
|
||||||
*
|
*
|
||||||
* @throws Exception Invalid
|
* @throws \Exception Invalid
|
||||||
*/
|
*/
|
||||||
public function set($setting, $value, $write = false, $isLoggedIn = false)
|
public function set($setting, $value, $write = false, $isLoggedIn = false)
|
||||||
{
|
{
|
||||||
if (empty($setting) || ! is_string($setting)) {
|
if (empty($setting) || ! is_string($setting)) {
|
||||||
throw new Exception('Invalid setting key parameter. String expected, got: '. gettype($setting));
|
throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting));
|
||||||
}
|
}
|
||||||
|
|
||||||
// During the ConfigIO transition, map legacy settings to the new ones.
|
// During the ConfigIO transition, map legacy settings to the new ones.
|
||||||
|
@ -142,6 +149,33 @@ public function set($setting, $value, $write = false, $isLoggedIn = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a config element from the config file.
|
||||||
|
*
|
||||||
|
* @param string $setting Asked setting, keys separated with dots.
|
||||||
|
* @param bool $write Write the new setting in the config file, default false.
|
||||||
|
* @param bool $isLoggedIn User login state, default false.
|
||||||
|
*
|
||||||
|
* @throws \Exception Invalid
|
||||||
|
*/
|
||||||
|
public function remove($setting, $write = false, $isLoggedIn = false)
|
||||||
|
{
|
||||||
|
if (empty($setting) || ! is_string($setting)) {
|
||||||
|
throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting));
|
||||||
|
}
|
||||||
|
|
||||||
|
// During the ConfigIO transition, map legacy settings to the new ones.
|
||||||
|
if ($this->configIO instanceof ConfigPhp && isset(ConfigPhp::$LEGACY_KEYS_MAPPING[$setting])) {
|
||||||
|
$setting = ConfigPhp::$LEGACY_KEYS_MAPPING[$setting];
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = explode('.', $setting);
|
||||||
|
self::removeConfig($settings, $this->loadedConfig);
|
||||||
|
if ($write) {
|
||||||
|
$this->write($isLoggedIn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a settings exists.
|
* Check if a settings exists.
|
||||||
*
|
*
|
||||||
|
@ -175,12 +209,12 @@ public function exists($setting)
|
||||||
*
|
*
|
||||||
* @throws MissingFieldConfigException: a mandatory field has not been provided in $conf.
|
* @throws MissingFieldConfigException: a mandatory field has not been provided in $conf.
|
||||||
* @throws UnauthorizedConfigException: user is not authorize to change configuration.
|
* @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)
|
public function write($isLoggedIn)
|
||||||
{
|
{
|
||||||
// These fields are required in configuration.
|
// These fields are required in configuration.
|
||||||
$mandatoryFields = array(
|
$mandatoryFields = [
|
||||||
'credentials.login',
|
'credentials.login',
|
||||||
'credentials.hash',
|
'credentials.hash',
|
||||||
'credentials.salt',
|
'credentials.salt',
|
||||||
|
@ -189,8 +223,7 @@ public function write($isLoggedIn)
|
||||||
'general.title',
|
'general.title',
|
||||||
'general.header_link',
|
'general.header_link',
|
||||||
'privacy.default_private_links',
|
'privacy.default_private_links',
|
||||||
'redirector.url',
|
];
|
||||||
);
|
|
||||||
|
|
||||||
// Only logged in user can alter config.
|
// Only logged in user can alter config.
|
||||||
if (is_file($this->getConfigFileExt()) && !$isLoggedIn) {
|
if (is_file($this->getConfigFileExt()) && !$isLoggedIn) {
|
||||||
|
@ -284,6 +317,27 @@ protected static function setConfig($settings, $value, &$conf)
|
||||||
$conf[$setting] = $value;
|
$conf[$setting] = $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursive function which find asked setting in the loaded config and deletes it.
|
||||||
|
*
|
||||||
|
* @param array $settings Ordered array which contains keys to find.
|
||||||
|
* @param array $conf Loaded settings, then sub-array.
|
||||||
|
*
|
||||||
|
* @return mixed Found setting or NOT_FOUND flag.
|
||||||
|
*/
|
||||||
|
protected static function removeConfig($settings, &$conf)
|
||||||
|
{
|
||||||
|
if (!is_array($settings) || count($settings) == 0) {
|
||||||
|
return self::$NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
$setting = array_shift($settings);
|
||||||
|
if (count($settings) > 0) {
|
||||||
|
return self::removeConfig($settings, $conf[$setting]);
|
||||||
|
}
|
||||||
|
unset($conf[$setting]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a bunch of default values allowing Shaarli to start without a config file.
|
* Set a bunch of default values allowing Shaarli to start without a config file.
|
||||||
*/
|
*/
|
||||||
|
@ -296,6 +350,7 @@ protected function setDefaultValues()
|
||||||
$this->setEmpty('resource.updates', 'data/updates.txt');
|
$this->setEmpty('resource.updates', 'data/updates.txt');
|
||||||
$this->setEmpty('resource.log', 'data/log.txt');
|
$this->setEmpty('resource.log', 'data/log.txt');
|
||||||
$this->setEmpty('resource.update_check', 'data/lastupdatecheck.txt');
|
$this->setEmpty('resource.update_check', 'data/lastupdatecheck.txt');
|
||||||
|
$this->setEmpty('resource.history', 'data/history.php');
|
||||||
$this->setEmpty('resource.raintpl_tpl', 'tpl/');
|
$this->setEmpty('resource.raintpl_tpl', 'tpl/');
|
||||||
$this->setEmpty('resource.theme', 'default');
|
$this->setEmpty('resource.theme', 'default');
|
||||||
$this->setEmpty('resource.raintpl_tmp', 'tmp/');
|
$this->setEmpty('resource.raintpl_tmp', 'tmp/');
|
||||||
|
@ -306,29 +361,41 @@ protected function setDefaultValues()
|
||||||
$this->setEmpty('security.ban_duration', 1800);
|
$this->setEmpty('security.ban_duration', 1800);
|
||||||
$this->setEmpty('security.session_protection_disabled', false);
|
$this->setEmpty('security.session_protection_disabled', false);
|
||||||
$this->setEmpty('security.open_shaarli', false);
|
$this->setEmpty('security.open_shaarli', false);
|
||||||
|
$this->setEmpty('security.allowed_protocols', ['ftp', 'ftps', 'magnet']);
|
||||||
|
|
||||||
$this->setEmpty('general.header_link', '?');
|
$this->setEmpty('general.header_link', '/');
|
||||||
$this->setEmpty('general.links_per_page', 20);
|
$this->setEmpty('general.links_per_page', 20);
|
||||||
$this->setEmpty('general.enabled_plugins', array('qrcode'));
|
$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', true);
|
||||||
$this->setEmpty('updates.check_updates_branch', 'stable');
|
$this->setEmpty('updates.check_updates_branch', 'latest');
|
||||||
$this->setEmpty('updates.check_updates_interval', 86400);
|
$this->setEmpty('updates.check_updates_interval', 86400);
|
||||||
|
|
||||||
$this->setEmpty('feed.rss_permalinks', true);
|
$this->setEmpty('feed.rss_permalinks', true);
|
||||||
$this->setEmpty('feed.show_atom', false);
|
$this->setEmpty('feed.show_atom', true);
|
||||||
|
|
||||||
$this->setEmpty('privacy.default_private_links', false);
|
$this->setEmpty('privacy.default_private_links', false);
|
||||||
$this->setEmpty('privacy.hide_public_links', false);
|
$this->setEmpty('privacy.hide_public_links', false);
|
||||||
|
$this->setEmpty('privacy.force_login', false);
|
||||||
$this->setEmpty('privacy.hide_timestamps', false);
|
$this->setEmpty('privacy.hide_timestamps', false);
|
||||||
|
// default state of the 'remember me' checkbox of the login form
|
||||||
|
$this->setEmpty('privacy.remember_user_default', true);
|
||||||
|
|
||||||
$this->setEmpty('thumbnail.enable_thumbnails', true);
|
$this->setEmpty('thumbnails.mode', Thumbnailer::MODE_ALL);
|
||||||
$this->setEmpty('thumbnail.enable_localcache', true);
|
$this->setEmpty('thumbnails.width', '125');
|
||||||
|
$this->setEmpty('thumbnails.height', '90');
|
||||||
|
|
||||||
$this->setEmpty('redirector.url', '');
|
$this->setEmpty('translation.language', 'auto');
|
||||||
$this->setEmpty('redirector.encode_url', true);
|
$this->setEmpty('translation.mode', 'php');
|
||||||
|
$this->setEmpty('translation.extensions', []);
|
||||||
|
|
||||||
$this->setEmpty('plugins', array());
|
$this->setEmpty('plugins', []);
|
||||||
|
|
||||||
|
$this->setEmpty('formatter', 'markdown');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -360,36 +427,3 @@ public function setConfigIO($configIO)
|
||||||
$this->configIO = $configIO;
|
$this->configIO = $configIO;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Exception used if a mandatory field is missing in given configuration.
|
|
||||||
*/
|
|
||||||
class MissingFieldConfigException extends Exception
|
|
||||||
{
|
|
||||||
public $field;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Construct exception.
|
|
||||||
*
|
|
||||||
* @param string $field field name missing.
|
|
||||||
*/
|
|
||||||
public function __construct($field)
|
|
||||||
{
|
|
||||||
$this->field = $field;
|
|
||||||
$this->message = 'Configuration value is required for '. $this->field;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exception used if an unauthorized attempt to edit configuration has been made.
|
|
||||||
*/
|
|
||||||
class UnauthorizedConfigException extends Exception
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Construct exception.
|
|
||||||
*/
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->message = 'You are not authorized to alter config.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Config;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class ConfigPhp (ConfigIO implementation)
|
* Class ConfigPhp (ConfigIO implementation)
|
||||||
*
|
*
|
||||||
|
@ -11,7 +13,7 @@ class ConfigPhp implements ConfigIO
|
||||||
/**
|
/**
|
||||||
* @var array List of config key without group.
|
* @var array List of config key without group.
|
||||||
*/
|
*/
|
||||||
public static $ROOT_KEYS = array(
|
public static $ROOT_KEYS = [
|
||||||
'login',
|
'login',
|
||||||
'hash',
|
'hash',
|
||||||
'salt',
|
'salt',
|
||||||
|
@ -21,16 +23,16 @@ class ConfigPhp implements ConfigIO
|
||||||
'redirector',
|
'redirector',
|
||||||
'disablesessionprotection',
|
'disablesessionprotection',
|
||||||
'privateLinkByDefault',
|
'privateLinkByDefault',
|
||||||
);
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map legacy config keys with the new ones.
|
* Map legacy config keys with the new ones.
|
||||||
* If ConfigPhp is used, getting <newkey> will actually look for <legacykey>.
|
* 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.
|
* @var array current key => legacy key.
|
||||||
*/
|
*/
|
||||||
public static $LEGACY_KEYS_MAPPING = array(
|
public static $LEGACY_KEYS_MAPPING = [
|
||||||
'credentials.login' => 'login',
|
'credentials.login' => 'login',
|
||||||
'credentials.hash' => 'hash',
|
'credentials.hash' => 'hash',
|
||||||
'credentials.salt' => 'salt',
|
'credentials.salt' => 'salt',
|
||||||
|
@ -67,32 +69,32 @@ class ConfigPhp implements ConfigIO
|
||||||
'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS',
|
'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS',
|
||||||
'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS',
|
'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS',
|
||||||
'security.open_shaarli' => 'config.OPEN_SHAARLI',
|
'security.open_shaarli' => 'config.OPEN_SHAARLI',
|
||||||
);
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
function read($filepath)
|
public function read($filepath)
|
||||||
{
|
{
|
||||||
if (! file_exists($filepath) || ! is_readable($filepath)) {
|
if (! file_exists($filepath) || ! is_readable($filepath)) {
|
||||||
return array();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
include $filepath;
|
include $filepath;
|
||||||
|
|
||||||
$out = array();
|
$out = [];
|
||||||
foreach (self::$ROOT_KEYS as $key) {
|
foreach (self::$ROOT_KEYS as $key) {
|
||||||
$out[$key] = $GLOBALS[$key];
|
$out[$key] = isset($GLOBALS[$key]) ? $GLOBALS[$key] : '';
|
||||||
}
|
}
|
||||||
$out['config'] = $GLOBALS['config'];
|
$out['config'] = isset($GLOBALS['config']) ? $GLOBALS['config'] : [];
|
||||||
$out['plugins'] = !empty($GLOBALS['plugins']) ? $GLOBALS['plugins'] : array();
|
$out['plugins'] = isset($GLOBALS['plugins']) ? $GLOBALS['plugins'] : [];
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
function write($filepath, $conf)
|
public function write($filepath, $conf)
|
||||||
{
|
{
|
||||||
$configStr = '<?php ' . PHP_EOL;
|
$configStr = '<?php ' . PHP_EOL;
|
||||||
foreach (self::$ROOT_KEYS as $key) {
|
foreach (self::$ROOT_KEYS as $key) {
|
||||||
|
@ -103,22 +105,31 @@ function write($filepath, $conf)
|
||||||
|
|
||||||
// Store all $conf['config']
|
// Store all $conf['config']
|
||||||
foreach ($conf['config'] as $key => $value) {
|
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'])) {
|
if (isset($conf['plugins'])) {
|
||||||
foreach ($conf['plugins'] as $key => $value) {
|
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
|
|| strcmp(file_get_contents($filepath), $configStr) != 0
|
||||||
) {
|
) {
|
||||||
throw new IOException(
|
throw new \Shaarli\Exceptions\IOException(
|
||||||
$filepath,
|
$filepath,
|
||||||
'Shaarli could not create the config file.
|
t('Shaarli could not create the config file. ' .
|
||||||
Please make sure Shaarli has the right to write in the folder is it installed in.'
|
'Please make sure Shaarli has the right to write in the folder is it installed in.')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -126,7 +137,7 @@ function write($filepath, $conf)
|
||||||
/**
|
/**
|
||||||
* @inheritdoc
|
* @inheritdoc
|
||||||
*/
|
*/
|
||||||
function getExtension()
|
public function getExtension()
|
||||||
{
|
{
|
||||||
return '.php';
|
return '.php';
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Shaarli\Config\Exception\PluginConfigOrderException;
|
||||||
|
use Shaarli\Plugin\PluginManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin configuration helper functions.
|
* Plugin configuration helper functions.
|
||||||
*
|
*
|
||||||
|
@ -16,13 +20,27 @@
|
||||||
*/
|
*/
|
||||||
function save_plugin_config($formData)
|
function save_plugin_config($formData)
|
||||||
{
|
{
|
||||||
|
// We can only save existing plugins
|
||||||
|
$directories = str_replace(
|
||||||
|
PluginManager::$PLUGINS_PATH . '/',
|
||||||
|
'',
|
||||||
|
glob(PluginManager::$PLUGINS_PATH . '/*')
|
||||||
|
);
|
||||||
|
$formData = array_filter(
|
||||||
|
$formData,
|
||||||
|
function ($value, string $key) use ($directories) {
|
||||||
|
return startsWith($key, 'order') || in_array($key, $directories);
|
||||||
|
},
|
||||||
|
ARRAY_FILTER_USE_BOTH
|
||||||
|
);
|
||||||
|
|
||||||
// Make sure there are no duplicates in orders.
|
// Make sure there are no duplicates in orders.
|
||||||
if (!validate_plugin_order($formData)) {
|
if (!validate_plugin_order($formData)) {
|
||||||
throw new PluginConfigOrderException();
|
throw new PluginConfigOrderException();
|
||||||
}
|
}
|
||||||
|
|
||||||
$plugins = array();
|
$plugins = [];
|
||||||
$newEnabledPlugins = array();
|
$newEnabledPlugins = [];
|
||||||
foreach ($formData as $key => $data) {
|
foreach ($formData as $key => $data) {
|
||||||
if (startsWith($key, 'order')) {
|
if (startsWith($key, 'order')) {
|
||||||
continue;
|
continue;
|
||||||
|
@ -31,8 +49,7 @@ function save_plugin_config($formData)
|
||||||
// If there is no order, it means a disabled plugin has been enabled.
|
// If there is no order, it means a disabled plugin has been enabled.
|
||||||
if (isset($formData['order_' . $key])) {
|
if (isset($formData['order_' . $key])) {
|
||||||
$plugins[(int) $formData['order_' . $key]] = $key;
|
$plugins[(int) $formData['order_' . $key]] = $key;
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
$newEnabledPlugins[] = $key;
|
$newEnabledPlugins[] = $key;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -45,7 +62,7 @@ function save_plugin_config($formData)
|
||||||
throw new PluginConfigOrderException();
|
throw new PluginConfigOrderException();
|
||||||
}
|
}
|
||||||
|
|
||||||
$finalPlugins = array();
|
$finalPlugins = [];
|
||||||
// Make plugins order continuous.
|
// Make plugins order continuous.
|
||||||
foreach ($plugins as $plugin) {
|
foreach ($plugins as $plugin) {
|
||||||
$finalPlugins[] = $plugin;
|
$finalPlugins[] = $plugin;
|
||||||
|
@ -64,10 +81,10 @@ function save_plugin_config($formData)
|
||||||
*/
|
*/
|
||||||
function validate_plugin_order($formData)
|
function validate_plugin_order($formData)
|
||||||
{
|
{
|
||||||
$orders = array();
|
$orders = [];
|
||||||
foreach ($formData as $key => $value) {
|
foreach ($formData as $key => $value) {
|
||||||
// No duplicate order allowed.
|
// No duplicate order allowed.
|
||||||
if (in_array($value, $orders)) {
|
if (in_array($value, $orders, true)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -108,17 +125,3 @@ function load_plugin_parameter_values($plugins, $conf)
|
||||||
|
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Exception used if an error occur while saving plugin configuration.
|
|
||||||
*/
|
|
||||||
class PluginConfigOrderException extends Exception
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Construct exception.
|
|
||||||
*/
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->message = 'An error occurred while trying to save plugins loading order.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
22
application/config/exception/MissingFieldConfigException.php
Normal file
22
application/config/exception/MissingFieldConfigException.php
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Config\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception used if a mandatory field is missing in given configuration.
|
||||||
|
*/
|
||||||
|
class MissingFieldConfigException extends \Exception
|
||||||
|
{
|
||||||
|
public $field;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct exception.
|
||||||
|
*
|
||||||
|
* @param string $field field name missing.
|
||||||
|
*/
|
||||||
|
public function __construct($field)
|
||||||
|
{
|
||||||
|
$this->field = $field;
|
||||||
|
$this->message = sprintf(t('Configuration value is required for %s'), $this->field);
|
||||||
|
}
|
||||||
|
}
|
17
application/config/exception/PluginConfigOrderException.php
Normal file
17
application/config/exception/PluginConfigOrderException.php
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Config\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception used if an error occur while saving plugin configuration.
|
||||||
|
*/
|
||||||
|
class PluginConfigOrderException extends \Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Construct exception.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->message = t('An error occurred while trying to save plugins loading order.');
|
||||||
|
}
|
||||||
|
}
|
17
application/config/exception/UnauthorizedConfigException.php
Normal file
17
application/config/exception/UnauthorizedConfigException.php
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Config\Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception used if an unauthorized attempt to edit configuration has been made.
|
||||||
|
*/
|
||||||
|
class UnauthorizedConfigException extends \Exception
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Construct exception.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->message = t('You are not authorized to alter config.');
|
||||||
|
}
|
||||||
|
}
|
176
application/container/ContainerBuilder.php
Normal file
176
application/container/ContainerBuilder.php
Normal file
|
@ -0,0 +1,176 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Container;
|
||||||
|
|
||||||
|
use malkusch\lock\mutex\FlockMutex;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Shaarli\Bookmark\BookmarkFileService;
|
||||||
|
use Shaarli\Bookmark\BookmarkServiceInterface;
|
||||||
|
use Shaarli\Config\ConfigManager;
|
||||||
|
use Shaarli\Feed\FeedBuilder;
|
||||||
|
use Shaarli\Formatter\FormatterFactory;
|
||||||
|
use Shaarli\Front\Controller\Visitor\ErrorController;
|
||||||
|
use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
|
||||||
|
use Shaarli\History;
|
||||||
|
use Shaarli\Http\HttpAccess;
|
||||||
|
use Shaarli\Http\MetadataRetriever;
|
||||||
|
use Shaarli\Netscape\NetscapeBookmarkUtils;
|
||||||
|
use Shaarli\Plugin\PluginManager;
|
||||||
|
use Shaarli\Render\PageBuilder;
|
||||||
|
use Shaarli\Render\PageCacheManager;
|
||||||
|
use Shaarli\Security\CookieManager;
|
||||||
|
use Shaarli\Security\LoginManager;
|
||||||
|
use Shaarli\Security\SessionManager;
|
||||||
|
use Shaarli\Thumbnailer;
|
||||||
|
use Shaarli\Updater\Updater;
|
||||||
|
use Shaarli\Updater\UpdaterUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ContainerBuilder
|
||||||
|
*
|
||||||
|
* Helper used to build a Slim container instance with Shaarli's object dependencies.
|
||||||
|
* Note that most injected objects MUST be added as closures, to let the container instantiate
|
||||||
|
* only the objects it requires during the execution.
|
||||||
|
*
|
||||||
|
* @package Container
|
||||||
|
*/
|
||||||
|
class ContainerBuilder
|
||||||
|
{
|
||||||
|
/** @var ConfigManager */
|
||||||
|
protected $conf;
|
||||||
|
|
||||||
|
/** @var SessionManager */
|
||||||
|
protected $session;
|
||||||
|
|
||||||
|
/** @var CookieManager */
|
||||||
|
protected $cookieManager;
|
||||||
|
|
||||||
|
/** @var LoginManager */
|
||||||
|
protected $login;
|
||||||
|
|
||||||
|
/** @var PluginManager */
|
||||||
|
protected $pluginManager;
|
||||||
|
|
||||||
|
/** @var LoggerInterface */
|
||||||
|
protected $logger;
|
||||||
|
|
||||||
|
/** @var string|null */
|
||||||
|
protected $basePath = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
ConfigManager $conf,
|
||||||
|
SessionManager $session,
|
||||||
|
CookieManager $cookieManager,
|
||||||
|
LoginManager $login,
|
||||||
|
PluginManager $pluginManager,
|
||||||
|
LoggerInterface $logger
|
||||||
|
) {
|
||||||
|
$this->conf = $conf;
|
||||||
|
$this->session = $session;
|
||||||
|
$this->login = $login;
|
||||||
|
$this->cookieManager = $cookieManager;
|
||||||
|
$this->pluginManager = $pluginManager;
|
||||||
|
$this->logger = $logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function build(): ShaarliContainer
|
||||||
|
{
|
||||||
|
$container = new ShaarliContainer();
|
||||||
|
|
||||||
|
$container['conf'] = $this->conf;
|
||||||
|
$container['sessionManager'] = $this->session;
|
||||||
|
$container['cookieManager'] = $this->cookieManager;
|
||||||
|
$container['loginManager'] = $this->login;
|
||||||
|
$container['pluginManager'] = $this->pluginManager;
|
||||||
|
$container['logger'] = $this->logger;
|
||||||
|
$container['basePath'] = $this->basePath;
|
||||||
|
|
||||||
|
|
||||||
|
$container['history'] = function (ShaarliContainer $container): History {
|
||||||
|
return new History($container->conf->get('resource.history'));
|
||||||
|
};
|
||||||
|
|
||||||
|
$container['bookmarkService'] = function (ShaarliContainer $container): BookmarkServiceInterface {
|
||||||
|
return new BookmarkFileService(
|
||||||
|
$container->conf,
|
||||||
|
$container->pluginManager,
|
||||||
|
$container->history,
|
||||||
|
new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2),
|
||||||
|
$container->loginManager->isLoggedIn()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
$container['metadataRetriever'] = function (ShaarliContainer $container): MetadataRetriever {
|
||||||
|
return new MetadataRetriever($container->conf, $container->httpAccess);
|
||||||
|
};
|
||||||
|
|
||||||
|
$container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder {
|
||||||
|
return new PageBuilder(
|
||||||
|
$container->conf,
|
||||||
|
$container->sessionManager->getSession(),
|
||||||
|
$container->logger,
|
||||||
|
$container->bookmarkService,
|
||||||
|
$container->sessionManager->generateToken(),
|
||||||
|
$container->loginManager->isLoggedIn()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
$container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory {
|
||||||
|
return new FormatterFactory(
|
||||||
|
$container->conf,
|
||||||
|
$container->loginManager->isLoggedIn()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
$container['pageCacheManager'] = function (ShaarliContainer $container): PageCacheManager {
|
||||||
|
return new PageCacheManager(
|
||||||
|
$container->conf->get('resource.page_cache'),
|
||||||
|
$container->loginManager->isLoggedIn()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
$container['feedBuilder'] = function (ShaarliContainer $container): FeedBuilder {
|
||||||
|
return new FeedBuilder(
|
||||||
|
$container->bookmarkService,
|
||||||
|
$container->formatterFactory->getFormatter(),
|
||||||
|
$container->environment,
|
||||||
|
$container->loginManager->isLoggedIn()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
$container['thumbnailer'] = function (ShaarliContainer $container): Thumbnailer {
|
||||||
|
return new Thumbnailer($container->conf);
|
||||||
|
};
|
||||||
|
|
||||||
|
$container['httpAccess'] = function (): HttpAccess {
|
||||||
|
return new HttpAccess();
|
||||||
|
};
|
||||||
|
|
||||||
|
$container['netscapeBookmarkUtils'] = function (ShaarliContainer $container): NetscapeBookmarkUtils {
|
||||||
|
return new NetscapeBookmarkUtils($container->bookmarkService, $container->conf, $container->history);
|
||||||
|
};
|
||||||
|
|
||||||
|
$container['updater'] = function (ShaarliContainer $container): Updater {
|
||||||
|
return new Updater(
|
||||||
|
UpdaterUtils::readUpdatesFile($container->conf->get('resource.updates')),
|
||||||
|
$container->bookmarkService,
|
||||||
|
$container->conf,
|
||||||
|
$container->loginManager->isLoggedIn()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
$container['notFoundHandler'] = function (ShaarliContainer $container): ErrorNotFoundController {
|
||||||
|
return new ErrorNotFoundController($container);
|
||||||
|
};
|
||||||
|
$container['errorHandler'] = function (ShaarliContainer $container): ErrorController {
|
||||||
|
return new ErrorController($container);
|
||||||
|
};
|
||||||
|
$container['phpErrorHandler'] = function (ShaarliContainer $container): ErrorController {
|
||||||
|
return new ErrorController($container);
|
||||||
|
};
|
||||||
|
|
||||||
|
return $container;
|
||||||
|
}
|
||||||
|
}
|
54
application/container/ShaarliContainer.php
Normal file
54
application/container/ShaarliContainer.php
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Container;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Shaarli\Bookmark\BookmarkServiceInterface;
|
||||||
|
use Shaarli\Config\ConfigManager;
|
||||||
|
use Shaarli\Feed\FeedBuilder;
|
||||||
|
use Shaarli\Formatter\FormatterFactory;
|
||||||
|
use Shaarli\History;
|
||||||
|
use Shaarli\Http\HttpAccess;
|
||||||
|
use Shaarli\Http\MetadataRetriever;
|
||||||
|
use Shaarli\Netscape\NetscapeBookmarkUtils;
|
||||||
|
use Shaarli\Plugin\PluginManager;
|
||||||
|
use Shaarli\Render\PageBuilder;
|
||||||
|
use Shaarli\Render\PageCacheManager;
|
||||||
|
use Shaarli\Security\CookieManager;
|
||||||
|
use Shaarli\Security\LoginManager;
|
||||||
|
use Shaarli\Security\SessionManager;
|
||||||
|
use Shaarli\Thumbnailer;
|
||||||
|
use Shaarli\Updater\Updater;
|
||||||
|
use Slim\Container;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension of Slim container to document the injected objects.
|
||||||
|
*
|
||||||
|
* @property string $basePath Shaarli's instance base path (e.g. `/shaarli/`)
|
||||||
|
* @property BookmarkServiceInterface $bookmarkService
|
||||||
|
* @property CookieManager $cookieManager
|
||||||
|
* @property ConfigManager $conf
|
||||||
|
* @property mixed[] $environment $_SERVER automatically injected by Slim
|
||||||
|
* @property callable $errorHandler Overrides default Slim exception display
|
||||||
|
* @property FeedBuilder $feedBuilder
|
||||||
|
* @property FormatterFactory $formatterFactory
|
||||||
|
* @property History $history
|
||||||
|
* @property HttpAccess $httpAccess
|
||||||
|
* @property LoginManager $loginManager
|
||||||
|
* @property LoggerInterface $logger
|
||||||
|
* @property MetadataRetriever $metadataRetriever
|
||||||
|
* @property NetscapeBookmarkUtils $netscapeBookmarkUtils
|
||||||
|
* @property callable $notFoundHandler Overrides default Slim exception display
|
||||||
|
* @property PageBuilder $pageBuilder
|
||||||
|
* @property PageCacheManager $pageCacheManager
|
||||||
|
* @property callable $phpErrorHandler Overrides default Slim PHP error display
|
||||||
|
* @property PluginManager $pluginManager
|
||||||
|
* @property SessionManager $sessionManager
|
||||||
|
* @property Thumbnailer $thumbnailer
|
||||||
|
* @property Updater $updater
|
||||||
|
*/
|
||||||
|
class ShaarliContainer extends Container
|
||||||
|
{
|
||||||
|
}
|
|
@ -1,4 +1,9 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exception class thrown when a filesystem access failure happens
|
* Exception class thrown when a filesystem access failure happens
|
||||||
*/
|
*/
|
||||||
|
@ -15,7 +20,7 @@ class IOException extends Exception
|
||||||
public function __construct($path, $message = '')
|
public function __construct($path, $message = '')
|
||||||
{
|
{
|
||||||
$this->path = $path;
|
$this->path = $path;
|
||||||
$this->message = empty($message) ? 'Error accessing' : $message;
|
$this->message = empty($message) ? t('Error accessing') : $message;
|
||||||
$this->message .= PHP_EOL . $this->path;
|
$this->message .= ' "' . $this->path . '"';
|
||||||
}
|
}
|
||||||
}
|
}
|
81
application/feed/CachedPage.php
Normal file
81
application/feed/CachedPage.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
286
application/feed/FeedBuilder.php
Normal file
286
application/feed/FeedBuilder.php
Normal 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>— ' . $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;
|
||||||
|
}
|
||||||
|
}
|
229
application/formatter/BookmarkDefaultFormatter.php
Normal file
229
application/formatter/BookmarkDefaultFormatter.php
Normal file
|
@ -0,0 +1,229 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Formatter;
|
||||||
|
|
||||||
|
use Shaarli\Bookmark\Bookmark;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class BookmarkDefaultFormatter
|
||||||
|
*
|
||||||
|
* Default bookmark formatter.
|
||||||
|
* Escape values for HTML display and automatically add link to URL and hashtags.
|
||||||
|
*
|
||||||
|
* @package Shaarli\Formatter
|
||||||
|
*/
|
||||||
|
class BookmarkDefaultFormatter extends BookmarkFormatter
|
||||||
|
{
|
||||||
|
public const SEARCH_HIGHLIGHT_OPEN = 'SHAARLI_O_HIGHLIGHT';
|
||||||
|
public const SEARCH_HIGHLIGHT_CLOSE = 'SHAARLI_C_HIGHLIGHT';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
protected function formatTitle($bookmark)
|
||||||
|
{
|
||||||
|
return escape($bookmark->getTitle());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
protected function formatTitleHtml($bookmark)
|
||||||
|
{
|
||||||
|
$title = $this->tokenizeSearchHighlightField(
|
||||||
|
$bookmark->getTitle() ?? '',
|
||||||
|
$bookmark->getAdditionalContentEntry('search_highlight')['title'] ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->replaceTokens(escape($title));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
protected function formatDescription($bookmark)
|
||||||
|
{
|
||||||
|
$indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
|
||||||
|
$description = $this->tokenizeSearchHighlightField(
|
||||||
|
$bookmark->getDescription() ?? '',
|
||||||
|
$bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
|
||||||
|
);
|
||||||
|
$description = format_description(
|
||||||
|
escape($description),
|
||||||
|
$indexUrl,
|
||||||
|
$this->conf->get('formatter_settings.autolink', true)
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->replaceTokens($description);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
protected function formatTagList($bookmark)
|
||||||
|
{
|
||||||
|
return escape(parent::formatTagList($bookmark));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
protected function formatTagListHtml($bookmark)
|
||||||
|
{
|
||||||
|
$tagsSeparator = $this->conf->get('general.tags_separator', ' ');
|
||||||
|
if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) {
|
||||||
|
return $this->formatTagList($bookmark);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tags = $this->tokenizeSearchHighlightField(
|
||||||
|
$bookmark->getTagsString($tagsSeparator),
|
||||||
|
$bookmark->getAdditionalContentEntry('search_highlight')['tags']
|
||||||
|
);
|
||||||
|
$tags = $this->filterTagList(tags_str2array($tags, $tagsSeparator));
|
||||||
|
$tags = escape($tags);
|
||||||
|
$tags = $this->replaceTokensArray($tags);
|
||||||
|
|
||||||
|
return $tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
protected function formatTagString($bookmark)
|
||||||
|
{
|
||||||
|
return implode($this->conf->get('general.tags_separator'), $this->formatTagList($bookmark));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
protected function formatUrl($bookmark)
|
||||||
|
{
|
||||||
|
if ($bookmark->isNote() && isset($this->contextData['index_url'])) {
|
||||||
|
return rtrim($this->contextData['index_url'], '/') . '/' . escape(ltrim($bookmark->getUrl(), '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return escape($bookmark->getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
protected function formatRealUrl($bookmark)
|
||||||
|
{
|
||||||
|
if ($bookmark->isNote()) {
|
||||||
|
if (isset($this->contextData['index_url'])) {
|
||||||
|
$prefix = rtrim($this->contextData['index_url'], '/') . '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($this->contextData['base_path'])) {
|
||||||
|
$prefix = rtrim($this->contextData['base_path'], '/') . '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return escape($prefix ?? '') . escape(ltrim($bookmark->getUrl() ?? '', '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return escape($bookmark->getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
protected function formatUrlHtml($bookmark)
|
||||||
|
{
|
||||||
|
$url = $this->tokenizeSearchHighlightField(
|
||||||
|
$bookmark->getUrl() ?? '',
|
||||||
|
$bookmark->getAdditionalContentEntry('search_highlight')['url'] ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->replaceTokens(escape($url));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
protected function formatThumbnail($bookmark)
|
||||||
|
{
|
||||||
|
return escape($bookmark->getThumbnail());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
protected function formatAdditionalContent(Bookmark $bookmark): array
|
||||||
|
{
|
||||||
|
$additionalContent = parent::formatAdditionalContent($bookmark);
|
||||||
|
|
||||||
|
unset($additionalContent['search_highlight']);
|
||||||
|
|
||||||
|
return $additionalContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert search highlight token in provided field content based on a list of search result positions
|
||||||
|
*
|
||||||
|
* @param string $fieldContent
|
||||||
|
* @param array|null $positions List of of search results with 'start' and 'end' positions.
|
||||||
|
*
|
||||||
|
* @return string Updated $fieldContent.
|
||||||
|
*/
|
||||||
|
protected function tokenizeSearchHighlightField(string $fieldContent, ?array $positions): string
|
||||||
|
{
|
||||||
|
if (empty($positions)) {
|
||||||
|
return $fieldContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
$insertedTokens = 0;
|
||||||
|
$tokenLength = strlen(static::SEARCH_HIGHLIGHT_OPEN);
|
||||||
|
foreach ($positions as $position) {
|
||||||
|
$position = [
|
||||||
|
'start' => $position['start'] + ($insertedTokens * $tokenLength),
|
||||||
|
'end' => $position['end'] + ($insertedTokens * $tokenLength),
|
||||||
|
];
|
||||||
|
|
||||||
|
$content = mb_substr($fieldContent, 0, $position['start']);
|
||||||
|
$content .= static::SEARCH_HIGHLIGHT_OPEN;
|
||||||
|
$content .= mb_substr($fieldContent, $position['start'], $position['end'] - $position['start']);
|
||||||
|
$content .= static::SEARCH_HIGHLIGHT_CLOSE;
|
||||||
|
$content .= mb_substr($fieldContent, $position['end']);
|
||||||
|
|
||||||
|
$fieldContent = $content;
|
||||||
|
|
||||||
|
$insertedTokens += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fieldContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace search highlight tokens with HTML highlighted span.
|
||||||
|
*
|
||||||
|
* @param string $fieldContent
|
||||||
|
*
|
||||||
|
* @return string updated content.
|
||||||
|
*/
|
||||||
|
protected function replaceTokens(string $fieldContent): string
|
||||||
|
{
|
||||||
|
return str_replace(
|
||||||
|
[static::SEARCH_HIGHLIGHT_OPEN, static::SEARCH_HIGHLIGHT_CLOSE],
|
||||||
|
['<span class="search-highlight">', '</span>'],
|
||||||
|
$fieldContent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply replaceTokens to an array of content strings.
|
||||||
|
*
|
||||||
|
* @param string[] $fieldContents
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
protected function replaceTokensArray(array $fieldContents): array
|
||||||
|
{
|
||||||
|
foreach ($fieldContents as &$entry) {
|
||||||
|
$entry = $this->replaceTokens($entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fieldContents;
|
||||||
|
}
|
||||||
|
}
|
390
application/formatter/BookmarkFormatter.php
Normal file
390
application/formatter/BookmarkFormatter.php
Normal file
|
@ -0,0 +1,390 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Formatter;
|
||||||
|
|
||||||
|
use DateTimeInterface;
|
||||||
|
use Shaarli\Bookmark\Bookmark;
|
||||||
|
use Shaarli\Config\ConfigManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class BookmarkFormatter
|
||||||
|
*
|
||||||
|
* Abstract class processing all bookmark attributes through methods designed to be overridden.
|
||||||
|
*
|
||||||
|
* List of available formatted fields:
|
||||||
|
* - id ID
|
||||||
|
* - shorturl Unique identifier, used in permalinks
|
||||||
|
* - url URL, can be altered in some way, e.g. passing through an HTTP reverse proxy
|
||||||
|
* - real_url (legacy) same as `url`
|
||||||
|
* - url_html URL to be displayed in HTML content (it can contain HTML tags)
|
||||||
|
* - title Title
|
||||||
|
* - title_html Title to be displayed in HTML content (it can contain HTML tags)
|
||||||
|
* - description Description content. It most likely contains HTML tags
|
||||||
|
* - thumbnail Thumbnail: path to local cache file, false if there is none, null if hasn't been retrieved
|
||||||
|
* - taglist List of tags (array)
|
||||||
|
* - taglist_urlencoded List of tags (array) URL encoded: it must be used to create a link to a URL containing a tag
|
||||||
|
* - taglist_html List of tags (array) to be displayed in HTML content (it can contain HTML tags)
|
||||||
|
* - tags Tags separated by a single whitespace
|
||||||
|
* - tags_urlencoded Tags separated by a single whitespace, URL encoded: must be used to create a link
|
||||||
|
* - sticky Is sticky (bool)
|
||||||
|
* - private Is private (bool)
|
||||||
|
* - class Additional CSS class
|
||||||
|
* - created Creation DateTime
|
||||||
|
* - updated Last edit DateTime
|
||||||
|
* - timestamp Creation timestamp
|
||||||
|
* - updated_timestamp Last edit timestamp
|
||||||
|
*
|
||||||
|
* @package Shaarli\Formatter
|
||||||
|
*/
|
||||||
|
abstract class BookmarkFormatter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var ConfigManager
|
||||||
|
*/
|
||||||
|
protected $conf;
|
||||||
|
|
||||||
|
/** @var bool */
|
||||||
|
protected $isLoggedIn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array Additional parameters than can be used for specific formatting
|
||||||
|
* e.g. index_url for Feed formatting
|
||||||
|
*/
|
||||||
|
protected $contextData = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LinkDefaultFormatter constructor.
|
||||||
|
* @param ConfigManager $conf
|
||||||
|
*/
|
||||||
|
public function __construct(ConfigManager $conf, bool $isLoggedIn)
|
||||||
|
{
|
||||||
|
$this->conf = $conf;
|
||||||
|
$this->isLoggedIn = $isLoggedIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Bookmark into an array usable by templates and plugins.
|
||||||
|
*
|
||||||
|
* All Bookmark attributes are formatted through a format method
|
||||||
|
* that can be overridden in a formatter extending this class.
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return array formatted representation of a Bookmark
|
||||||
|
*/
|
||||||
|
public function format($bookmark)
|
||||||
|
{
|
||||||
|
$out['id'] = $this->formatId($bookmark);
|
||||||
|
$out['shorturl'] = $this->formatShortUrl($bookmark);
|
||||||
|
$out['url'] = $this->formatUrl($bookmark);
|
||||||
|
$out['real_url'] = $this->formatRealUrl($bookmark);
|
||||||
|
$out['url_html'] = $this->formatUrlHtml($bookmark);
|
||||||
|
$out['title'] = $this->formatTitle($bookmark);
|
||||||
|
$out['title_html'] = $this->formatTitleHtml($bookmark);
|
||||||
|
$out['description'] = $this->formatDescription($bookmark);
|
||||||
|
$out['thumbnail'] = $this->formatThumbnail($bookmark);
|
||||||
|
$out['taglist'] = $this->formatTagList($bookmark);
|
||||||
|
$out['taglist_urlencoded'] = $this->formatTagListUrlEncoded($bookmark);
|
||||||
|
$out['taglist_html'] = $this->formatTagListHtml($bookmark);
|
||||||
|
$out['tags'] = $this->formatTagString($bookmark);
|
||||||
|
$out['tags_urlencoded'] = $this->formatTagStringUrlEncoded($bookmark);
|
||||||
|
$out['sticky'] = $bookmark->isSticky();
|
||||||
|
$out['private'] = $bookmark->isPrivate();
|
||||||
|
$out['class'] = $this->formatClass($bookmark);
|
||||||
|
$out['created'] = $this->formatCreated($bookmark);
|
||||||
|
$out['updated'] = $this->formatUpdated($bookmark);
|
||||||
|
$out['timestamp'] = $this->formatCreatedTimestamp($bookmark);
|
||||||
|
$out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark);
|
||||||
|
$out['additional_content'] = $this->formatAdditionalContent($bookmark);
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add additional data available to formatters.
|
||||||
|
* This is used for example to add `index_url` in description's links.
|
||||||
|
*
|
||||||
|
* @param string $key Context data key
|
||||||
|
* @param string $value Context data value
|
||||||
|
*/
|
||||||
|
public function addContextData($key, $value)
|
||||||
|
{
|
||||||
|
$this->contextData[$key] = $value;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format ID
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return int formatted ID
|
||||||
|
*/
|
||||||
|
protected function formatId($bookmark)
|
||||||
|
{
|
||||||
|
return $bookmark->getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format ShortUrl
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return string formatted ShortUrl
|
||||||
|
*/
|
||||||
|
protected function formatShortUrl($bookmark)
|
||||||
|
{
|
||||||
|
return $bookmark->getShortUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Url
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return string formatted Url
|
||||||
|
*/
|
||||||
|
protected function formatUrl($bookmark)
|
||||||
|
{
|
||||||
|
return $bookmark->getUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format RealUrl
|
||||||
|
* Legacy: identical to Url
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return string formatted RealUrl
|
||||||
|
*/
|
||||||
|
protected function formatRealUrl($bookmark)
|
||||||
|
{
|
||||||
|
return $this->formatUrl($bookmark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Url Html: to be displayed in HTML content, it can contains HTML tags.
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return string formatted Url HTML
|
||||||
|
*/
|
||||||
|
protected function formatUrlHtml($bookmark)
|
||||||
|
{
|
||||||
|
return $this->formatUrl($bookmark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Title
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return string formatted Title
|
||||||
|
*/
|
||||||
|
protected function formatTitle($bookmark)
|
||||||
|
{
|
||||||
|
return $bookmark->getTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Title HTML: to be displayed in HTML content, it can contains HTML tags.
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return string formatted Title
|
||||||
|
*/
|
||||||
|
protected function formatTitleHtml($bookmark)
|
||||||
|
{
|
||||||
|
return $bookmark->getTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Description
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return string formatted Description
|
||||||
|
*/
|
||||||
|
protected function formatDescription($bookmark)
|
||||||
|
{
|
||||||
|
return $bookmark->getDescription();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Thumbnail
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return string formatted Thumbnail
|
||||||
|
*/
|
||||||
|
protected function formatThumbnail($bookmark)
|
||||||
|
{
|
||||||
|
return $bookmark->getThumbnail();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Tags
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return array formatted Tags
|
||||||
|
*/
|
||||||
|
protected function formatTagList($bookmark)
|
||||||
|
{
|
||||||
|
return $this->filterTagList($bookmark->getTags());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Url Encoded Tags
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return array formatted Tags
|
||||||
|
*/
|
||||||
|
protected function formatTagListUrlEncoded($bookmark)
|
||||||
|
{
|
||||||
|
return array_map('urlencode', $this->filterTagList($bookmark->getTags()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Tags HTML: to be displayed in HTML content, it can contains HTML tags.
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return array formatted Tags
|
||||||
|
*/
|
||||||
|
protected function formatTagListHtml($bookmark)
|
||||||
|
{
|
||||||
|
return $this->formatTagList($bookmark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format TagString
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return string formatted TagString
|
||||||
|
*/
|
||||||
|
protected function formatTagString($bookmark)
|
||||||
|
{
|
||||||
|
return implode($this->conf->get('general.tags_separator', ' '), $this->formatTagList($bookmark));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format TagString
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return string formatted TagString
|
||||||
|
*/
|
||||||
|
protected function formatTagStringUrlEncoded($bookmark)
|
||||||
|
{
|
||||||
|
return implode(' ', $this->formatTagListUrlEncoded($bookmark));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Class
|
||||||
|
* Used to add specific CSS class for a link
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return string formatted Class
|
||||||
|
*/
|
||||||
|
protected function formatClass($bookmark)
|
||||||
|
{
|
||||||
|
return $bookmark->isPrivate() ? 'private' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Created
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return DateTimeInterface instance
|
||||||
|
*/
|
||||||
|
protected function formatCreated(Bookmark $bookmark)
|
||||||
|
{
|
||||||
|
return $bookmark->getCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Updated
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return DateTimeInterface instance
|
||||||
|
*/
|
||||||
|
protected function formatUpdated(Bookmark $bookmark)
|
||||||
|
{
|
||||||
|
return $bookmark->getUpdated();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format CreatedTimestamp
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return int formatted CreatedTimestamp
|
||||||
|
*/
|
||||||
|
protected function formatCreatedTimestamp(Bookmark $bookmark)
|
||||||
|
{
|
||||||
|
if (! empty($bookmark->getCreated())) {
|
||||||
|
return $bookmark->getCreated()->getTimestamp();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format UpdatedTimestamp
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return int formatted UpdatedTimestamp
|
||||||
|
*/
|
||||||
|
protected function formatUpdatedTimestamp(Bookmark $bookmark)
|
||||||
|
{
|
||||||
|
if (! empty($bookmark->getUpdated())) {
|
||||||
|
return $bookmark->getUpdated()->getTimestamp();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bookmark's additional content
|
||||||
|
*
|
||||||
|
* @param Bookmark $bookmark instance
|
||||||
|
*
|
||||||
|
* @return mixed[]
|
||||||
|
*/
|
||||||
|
protected function formatAdditionalContent(Bookmark $bookmark): array
|
||||||
|
{
|
||||||
|
return $bookmark->getAdditionalContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format tag list, e.g. remove private tags if the user is not logged in.
|
||||||
|
* TODO: this method is called multiple time to format tags, the result should be cached.
|
||||||
|
*
|
||||||
|
* @param array $tags
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
protected function filterTagList(array $tags): array
|
||||||
|
{
|
||||||
|
if ($this->isLoggedIn === true) {
|
||||||
|
return $tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($tags as $tag) {
|
||||||
|
if (strpos($tag, '.') === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out[] = $tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
24
application/formatter/BookmarkMarkdownExtraFormatter.php
Normal file
24
application/formatter/BookmarkMarkdownExtraFormatter.php
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Formatter;
|
||||||
|
|
||||||
|
use Shaarli\Config\ConfigManager;
|
||||||
|
use Shaarli\Formatter\Parsedown\ShaarliParsedownExtra;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class BookmarkMarkdownExtraFormatter
|
||||||
|
*
|
||||||
|
* Format bookmark description into MarkdownExtra format.
|
||||||
|
*
|
||||||
|
* @see https://michelf.ca/projects/php-markdown/extra/
|
||||||
|
*
|
||||||
|
* @package Shaarli\Formatter
|
||||||
|
*/
|
||||||
|
class BookmarkMarkdownExtraFormatter extends BookmarkMarkdownFormatter
|
||||||
|
{
|
||||||
|
public function __construct(ConfigManager $conf, bool $isLoggedIn)
|
||||||
|
{
|
||||||
|
parent::__construct($conf, $isLoggedIn);
|
||||||
|
$this->parsedown = new ShaarliParsedownExtra();
|
||||||
|
}
|
||||||
|
}
|
221
application/formatter/BookmarkMarkdownFormatter.php
Normal file
221
application/formatter/BookmarkMarkdownFormatter.php
Normal file
|
@ -0,0 +1,221 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Formatter;
|
||||||
|
|
||||||
|
use Shaarli\Config\ConfigManager;
|
||||||
|
use Shaarli\Formatter\Parsedown\ShaarliParsedown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class BookmarkMarkdownFormatter
|
||||||
|
*
|
||||||
|
* Format bookmark description into Markdown format.
|
||||||
|
*
|
||||||
|
* @package Shaarli\Formatter
|
||||||
|
*/
|
||||||
|
class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* When this tag is present in a bookmark, its description should not be processed with Markdown
|
||||||
|
*/
|
||||||
|
public const NO_MD_TAG = 'nomarkdown';
|
||||||
|
|
||||||
|
/** @var \Parsedown instance */
|
||||||
|
protected $parsedown;
|
||||||
|
|
||||||
|
/** @var bool used to escape HTML in Markdown or not.
|
||||||
|
* It MUST be set to true for shared instance as HTML content can
|
||||||
|
* introduce XSS vulnerabilities.
|
||||||
|
*/
|
||||||
|
protected $escape;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array List of allowed protocols for links inside bookmark's description.
|
||||||
|
*/
|
||||||
|
protected $allowedProtocols;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LinkMarkdownFormatter constructor.
|
||||||
|
*
|
||||||
|
* @param ConfigManager $conf instance
|
||||||
|
* @param bool $isLoggedIn
|
||||||
|
*/
|
||||||
|
public function __construct(ConfigManager $conf, bool $isLoggedIn)
|
||||||
|
{
|
||||||
|
parent::__construct($conf, $isLoggedIn);
|
||||||
|
|
||||||
|
$this->parsedown = new ShaarliParsedown();
|
||||||
|
$this->escape = $conf->get('security.markdown_escape', true);
|
||||||
|
$this->allowedProtocols = $conf->get('security.allowed_protocols', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
public function formatDescription($bookmark)
|
||||||
|
{
|
||||||
|
if (in_array(self::NO_MD_TAG, $bookmark->getTags())) {
|
||||||
|
return parent::formatDescription($bookmark);
|
||||||
|
}
|
||||||
|
|
||||||
|
$processedDescription = $this->tokenizeSearchHighlightField(
|
||||||
|
$bookmark->getDescription() ?? '',
|
||||||
|
$bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
|
||||||
|
);
|
||||||
|
$processedDescription = $this->filterProtocols($processedDescription);
|
||||||
|
$processedDescription = $this->formatHashTags($processedDescription);
|
||||||
|
$processedDescription = $this->reverseEscapedHtml($processedDescription);
|
||||||
|
$processedDescription = $this->parsedown
|
||||||
|
->setMarkupEscaped($this->escape)
|
||||||
|
->setBreaksEnabled(true)
|
||||||
|
->text($processedDescription);
|
||||||
|
$processedDescription = $this->sanitizeHtml($processedDescription);
|
||||||
|
$processedDescription = $this->replaceTokens($processedDescription);
|
||||||
|
|
||||||
|
if (!empty($processedDescription)) {
|
||||||
|
$processedDescription = '<div class="markdown">' . $processedDescription . '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $processedDescription;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the NO markdown tag if it is present
|
||||||
|
*
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
protected function formatTagList($bookmark)
|
||||||
|
{
|
||||||
|
$out = parent::formatTagList($bookmark);
|
||||||
|
if ($this->isLoggedIn === false && ($pos = array_search(self::NO_MD_TAG, $out)) !== false) {
|
||||||
|
unset($out[$pos]);
|
||||||
|
return array_values($out);
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace not whitelisted protocols with http:// in given description.
|
||||||
|
* Also adds `index_url` to relative links if it's specified
|
||||||
|
*
|
||||||
|
* @param string $description input description text.
|
||||||
|
*
|
||||||
|
* @return string $description without malicious link.
|
||||||
|
*/
|
||||||
|
protected function filterProtocols($description)
|
||||||
|
{
|
||||||
|
$allowedProtocols = $this->allowedProtocols;
|
||||||
|
$indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
|
||||||
|
|
||||||
|
return preg_replace_callback(
|
||||||
|
'#]\((.*?)\)#is',
|
||||||
|
function ($match) use ($allowedProtocols, $indexUrl) {
|
||||||
|
$link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : '';
|
||||||
|
$link .= whitelist_protocols($match[1], $allowedProtocols);
|
||||||
|
return '](' . $link . ')';
|
||||||
|
},
|
||||||
|
$description
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace hashtag in Markdown links format
|
||||||
|
* E.g. `#hashtag` becomes `[#hashtag](./add-tag/hashtag)`
|
||||||
|
* It includes the index URL if specified.
|
||||||
|
*
|
||||||
|
* @param string $description
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function formatHashTags($description)
|
||||||
|
{
|
||||||
|
$indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
|
||||||
|
$tokens = '(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN . ')' .
|
||||||
|
'(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE . ')'
|
||||||
|
;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* To support unicode: http://stackoverflow.com/a/35498078/1484919
|
||||||
|
* \p{Pc} - to match underscore
|
||||||
|
* \p{N} - numeric character in any script
|
||||||
|
* \p{L} - letter from any language
|
||||||
|
* \p{Mn} - any non marking space (accents, umlauts, etc)
|
||||||
|
*/
|
||||||
|
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}' . $tokens . ']+)/mui';
|
||||||
|
$replacement = function (array $match) use ($indexUrl): string {
|
||||||
|
$cleanMatch = str_replace(
|
||||||
|
BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN,
|
||||||
|
'',
|
||||||
|
str_replace(BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE, '', $match[2])
|
||||||
|
);
|
||||||
|
return $match[1] . '[#' . $match[2] . '](' . $indexUrl . './add-tag/' . $cleanMatch . ')';
|
||||||
|
};
|
||||||
|
|
||||||
|
$descriptionLines = explode(PHP_EOL, $description);
|
||||||
|
$descriptionOut = '';
|
||||||
|
$codeBlockOn = false;
|
||||||
|
$lineCount = 0;
|
||||||
|
|
||||||
|
foreach ($descriptionLines as $descriptionLine) {
|
||||||
|
// Detect line of code: starting with 4 spaces,
|
||||||
|
// except lists which can start with +/*/- or `2.` after spaces.
|
||||||
|
$codeLineOn = preg_match('/^ +(?=[^\+\*\-])(?=(?!\d\.).)/', $descriptionLine) > 0;
|
||||||
|
// Detect and toggle block of code
|
||||||
|
if (!$codeBlockOn) {
|
||||||
|
$codeBlockOn = preg_match('/^```/', $descriptionLine) > 0;
|
||||||
|
} elseif (preg_match('/^```/', $descriptionLine) > 0) {
|
||||||
|
$codeBlockOn = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$codeBlockOn && !$codeLineOn) {
|
||||||
|
$descriptionLine = preg_replace_callback($regex, $replacement, $descriptionLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
$descriptionOut .= $descriptionLine;
|
||||||
|
if ($lineCount++ < count($descriptionLines) - 1) {
|
||||||
|
$descriptionOut .= PHP_EOL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $descriptionOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove dangerous HTML tags (tags, iframe, etc.).
|
||||||
|
* Doesn't affect <code> content (already escaped by Parsedown).
|
||||||
|
*
|
||||||
|
* @param string $description input description text.
|
||||||
|
*
|
||||||
|
* @return string given string escaped.
|
||||||
|
*/
|
||||||
|
protected function sanitizeHtml($description)
|
||||||
|
{
|
||||||
|
$escapeTags = [
|
||||||
|
'script',
|
||||||
|
'style',
|
||||||
|
'link',
|
||||||
|
'iframe',
|
||||||
|
'frameset',
|
||||||
|
'frame',
|
||||||
|
];
|
||||||
|
foreach ($escapeTags as $tag) {
|
||||||
|
$description = preg_replace_callback(
|
||||||
|
'#<\s*' . $tag . '[^>]*>(.*</\s*' . $tag . '[^>]*>)?#is',
|
||||||
|
function ($match) {
|
||||||
|
return escape($match[0]);
|
||||||
|
},
|
||||||
|
$description
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$description = preg_replace(
|
||||||
|
'#(<[^>]+\s)on[a-z]*="?[^ "]*"?#is',
|
||||||
|
'$1',
|
||||||
|
$description
|
||||||
|
);
|
||||||
|
return $description;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function reverseEscapedHtml($description)
|
||||||
|
{
|
||||||
|
return unescape($description);
|
||||||
|
}
|
||||||
|
}
|
15
application/formatter/BookmarkRawFormatter.php
Normal file
15
application/formatter/BookmarkRawFormatter.php
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Formatter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class BookmarkRawFormatter
|
||||||
|
*
|
||||||
|
* Used to retrieve bookmarks as array with raw values.
|
||||||
|
* Warning: Do NOT use this for HTML content as it can introduce XSS vulnerabilities.
|
||||||
|
*
|
||||||
|
* @package Shaarli\Formatter
|
||||||
|
*/
|
||||||
|
class BookmarkRawFormatter extends BookmarkFormatter
|
||||||
|
{
|
||||||
|
}
|
51
application/formatter/FormatterFactory.php
Normal file
51
application/formatter/FormatterFactory.php
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Formatter;
|
||||||
|
|
||||||
|
use Shaarli\Config\ConfigManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class FormatterFactory
|
||||||
|
*
|
||||||
|
* Helper class used to instantiate the proper BookmarkFormatter.
|
||||||
|
*
|
||||||
|
* @package Shaarli\Formatter
|
||||||
|
*/
|
||||||
|
class FormatterFactory
|
||||||
|
{
|
||||||
|
/** @var ConfigManager instance */
|
||||||
|
protected $conf;
|
||||||
|
|
||||||
|
/** @var bool */
|
||||||
|
protected $isLoggedIn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FormatterFactory constructor.
|
||||||
|
*
|
||||||
|
* @param ConfigManager $conf
|
||||||
|
* @param bool $isLoggedIn
|
||||||
|
*/
|
||||||
|
public function __construct(ConfigManager $conf, bool $isLoggedIn)
|
||||||
|
{
|
||||||
|
$this->conf = $conf;
|
||||||
|
$this->isLoggedIn = $isLoggedIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instanciate a BookmarkFormatter depending on the configuration or provided formatter type.
|
||||||
|
*
|
||||||
|
* @param string|null $type force a specific type regardless of the configuration
|
||||||
|
*
|
||||||
|
* @return BookmarkFormatter instance.
|
||||||
|
*/
|
||||||
|
public function getFormatter(string $type = null): BookmarkFormatter
|
||||||
|
{
|
||||||
|
$type = $type ? $type : $this->conf->get('formatter', 'default');
|
||||||
|
$className = '\\Shaarli\\Formatter\\Bookmark' . ucfirst($type) . 'Formatter';
|
||||||
|
if (!class_exists($className)) {
|
||||||
|
$className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new $className($this->conf, $this->isLoggedIn);
|
||||||
|
}
|
||||||
|
}
|
15
application/formatter/Parsedown/ShaarliParsedown.php
Normal file
15
application/formatter/Parsedown/ShaarliParsedown.php
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Formatter\Parsedown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsedown extension for Shaarli.
|
||||||
|
*
|
||||||
|
* Extension for both Parsedown and ParsedownExtra centralized in ShaarliParsedownTrait.
|
||||||
|
*/
|
||||||
|
class ShaarliParsedown extends \Parsedown
|
||||||
|
{
|
||||||
|
use ShaarliParsedownTrait;
|
||||||
|
}
|
15
application/formatter/Parsedown/ShaarliParsedownExtra.php
Normal file
15
application/formatter/Parsedown/ShaarliParsedownExtra.php
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Formatter\Parsedown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ParsedownExtra extension for Shaarli.
|
||||||
|
*
|
||||||
|
* Extension for both Parsedown and ParsedownExtra centralized in ShaarliParsedownTrait.
|
||||||
|
*/
|
||||||
|
class ShaarliParsedownExtra extends \ParsedownExtra
|
||||||
|
{
|
||||||
|
use ShaarliParsedownTrait;
|
||||||
|
}
|
81
application/formatter/Parsedown/ShaarliParsedownTrait.php
Normal file
81
application/formatter/Parsedown/ShaarliParsedownTrait.php
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Formatter\Parsedown;
|
||||||
|
|
||||||
|
use Shaarli\Formatter\BookmarkDefaultFormatter as Formatter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trait used for Parsedown and ParsedownExtra extension.
|
||||||
|
*
|
||||||
|
* Extended:
|
||||||
|
* - Format links properly in search context
|
||||||
|
*/
|
||||||
|
trait ShaarliParsedownTrait
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
protected function inlineLink($excerpt)
|
||||||
|
{
|
||||||
|
return $this->shaarliFormatLink(parent::inlineLink($excerpt), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritDoc
|
||||||
|
*/
|
||||||
|
protected function inlineUrl($excerpt)
|
||||||
|
{
|
||||||
|
return $this->shaarliFormatLink(parent::inlineUrl($excerpt), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Properly format markdown link:
|
||||||
|
* - remove highlight tags from HREF attribute
|
||||||
|
* - (optional) add highlight tags to link caption
|
||||||
|
*
|
||||||
|
* @param array|null $link Parsedown formatted link array.
|
||||||
|
* It can be empty.
|
||||||
|
* @param bool $fullWrap Add highlight tags the whole link caption
|
||||||
|
*
|
||||||
|
* @return array|null
|
||||||
|
*/
|
||||||
|
protected function shaarliFormatLink(?array $link, bool $fullWrap): ?array
|
||||||
|
{
|
||||||
|
// If open and clean search tokens are found in the link, process.
|
||||||
|
if (
|
||||||
|
is_array($link)
|
||||||
|
&& strpos($link['element']['attributes']['href'] ?? '', Formatter::SEARCH_HIGHLIGHT_OPEN) !== false
|
||||||
|
&& strpos($link['element']['attributes']['href'] ?? '', Formatter::SEARCH_HIGHLIGHT_CLOSE) !== false
|
||||||
|
) {
|
||||||
|
$link['element']['attributes']['href'] = $this->shaarliRemoveSearchTokens(
|
||||||
|
$link['element']['attributes']['href']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($fullWrap) {
|
||||||
|
$link['element']['text'] = Formatter::SEARCH_HIGHLIGHT_OPEN .
|
||||||
|
$link['element']['text'] .
|
||||||
|
Formatter::SEARCH_HIGHLIGHT_CLOSE
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $link;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove open and close tags from provided string.
|
||||||
|
*
|
||||||
|
* @param string $entry input
|
||||||
|
*
|
||||||
|
* @return string Striped input
|
||||||
|
*/
|
||||||
|
protected function shaarliRemoveSearchTokens(string $entry): string
|
||||||
|
{
|
||||||
|
$entry = str_replace(Formatter::SEARCH_HIGHLIGHT_OPEN, '', $entry);
|
||||||
|
$entry = str_replace(Formatter::SEARCH_HIGHLIGHT_CLOSE, '', $entry);
|
||||||
|
|
||||||
|
return $entry;
|
||||||
|
}
|
||||||
|
}
|
27
application/front/ShaarliAdminMiddleware.php
Normal file
27
application/front/ShaarliAdminMiddleware.php
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Front;
|
||||||
|
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware used for controller requiring to be authenticated.
|
||||||
|
* It extends ShaarliMiddleware, and just make sure that the user is authenticated.
|
||||||
|
* Otherwise, it redirects to the login page.
|
||||||
|
*/
|
||||||
|
class ShaarliAdminMiddleware extends ShaarliMiddleware
|
||||||
|
{
|
||||||
|
public function __invoke(Request $request, Response $response, callable $next): Response
|
||||||
|
{
|
||||||
|
$this->initBasePath($request);
|
||||||
|
|
||||||
|
if (true !== $this->container->loginManager->isLoggedIn()) {
|
||||||
|
$returnUrl = urlencode($this->container->environment['REQUEST_URI']);
|
||||||
|
|
||||||
|
return $response->withRedirect($this->container->basePath . '/login?returnurl=' . $returnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parent::__invoke($request, $response, $next);
|
||||||
|
}
|
||||||
|
}
|
116
application/front/ShaarliMiddleware.php
Normal file
116
application/front/ShaarliMiddleware.php
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shaarli\Front;
|
||||||
|
|
||||||
|
use Shaarli\Container\ShaarliContainer;
|
||||||
|
use Shaarli\Front\Exception\UnauthorizedException;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ShaarliMiddleware
|
||||||
|
*
|
||||||
|
* This will be called before accessing any Shaarli controller.
|
||||||
|
*/
|
||||||
|
class ShaarliMiddleware
|
||||||
|
{
|
||||||
|
/** @var ShaarliContainer contains all Shaarli DI */
|
||||||
|
protected $container;
|
||||||
|
|
||||||
|
public function __construct(ShaarliContainer $container)
|
||||||
|
{
|
||||||
|
$this->container = $container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware execution:
|
||||||
|
* - run updates
|
||||||
|
* - if not logged in open shaarli, redirect to login
|
||||||
|
* - execute the controller
|
||||||
|
* - return the response
|
||||||
|
*
|
||||||
|
* In case of error, the error template will be displayed with the exception message.
|
||||||
|
*
|
||||||
|
* @param Request $request Slim request
|
||||||
|
* @param Response $response Slim response
|
||||||
|
* @param callable $next Next action
|
||||||
|
*
|
||||||
|
* @return Response response.
|
||||||
|
*/
|
||||||
|
public function __invoke(Request $request, Response $response, callable $next): Response
|
||||||
|
{
|
||||||
|
$this->initBasePath($request);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
!is_file($this->container->conf->getConfigFileExt())
|
||||||
|
&& !in_array($next->getName(), ['displayInstall', 'saveInstall'], true)
|
||||||
|
) {
|
||||||
|
return $response->withRedirect($this->container->basePath . '/install');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->runUpdates();
|
||||||
|
$this->checkOpenShaarli($request, $response, $next);
|
||||||
|
|
||||||
|
return $next($request, $response);
|
||||||
|
} catch (UnauthorizedException $e) {
|
||||||
|
$returnUrl = urlencode($this->container->environment['REQUEST_URI']);
|
||||||
|
|
||||||
|
return $response->withRedirect($this->container->basePath . '/login?returnurl=' . $returnUrl);
|
||||||
|
}
|
||||||
|
// Other exceptions are handled by ErrorController
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the updater for every requests processed while logged in.
|
||||||
|
*/
|
||||||
|
protected function runUpdates(): void
|
||||||
|
{
|
||||||
|
if ($this->container->loginManager->isLoggedIn() !== true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->container->updater->setBasePath($this->container->basePath);
|
||||||
|
$newUpdates = $this->container->updater->update();
|
||||||
|
if (!empty($newUpdates)) {
|
||||||
|
$this->container->updater->writeUpdates(
|
||||||
|
$this->container->conf->get('resource.updates'),
|
||||||
|
$this->container->updater->getDoneUpdates()
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->container->pageCacheManager->invalidateCaches();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access is denied to most pages with `hide_public_links` + `force_login` settings.
|
||||||
|
*/
|
||||||
|
protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
// if the user isn't logged in
|
||||||
|
!$this->container->loginManager->isLoggedIn()
|
||||||
|
// and Shaarli doesn't have public content...
|
||||||
|
&& $this->container->conf->get('privacy.hide_public_links')
|
||||||
|
// and is configured to enforce the login
|
||||||
|
&& $this->container->conf->get('privacy.force_login')
|
||||||
|
// and the current page isn't already the login page
|
||||||
|
// and the user is not requesting a feed (which would lead to a different content-type as expected)
|
||||||
|
&& !in_array($next->getName(), ['login', 'processLogin', 'atom', 'rss'], true)
|
||||||
|
) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the URL base path if it hasn't been defined yet.
|
||||||
|
*/
|
||||||
|
protected function initBasePath(Request $request): void
|
||||||
|
{
|
||||||
|
if (null === $this->container->basePath) {
|
||||||
|
$this->container->basePath = rtrim($request->getUri()->getBasePath(), '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
132
application/front/controller/admin/ConfigureController.php
Normal file
132
application/front/controller/admin/ConfigureController.php
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Shaarli\Front\Controller\Admin;
|
||||||
|
|
||||||
|
use Shaarli\Languages;
|
||||||
|
use Shaarli\Render\TemplatePage;
|
||||||
|
use Shaarli\Render\ThemeUtils;
|
||||||
|
use Shaarli\Thumbnailer;
|
||||||
|
use Slim\Http\Request;
|
||||||
|
use Slim\Http\Response;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ConfigureController
|
||||||
|
*
|
||||||
|
* Slim controller used to handle Shaarli configuration page (display + save new config).
|
||||||
|
*/
|
||||||
|
class ConfigureController extends ShaarliAdminController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /admin/configure - Displays the configuration page
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$this->assignView('title', $this->container->conf->get('general.title', 'Shaarli'));
|
||||||
|
$this->assignView('theme', $this->container->conf->get('resource.theme'));
|
||||||
|
$this->assignView(
|
||||||
|
'theme_available',
|
||||||
|
ThemeUtils::getThemes($this->container->conf->get('resource.raintpl_tpl'))
|
||||||
|
);
|
||||||
|
$this->assignView('formatter_available', ['default', 'markdown', 'markdownExtra']);
|
||||||
|
list($continents, $cities) = generateTimeZoneData(
|
||||||
|
timezone_identifiers_list(),
|
||||||
|
$this->container->conf->get('general.timezone')
|
||||||
|
);
|
||||||
|
$this->assignView('continents', $continents);
|
||||||
|
$this->assignView('cities', $cities);
|
||||||
|
$this->assignView('retrieve_description', $this->container->conf->get('general.retrieve_description', false));
|
||||||
|
$this->assignView('private_links_default', $this->container->conf->get('privacy.default_private_links', false));
|
||||||
|
$this->assignView(
|
||||||
|
'session_protection_disabled',
|
||||||
|
$this->container->conf->get('security.session_protection_disabled', false)
|
||||||
|
);
|
||||||
|
$this->assignView('enable_rss_permalinks', $this->container->conf->get('feed.rss_permalinks', false));
|
||||||
|
$this->assignView('enable_update_check', $this->container->conf->get('updates.check_updates', true));
|
||||||
|
$this->assignView('hide_public_links', $this->container->conf->get('privacy.hide_public_links', false));
|
||||||
|
$this->assignView('api_enabled', $this->container->conf->get('api.enabled', true));
|
||||||
|
$this->assignView('api_secret', $this->container->conf->get('api.secret'));
|
||||||
|
$this->assignView('languages', Languages::getAvailableLanguages());
|
||||||
|
$this->assignView('gd_enabled', extension_loaded('gd'));
|
||||||
|
$this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE));
|
||||||
|
$this->assignView(
|
||||||
|
'pagetitle',
|
||||||
|
t('Configure') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
|
||||||
|
);
|
||||||
|
|
||||||
|
return $response->write($this->render(TemplatePage::CONFIGURE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/configure - Update Shaarli's configuration
|
||||||
|
*/
|
||||||
|
public function save(Request $request, Response $response): Response
|
||||||
|
{
|
||||||
|
$this->checkToken($request);
|
||||||
|
|
||||||
|
$continent = $request->getParam('continent');
|
||||||
|
$city = $request->getParam('city');
|
||||||
|
$tz = 'UTC';
|
||||||
|
if (null !== $continent && null !== $city && isTimeZoneValid($continent, $city)) {
|
||||||
|
$tz = $continent . '/' . $city;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->container->conf->set('general.timezone', $tz);
|
||||||
|
$this->container->conf->set('general.title', escape($request->getParam('title')));
|
||||||
|
$this->container->conf->set('general.header_link', escape($request->getParam('titleLink')));
|
||||||
|
$this->container->conf->set('general.retrieve_description', !empty($request->getParam('retrieveDescription')));
|
||||||
|
$this->container->conf->set('resource.theme', escape($request->getParam('theme')));
|
||||||
|
$this->container->conf->set(
|
||||||
|
'security.session_protection_disabled',
|
||||||
|
!empty($request->getParam('disablesessionprotection'))
|
||||||
|
);
|
||||||
|
$this->container->conf->set(
|
||||||
|
'privacy.default_private_links',
|
||||||
|
!empty($request->getParam('privateLinkByDefault'))
|
||||||
|
);
|
||||||
|
$this->container->conf->set('feed.rss_permalinks', !empty($request->getParam('enableRssPermalinks')));
|
||||||
|
$this->container->conf->set('updates.check_updates', !empty($request->getParam('updateCheck')));
|
||||||
|
$this->container->conf->set('privacy.hide_public_links', !empty($request->getParam('hidePublicLinks')));
|
||||||
|
$this->container->conf->set('api.enabled', !empty($request->getParam('enableApi')));
|
||||||
|
$this->container->conf->set('api.secret', escape($request->getParam('apiSecret')));
|
||||||
|
$this->container->conf->set('formatter', escape($request->getParam('formatter')));
|
||||||
|
|
||||||
|
if (!empty($request->getParam('language'))) {
|
||||||
|
$this->container->conf->set('translation.language', escape($request->getParam('language')));
|
||||||
|
}
|
||||||
|
|
||||||
|
$thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE;
|
||||||
|
if (
|
||||||
|
$thumbnailsMode !== Thumbnailer::MODE_NONE
|
||||||
|
&& $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)
|
||||||
|
) {
|
||||||
|
$this->saveWarningMessage(
|
||||||
|
t('You have enabled or changed thumbnails mode.') .
|
||||||
|
'<a href="' . $this->container->basePath . '/admin/thumbnails">' .
|
||||||
|
t('Please synchronize them.') .
|
||||||
|
'</a>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$this->container->conf->set('thumbnails.mode', $thumbnailsMode);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->container->conf->write($this->container->loginManager->isLoggedIn());
|
||||||
|
$this->container->history->updateSettings();
|
||||||
|
$this->container->pageCacheManager->invalidateCaches();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->assignView('message', t('Error while writing config file after configuration update.'));
|
||||||
|
|
||||||
|
if ($this->container->conf->get('dev.debug', false)) {
|
||||||
|
$this->assignView('stacktrace', $e->getMessage() . PHP_EOL . $e->getTraceAsString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response->write($this->render('error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->saveSuccessMessage(t('Configuration was saved.'));
|
||||||
|
|
||||||
|
return $this->redirect($response, '/admin/configure');
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue