Compare commits
2271 Commits
claro-thro
...
app-rootle
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b300efd439 | ||
|
|
8a8ce06965 | ||
|
|
5de5361703 | ||
|
|
2636d4dace | ||
|
|
0163884ef6 | ||
|
|
9c44af354f | ||
|
|
78c903cb7f | ||
|
|
0f0aa30746 | ||
|
|
ca75ac8a56 | ||
|
|
c457b08070 | ||
|
|
3619ee97c5 | ||
|
|
ec0b4306a9 | ||
|
|
0d3c5049c1 | ||
|
|
dff0a8ccbb | ||
|
|
16dd42c3d6 | ||
|
|
d91b7c339d | ||
|
|
48db6beca7 | ||
|
|
913ba654bd | ||
|
|
d5b4e5ff72 | ||
|
|
1fb202b258 | ||
|
|
11acb07def | ||
|
|
d5d47b8e50 | ||
|
|
1570fb4f15 | ||
|
|
bce2722b12 | ||
|
|
89489f622d | ||
|
|
fd19bfd0dc | ||
|
|
e07f8dc7db | ||
|
|
207d3dd48d | ||
|
|
a10f45cf67 | ||
|
|
8423183e2f | ||
|
|
d17f90b96f | ||
|
|
94c43fe979 | ||
|
|
d94e21c884 | ||
|
|
b8cbb167d4 | ||
|
|
99e444d1d2 | ||
|
|
b626a61461 | ||
|
|
8cceaa873a | ||
|
|
d167d5803f | ||
|
|
10a431b8b5 | ||
|
|
3496402686 | ||
|
|
9dd4102c7f | ||
|
|
6b521b5ed1 | ||
|
|
3c53a0b5dc | ||
|
|
b98ed32e95 | ||
|
|
29cc61d1e3 | ||
|
|
19c0909337 | ||
|
|
0caf1ef850 | ||
|
|
1346767dfb | ||
|
|
8f448685be | ||
|
|
9d37158d8c | ||
|
|
fd9eabdfdb | ||
|
|
7f3129d4f3 | ||
|
|
71c0d319aa | ||
|
|
6e715bc154 | ||
|
|
8f20c1a7ca | ||
|
|
dc0d1b93d2 | ||
|
|
5ca874aa9b | ||
|
|
c3013d04e5 | ||
|
|
6ed766c7fe | ||
|
|
44257b8016 | ||
|
|
c7cc3c92ba | ||
|
|
8fe28cfcb8 | ||
|
|
a758d287ff | ||
|
|
f2db34a707 | ||
|
|
b560ef7d05 | ||
|
|
acf3748621 | ||
|
|
0ce4ae3ece | ||
|
|
59cf218144 | ||
|
|
42051f3bad | ||
|
|
d5eec31d6b | ||
|
|
c416fdff07 | ||
|
|
44906b8268 | ||
|
|
1463d28cb6 | ||
|
|
4a399915a5 | ||
|
|
4f581e53bf | ||
|
|
855da3f87f | ||
|
|
2df5c79f68 | ||
|
|
6baea4cbcf | ||
|
|
2b5ec065a7 | ||
|
|
342d62da66 | ||
|
|
49ef5a929b | ||
|
|
ea06455b3f | ||
|
|
df8403be08 | ||
|
|
db3e67b3fe | ||
|
|
d7a6f74ae5 | ||
|
|
88806115a6 | ||
|
|
b0bcddd6da | ||
|
|
00b0f90238 | ||
|
|
907c380c12 | ||
|
|
611910e181 | ||
|
|
e3acfb90b0 | ||
|
|
9993e90da6 | ||
|
|
83f05888c7 | ||
|
|
09230bcded | ||
|
|
fa18ecf58f | ||
|
|
9d50d95fa3 | ||
|
|
b3c085972b | ||
|
|
99ea9d2c25 | ||
|
|
cc51487f08 | ||
|
|
9e6684e927 | ||
|
|
bcbbd53a8f | ||
|
|
4137e7f473 | ||
|
|
8b037e81d8 | ||
|
|
5e7713a658 | ||
|
|
e2e161dece | ||
|
|
7a5ea2a2b9 | ||
|
|
d832907125 | ||
|
|
16b89cc293 | ||
|
|
b1e3d660e4 | ||
|
|
ac55a15c84 | ||
|
|
ae5e7568f5 | ||
|
|
435c321caa | ||
|
|
de00a09538 | ||
|
|
fea3089bde | ||
|
|
9743f0efcd | ||
|
|
66db7dc0ac | ||
|
|
536085c764 | ||
|
|
81f3139992 | ||
|
|
fc95c988cf | ||
|
|
6a51afcfcb | ||
|
|
ea732aa55f | ||
|
|
d19729157b | ||
|
|
7602038264 | ||
|
|
e85b27a61c | ||
|
|
924496e148 | ||
|
|
78ce18b0e8 | ||
|
|
dad3646876 | ||
|
|
244146fac7 | ||
|
|
05da9ca742 | ||
|
|
373a2fec3a | ||
|
|
1dbc4dc475 | ||
|
|
528fad51fb | ||
|
|
a5b19e5ff5 | ||
|
|
a56e935deb | ||
|
|
3d70fb21f7 | ||
|
|
19563f23af | ||
|
|
a994db2b5a | ||
|
|
c7e36e1a0c | ||
|
|
21aebd8ff1 | ||
|
|
a86df7eac8 | ||
|
|
03c9d4f390 | ||
|
|
283ad4ebea | ||
|
|
d334023267 | ||
|
|
8ef2803b27 | ||
|
|
de214a01d2 | ||
|
|
bcdfedeb8a | ||
|
|
ea6cdcccb0 | ||
|
|
8727fb3ba8 | ||
|
|
f0f22c23c5 | ||
|
|
90e7bf7cc3 | ||
|
|
a882eb13f7 | ||
|
|
91a91dac15 | ||
|
|
51cd02fc3e | ||
|
|
0ea9db3170 | ||
|
|
9a1f7c2ebf | ||
|
|
3c171cc92c | ||
|
|
e33b0297d5 | ||
|
|
9132360d46 | ||
|
|
d82da74363 | ||
|
|
ff59fbd460 | ||
|
|
e85d47dfd4 | ||
|
|
d4ae6c67db | ||
|
|
f1a9ac9b15 | ||
|
|
67012f9dac | ||
|
|
14ad8b21d5 | ||
|
|
4b3cf17d8d | ||
|
|
1b31e6fd5b | ||
|
|
7883f024e7 | ||
|
|
8f66f579e4 | ||
|
|
09898ccbc8 | ||
|
|
2b8e344532 | ||
|
|
e453befab6 | ||
|
|
dbb6e7291e | ||
|
|
eac9e7c103 | ||
|
|
93bd96e356 | ||
|
|
7005d6d5f3 | ||
|
|
0621d22bbe | ||
|
|
cc133d2c0a | ||
|
|
e52eaf0e7b | ||
|
|
ce9847d317 | ||
|
|
d4da4dcc32 | ||
|
|
2c7e000120 | ||
|
|
1fe1132a1a | ||
|
|
61910acbcd | ||
|
|
ff4248b09e | ||
|
|
0b7d021f8e | ||
|
|
d4c972f551 | ||
|
|
f48f1b0131 | ||
|
|
4ced03b4b6 | ||
|
|
9ce347d8d5 | ||
|
|
e777f2e292 | ||
|
|
ee18936bfe | ||
|
|
5cfde4cada | ||
|
|
1be156408a | ||
|
|
cfcab96e18 | ||
|
|
7cd2c5cac8 | ||
|
|
adf3985afa | ||
|
|
afaef66783 | ||
|
|
8b72d9ab11 | ||
|
|
855695a862 | ||
|
|
0ac8710ea1 | ||
|
|
01c9869e2b | ||
|
|
d2424b9e4b | ||
|
|
a1a2fe40f6 | ||
|
|
925256c81f | ||
|
|
5a7c5b8249 | ||
|
|
5920ac814c | ||
|
|
2af5f73480 | ||
|
|
c7e1caf223 | ||
|
|
8c9c69921f | ||
|
|
3181272619 | ||
|
|
865ecc8796 | ||
|
|
0a5507d3bd | ||
|
|
69c1c62992 | ||
|
|
de2830b241 | ||
|
|
ed43a73369 | ||
|
|
e31636bf97 | ||
|
|
3d5308a6e5 | ||
|
|
30b36e0034 | ||
|
|
1e3b7f7a43 | ||
|
|
994f376f42 | ||
|
|
deb441e9e3 | ||
|
|
9826d2f075 | ||
|
|
e956632c5c | ||
|
|
7af2938aea | ||
|
|
c28955c8ba | ||
|
|
a7f3543516 | ||
|
|
761c3826d1 | ||
|
|
de39d97e1f | ||
|
|
1bfae41e6d | ||
|
|
efd5d79dde | ||
|
|
db05575b2d | ||
|
|
ce3eb32076 | ||
|
|
752c692170 | ||
|
|
8d3f570ee9 | ||
|
|
7bba4ae558 | ||
|
|
382d01e8db | ||
|
|
487635ca28 | ||
|
|
bde94dbf4b | ||
|
|
322296d6a0 | ||
|
|
ccb4a4d337 | ||
|
|
b0f96dbb5a | ||
|
|
aec8cdd0c8 | ||
|
|
cb90393a7e | ||
|
|
028afdd7d5 | ||
|
|
6b1b496248 | ||
|
|
d744209df7 | ||
|
|
eac076fcd6 | ||
|
|
e7ddbbb2ce | ||
|
|
ff818a75f0 | ||
|
|
03e956132d | ||
|
|
2b61052e87 | ||
|
|
cf18bc576e | ||
|
|
3bf275e445 | ||
|
|
492c4eecfb | ||
|
|
93bb473bce | ||
|
|
6e025103d3 | ||
|
|
350177df39 | ||
|
|
d3fadc0bd0 | ||
|
|
bf6e3c381b | ||
|
|
7092a1e85d | ||
|
|
62ca093b75 | ||
|
|
cdd7ad020e | ||
|
|
45a9ff0c88 | ||
|
|
6c75ea17da | ||
|
|
b07ad642de | ||
|
|
56315b39b4 | ||
|
|
89f5af62d8 | ||
|
|
9556519e67 | ||
|
|
c779e2ba0d | ||
|
|
40df94c169 | ||
|
|
e29fe626e1 | ||
|
|
b15f185e3d | ||
|
|
f489f620d0 | ||
|
|
dd6ac57a07 | ||
|
|
03526d8151 | ||
|
|
8535305cfc | ||
|
|
afd04d141c | ||
|
|
485bfe327a | ||
|
|
e2ab00c889 | ||
|
|
83f5ab5c79 | ||
|
|
faefedb950 | ||
|
|
adba0aa8d2 | ||
|
|
ba6a912abd | ||
|
|
bd95325f8d | ||
|
|
1d788eddf8 | ||
|
|
3d255d861c | ||
|
|
dc25a9cf68 | ||
|
|
a9d8fd8bdc | ||
|
|
d43d6f7dff | ||
|
|
a28d9582e8 | ||
|
|
718af52a1b | ||
|
|
d26309b1e5 | ||
|
|
3468317bd3 | ||
|
|
af3e9eb4a0 | ||
|
|
5bfd18d3e6 | ||
|
|
a4543de3ac | ||
|
|
b1187f0db6 | ||
|
|
11946f0148 | ||
|
|
3de09b44f2 | ||
|
|
0578bf8025 | ||
|
|
1e90feef0e | ||
|
|
c314bd8742 | ||
|
|
103fdd5e60 | ||
|
|
7a54154d45 | ||
|
|
27bd226f2b | ||
|
|
15c9dbe270 | ||
|
|
2420feb91f | ||
|
|
8ccea1712e | ||
|
|
f3f2e7d043 | ||
|
|
6920c44587 | ||
|
|
566d164053 | ||
|
|
06aabdf60b | ||
|
|
4e17fac8b7 | ||
|
|
7dc83961bd | ||
|
|
b0fc248c05 | ||
|
|
6ec01203a1 | ||
|
|
44137342a6 | ||
|
|
fd5e0f98c4 | ||
|
|
e18295a364 | ||
|
|
d68c736e47 | ||
|
|
6418157ccf | ||
|
|
d7c070b22b | ||
|
|
c1b3c99667 | ||
|
|
8f3646a9c9 | ||
|
|
a37eab2610 | ||
|
|
431b8778ed | ||
|
|
c4edc93182 | ||
|
|
028bb846d9 | ||
|
|
31ef788e02 | ||
|
|
ae27d51197 | ||
|
|
a60c833ee4 | ||
|
|
0fcc2d1d66 | ||
|
|
152545b3c9 | ||
|
|
881f8805bd | ||
|
|
53bd56894d | ||
|
|
af5c64045b | ||
|
|
0fcc715069 | ||
|
|
fd684dae29 | ||
|
|
58450486a0 | ||
|
|
4a60652be9 | ||
|
|
521ac622e4 | ||
|
|
f0e0f7d5f5 | ||
|
|
c2d8ba5973 | ||
|
|
807f914338 | ||
|
|
fd98d6d117 | ||
|
|
92185933f9 | ||
|
|
4fd0d13b64 | ||
|
|
e2a02f1f4b | ||
|
|
00d2cb0f93 | ||
|
|
2b01786124 | ||
|
|
088dd049b5 | ||
|
|
28a911a2a8 | ||
|
|
066b9a29d7 | ||
|
|
269c0f53b8 | ||
|
|
80bd26b3b1 | ||
|
|
3ca3e54251 | ||
|
|
7795c415ab | ||
|
|
cf656125b9 | ||
|
|
740d249aba | ||
|
|
dfffa20e78 | ||
|
|
d25440f051 | ||
|
|
da620ef5d8 | ||
|
|
ecedc51162 | ||
|
|
33a3672eab | ||
|
|
a0b52caced | ||
|
|
37014d78fd | ||
|
|
0c42d99a93 | ||
|
|
c41c1bd353 | ||
|
|
d40b6c655f | ||
|
|
6cf4ebbabd | ||
|
|
723323d746 | ||
|
|
d305532bce | ||
|
|
903b9dbb8b | ||
|
|
8e490af01c | ||
|
|
ca86a0e239 | ||
|
|
563675de09 | ||
|
|
0f9488ace0 | ||
|
|
cddbf5bf5a | ||
|
|
b14a8a76eb | ||
|
|
c4026b408b | ||
|
|
c923fda8c9 | ||
|
|
fe08299ec4 | ||
|
|
029cb8f442 | ||
|
|
42b287e964 | ||
|
|
dabb85c7dd | ||
|
|
7ed4fa4c1d | ||
|
|
c4b16ca608 | ||
|
|
c48dd6a3c4 | ||
|
|
d34d01fd72 | ||
|
|
d210ae50ad | ||
|
|
b7a6c948d0 | ||
|
|
04c2fa9f15 | ||
|
|
4d825fa6a6 | ||
|
|
33c20d42df | ||
|
|
aa2b770e30 | ||
|
|
a2af3a6bb4 | ||
|
|
fcfcb69a2e | ||
|
|
fd55e492c3 | ||
|
|
7bbe71b818 | ||
|
|
40afee5d12 | ||
|
|
0cd4abe4eb | ||
|
|
1646aba944 | ||
|
|
b28d339bf2 | ||
|
|
f484988967 | ||
|
|
380624a484 | ||
|
|
f0f7a5f958 | ||
|
|
c30b24d09f | ||
|
|
5c0a5da88c | ||
|
|
a16acd65fc | ||
|
|
2be8d58509 | ||
|
|
9c0ead3640 | ||
|
|
4c94139113 | ||
|
|
b64ec219b9 | ||
|
|
22f8a22748 | ||
|
|
371af1a39c | ||
|
|
fb4bc2615e | ||
|
|
46e2635869 | ||
|
|
423b26afc5 | ||
|
|
8b129626cd | ||
|
|
c6d21b3196 | ||
|
|
d373b7b452 | ||
|
|
20d6aaa9ab | ||
|
|
8ea537123d | ||
|
|
313f12ae93 | ||
|
|
457553eeac | ||
|
|
0317828847 | ||
|
|
72e64bdb78 | ||
|
|
fa9c614ff1 | ||
|
|
824addbc9d | ||
|
|
292ca86665 | ||
|
|
a355221e7f | ||
|
|
94c49399cc | ||
|
|
52180c9f8f | ||
|
|
3212c51ce8 | ||
|
|
a30b9bb649 | ||
|
|
be6bc72a74 | ||
|
|
3180b35807 | ||
|
|
9732d8fc9f | ||
|
|
10a1dd35e3 | ||
|
|
30c04adfa6 | ||
|
|
cb2f1ac2d9 | ||
|
|
9a0dcdd6cc | ||
|
|
d376cd6142 | ||
|
|
602e868425 | ||
|
|
f56a049641 | ||
|
|
b702761941 | ||
|
|
7eb711eefa | ||
|
|
017bf9777f | ||
|
|
bbb47b5d62 | ||
|
|
68dee45782 | ||
|
|
c654f02a53 | ||
|
|
6cbdbd261e | ||
|
|
54942b7664 | ||
|
|
0ac143a29b | ||
|
|
560caf8377 | ||
|
|
504d0afd35 | ||
|
|
42bc1620b8 | ||
|
|
3545d3ba83 | ||
|
|
9437b45569 | ||
|
|
f0a20a62c7 | ||
|
|
e2f9a3b9a4 | ||
|
|
051fc29b55 | ||
|
|
359f0af2e7 | ||
|
|
d47b8c8494 | ||
|
|
96595ca4c5 | ||
|
|
5fea1a7ea9 | ||
|
|
a2c8c92f62 | ||
|
|
f0f44c6ea5 | ||
|
|
f490bdd17a | ||
|
|
413d824f23 | ||
|
|
e8b3cdcf4a | ||
|
|
c0e77241d3 | ||
|
|
70f500bee9 | ||
|
|
aca16a3448 | ||
|
|
3f97b8fdb9 | ||
|
|
1358746100 | ||
|
|
a49e3af55a | ||
|
|
60658be5bc | ||
|
|
ec764f97e7 | ||
|
|
0dbed700ef | ||
|
|
7d77edd1fb | ||
|
|
3b7174788d | ||
|
|
830a20debf | ||
|
|
57e31fe5a7 | ||
|
|
c301053965 | ||
|
|
3487c922b3 | ||
|
|
a63c949a55 | ||
|
|
6e01d5d930 | ||
|
|
7567676ed8 | ||
|
|
93fd85df6f | ||
|
|
ed2cbeffcc | ||
|
|
26c67dba77 | ||
|
|
d5c043e846 | ||
|
|
ff18453205 | ||
|
|
ff7e99b986 | ||
|
|
a8b0bce008 | ||
|
|
7187ab859d | ||
|
|
4aefbd628e | ||
|
|
cbf710161d | ||
|
|
e507d006fd | ||
|
|
6a68ed0208 | ||
|
|
4e02dc0ab5 | ||
|
|
7c45b3f789 | ||
|
|
c0385c2098 | ||
|
|
74d7f88fae | ||
|
|
8cf421e1fc | ||
|
|
5006c754c4 | ||
|
|
f7b3c50828 | ||
|
|
7da7de6e7a | ||
|
|
8f19423c22 | ||
|
|
f3aceb4648 | ||
|
|
b8c1d622a7 | ||
|
|
fdd1c43612 | ||
|
|
96f704d157 | ||
|
|
5c70d26b7e | ||
|
|
80d3db1dcf | ||
|
|
4b61618920 | ||
|
|
d9861038bc | ||
|
|
f8fe5e02f1 | ||
|
|
7e5453b3aa | ||
|
|
d9ae4204ce | ||
|
|
3d55db6a53 | ||
|
|
9d69fd2a56 | ||
|
|
b148d2f515 | ||
|
|
0bb72fbb26 | ||
|
|
50f014e52d | ||
|
|
6d98cc6c80 | ||
|
|
9428e2c571 | ||
|
|
7e36f6e4c4 | ||
|
|
747899c211 | ||
|
|
59b0ae8af2 | ||
|
|
53bc39fc20 | ||
|
|
184efcf3f5 | ||
|
|
c2f7044485 | ||
|
|
d4be821825 | ||
|
|
8632c39eb2 | ||
|
|
65f341fbf4 | ||
|
|
25b71b90b2 | ||
|
|
cf1eaeedf3 | ||
|
|
f82085ea9b | ||
|
|
b7a7673d24 | ||
|
|
2975c7297b | ||
|
|
6555ee811d | ||
|
|
7cd26272fa | ||
|
|
8151295829 | ||
|
|
8ef816d8f8 | ||
|
|
6436dd16f9 | ||
|
|
c9c9e4a9ea | ||
|
|
082b3386e9 | ||
|
|
dd983e5de1 | ||
|
|
fc84712135 | ||
|
|
185234bc67 | ||
|
|
9457bb090a | ||
|
|
d391a01de7 | ||
|
|
5adedcd3d0 | ||
|
|
484ab26a3b | ||
|
|
b0059d3f88 | ||
|
|
09fb2273e6 | ||
|
|
9e557501fa | ||
|
|
b91ffae292 | ||
|
|
1b3e655f89 | ||
|
|
1152b2454e | ||
|
|
3406a16025 | ||
|
|
d33d026b12 | ||
|
|
659ad8537a | ||
|
|
68e49203d1 | ||
|
|
d781354539 | ||
|
|
715ff2145b | ||
|
|
b17b4a4b9e | ||
|
|
ddb6f4316c | ||
|
|
06940cdd6e | ||
|
|
c9de9f55bc | ||
|
|
f41ab8e0c8 | ||
|
|
d3160af52d | ||
|
|
1b1236dc49 | ||
|
|
2654b3c6be | ||
|
|
41a52c8333 | ||
|
|
585f37e418 | ||
|
|
4250386ba5 | ||
|
|
ea4bbd7a46 | ||
|
|
fdd1831c5d | ||
|
|
385da287d8 | ||
|
|
0345e9d3f6 | ||
|
|
ee54904274 | ||
|
|
e35a4a1306 | ||
|
|
1c4f7ab3b8 | ||
|
|
7116629487 | ||
|
|
1703850215 | ||
|
|
6f14a5b261 | ||
|
|
451556b171 | ||
|
|
95431890be | ||
|
|
de1e218a83 | ||
|
|
a395574516 | ||
|
|
39c0bd378a | ||
|
|
806b46d0c4 | ||
|
|
bd0af3ae9e | ||
|
|
9a5c21630b | ||
|
|
f7e2f62022 | ||
|
|
77f39d65b5 | ||
|
|
168dc6fe57 | ||
|
|
74a247fc5c | ||
|
|
89ef98e57e | ||
|
|
079f6dfdd0 | ||
|
|
6738f5c86b | ||
|
|
9282804b35 | ||
|
|
a7d6ead956 | ||
|
|
cc30198b3d | ||
|
|
4e35c44add | ||
|
|
6077175c57 | ||
|
|
f2d71d62b7 | ||
|
|
b59bde7b45 | ||
|
|
931e33c381 | ||
|
|
14b1a037ff | ||
|
|
478c9b64a9 | ||
|
|
c57ebf2c10 | ||
|
|
b4684ed462 | ||
|
|
fc432f95f7 | ||
|
|
56fd06d611 | ||
|
|
765dff83f2 | ||
|
|
49d93989af | ||
|
|
a769ccc51c | ||
|
|
3cdbd4422c | ||
|
|
f1759786d7 | ||
|
|
d3f26cc4a6 | ||
|
|
aaccf89501 | ||
|
|
420782418d | ||
|
|
304845f380 | ||
|
|
8cf9c451dc | ||
|
|
4a4928ea25 | ||
|
|
ac6a34f097 | ||
|
|
f1607902e6 | ||
|
|
c3482fbe6b | ||
|
|
1ff52bff81 | ||
|
|
97baf3e8b9 | ||
|
|
1818fc11a5 | ||
|
|
6971ca08b2 | ||
|
|
7aeaa1b039 | ||
|
|
40b2356be2 | ||
|
|
92747b1d21 | ||
|
|
720b318796 | ||
|
|
8a645892a6 | ||
|
|
3ef9bb5b58 | ||
|
|
0726a9d820 | ||
|
|
ddc81b2c89 | ||
|
|
ed74c43f18 | ||
|
|
7109871452 | ||
|
|
471f97ca82 | ||
|
|
53061d1508 | ||
|
|
57b0413a3a | ||
|
|
6a70f5e92c | ||
|
|
78e82b6043 | ||
|
|
987870074b | ||
|
|
0269c7ce32 | ||
|
|
5df8dacf9f | ||
|
|
be94a3a791 | ||
|
|
a201e10ee0 | ||
|
|
aaebe55456 | ||
|
|
a72e24343b | ||
|
|
72e21f89ce | ||
|
|
14027ae04e | ||
|
|
df7b2e7984 | ||
|
|
409c63dcf8 | ||
|
|
28fb571dca | ||
|
|
d0cf7a3d97 | ||
|
|
831648e3c8 | ||
|
|
204f92b926 | ||
|
|
a109e89983 | ||
|
|
a6cad5cbca | ||
|
|
9dabfbfa11 | ||
|
|
17addd1237 | ||
|
|
7cfc30ac25 | ||
|
|
3323ae78ce | ||
|
|
e7111e4f14 | ||
|
|
2b4ba59a79 | ||
|
|
0a3a464def | ||
|
|
3070933f64 | ||
|
|
e5ec69f45a | ||
|
|
59a09790eb | ||
|
|
739c3fe760 | ||
|
|
4a891b20f0 | ||
|
|
d532eb773b | ||
|
|
cd71292610 | ||
|
|
16a7208893 | ||
|
|
2422aae577 | ||
|
|
57d9a5e925 | ||
|
|
d6f604c06c | ||
|
|
1ea177491f | ||
|
|
b2ffc8c2e3 | ||
|
|
9ac67c7973 | ||
|
|
b77f6c9a6b | ||
|
|
9e469b1642 | ||
|
|
10a1d4d879 | ||
|
|
63ec5a8965 | ||
|
|
2d830c6281 | ||
|
|
fb1e85baaf | ||
|
|
aeb4137cbd | ||
|
|
3e273ea527 | ||
|
|
34f294414f | ||
|
|
bd66eff7cc | ||
|
|
938f7db482 | ||
|
|
5980b3d2cb | ||
|
|
10d1a8c05a | ||
|
|
ad30d39e2a | ||
|
|
4166628c36 | ||
|
|
6a8030fd76 | ||
|
|
d78ba7b3a9 | ||
|
|
b2952843f5 | ||
|
|
3a3fde1a2e | ||
|
|
8cd69fe15c | ||
|
|
ef1e2a8b2f | ||
|
|
1db1be7a81 | ||
|
|
4c37fa4b41 | ||
|
|
109b702ed0 | ||
|
|
85b974af32 | ||
|
|
26f47c0694 | ||
|
|
aa924d9ee7 | ||
|
|
a92070da06 | ||
|
|
2493c9cddd | ||
|
|
676c5787e7 | ||
|
|
8dfefe7968 | ||
|
|
3bd13b91c8 | ||
|
|
07ea364189 | ||
|
|
edc7998851 | ||
|
|
fb208bb136 | ||
|
|
41b4eef504 | ||
|
|
78acf18b70 | ||
|
|
8943604aad | ||
|
|
324d926eb4 | ||
|
|
12f9df1066 | ||
|
|
8f749fe61b | ||
|
|
6d438c5a77 | ||
|
|
5632d75a45 | ||
|
|
abab2a94e8 | ||
|
|
b8f0627a0e | ||
|
|
56cf425e45 | ||
|
|
c3fbf56984 | ||
|
|
242cf916ef | ||
|
|
5f808051b2 | ||
|
|
f537502fce | ||
|
|
67a89e861d | ||
|
|
9326ed605f | ||
|
|
812f5f532e | ||
|
|
91c9a73532 | ||
|
|
931a7533ce | ||
|
|
80291ffe0c | ||
|
|
cfc31fc692 | ||
|
|
d17b79311e | ||
|
|
afdb4b0072 | ||
|
|
6bd6a14c20 | ||
|
|
f5c881586b | ||
|
|
7988c79bd4 | ||
|
|
1b5c61ac85 | ||
|
|
01b39d985c | ||
|
|
dd7299b6d0 | ||
|
|
55729b4bbd | ||
|
|
af2f4460ce | ||
|
|
c3ffa08807 | ||
|
|
98af46addd | ||
|
|
cf93371607 | ||
|
|
8b743f7249 | ||
|
|
d3d3bceec9 | ||
|
|
9b5d199ad9 | ||
|
|
fe1feca009 | ||
|
|
15af164f69 | ||
|
|
0a2dcacbcf | ||
|
|
81a10f69bc | ||
|
|
5a50c333b2 | ||
|
|
fe5ada7250 | ||
|
|
5e34fe17a7 | ||
|
|
618e96b793 | ||
|
|
03d0692268 | ||
|
|
68d7cf44f9 | ||
|
|
37827427a2 | ||
|
|
9845d5fd15 | ||
|
|
edd476e7fe | ||
|
|
a18473e4c0 | ||
|
|
b37a03fb31 | ||
|
|
f2323bda81 | ||
|
|
70051742af | ||
|
|
b381e95792 | ||
|
|
8a83f061bf | ||
|
|
a7983d475e | ||
|
|
77b8dc7386 | ||
|
|
45431170b6 | ||
|
|
3ba8d964b6 | ||
|
|
1ec003ce35 | ||
|
|
25775bb407 | ||
|
|
d3a81f598b | ||
|
|
2c41bc7fbc | ||
|
|
9db5e402a0 | ||
|
|
011c941e7c | ||
|
|
b0eb347839 | ||
|
|
f0ad5881c0 | ||
|
|
734be4ebd1 | ||
|
|
763515de79 | ||
|
|
5606e38bff | ||
|
|
57bf56f794 | ||
|
|
a0f37c3206 | ||
|
|
95277fd099 | ||
|
|
2d5603b196 | ||
|
|
50997df57a | ||
|
|
cc220058e0 | ||
|
|
728a71150a | ||
|
|
58ea0d4339 | ||
|
|
00b86bac39 | ||
|
|
e4b8e2d063 | ||
|
|
d2ccbecea6 | ||
|
|
2e3a9098b9 | ||
|
|
f704d25ab1 | ||
|
|
03495c11ed | ||
|
|
3f8aaffd34 | ||
|
|
eb068fbc47 | ||
|
|
bf2bb875ab | ||
|
|
14ca0f2ac0 | ||
|
|
0f324b77df | ||
|
|
7a919a79d7 | ||
|
|
bf53dfa515 | ||
|
|
4cc3374f9f | ||
|
|
87a30d88d3 | ||
|
|
9e8d69739f | ||
|
|
7a52560e4e | ||
|
|
57436ee0c1 | ||
|
|
3cc60a0219 | ||
|
|
9dac9c5a0d | ||
|
|
ac5a4f5937 | ||
|
|
a38892d5d7 | ||
|
|
8a920a16e7 | ||
|
|
a7a59fe0e2 | ||
|
|
72cf4f1f0d | ||
|
|
5b17c44e70 | ||
|
|
7d8837ca17 | ||
|
|
77a98134b8 | ||
|
|
1584d00891 | ||
|
|
9714c4fbcf | ||
|
|
76d8b1bf6f | ||
|
|
933913410c | ||
|
|
9f734c9050 | ||
|
|
3b70d1f622 | ||
|
|
2a5c2be6cd | ||
|
|
41245da8a6 | ||
|
|
6fe0751038 | ||
|
|
377e0b812c | ||
|
|
dd30825b94 | ||
|
|
d1ffe6d6cf | ||
|
|
aead30a041 | ||
|
|
a936e80630 | ||
|
|
9e7e0e84d7 | ||
|
|
c6f5902cbc | ||
|
|
a9646b9574 | ||
|
|
145fc31625 | ||
|
|
949e2ab4d2 | ||
|
|
23768709d0 | ||
|
|
8ed927dbd2 | ||
|
|
78ff7770d1 | ||
|
|
012a9fdee3 | ||
|
|
e44f0cb937 | ||
|
|
36e174750e | ||
|
|
b8f82ca12f | ||
|
|
e8f9567d79 | ||
|
|
a1173ab06a | ||
|
|
2c931df77c | ||
|
|
5c60254474 | ||
|
|
235ae4ded2 | ||
|
|
0808123179 | ||
|
|
5ed108dce4 | ||
|
|
28eafa2bcd | ||
|
|
23b4152c9e | ||
|
|
992e9cd9e3 | ||
|
|
b6b6771d8d | ||
|
|
a73e3bec45 | ||
|
|
cf0ec06b8c | ||
|
|
73d14338ab | ||
|
|
aa3aa34c82 | ||
|
|
f458450c17 | ||
|
|
a59401b121 | ||
|
|
88c1132bb8 | ||
|
|
4505bf31b5 | ||
|
|
010e343b70 | ||
|
|
9669bb94de | ||
|
|
44c5d0feba | ||
|
|
cd26dbe64c | ||
|
|
2610afcdb7 | ||
|
|
acbc89b369 | ||
|
|
14d57d9a14 | ||
|
|
7bd9572aa1 | ||
|
|
1d4d3bc49c | ||
|
|
f16fc3bf41 | ||
|
|
93a5ba55d3 | ||
|
|
800ebd6373 | ||
|
|
69f261c41d | ||
|
|
e9c062a189 | ||
|
|
34807bacd4 | ||
|
|
4e9c3500fb | ||
|
|
b3bedd0a94 | ||
|
|
8ed8a10965 | ||
|
|
92c78beb90 | ||
|
|
8e1281b41e | ||
|
|
326850845d | ||
|
|
dff479af64 | ||
|
|
d09a64d6f9 | ||
|
|
8574532b7f | ||
|
|
4795c4a2a9 | ||
|
|
0f51350e9f | ||
|
|
295fc1f88a | ||
|
|
2adf364c2c | ||
|
|
9f6237a1b8 | ||
|
|
57cd8acfc9 | ||
|
|
77031575ab | ||
|
|
983655165e | ||
|
|
6c06a26649 | ||
|
|
f423874e05 | ||
|
|
b5a559a1a7 | ||
|
|
e3c4724dc1 | ||
|
|
82749ee7a7 | ||
|
|
0c38dc8456 | ||
|
|
2ccf0e50a2 | ||
|
|
acf0e0d266 | ||
|
|
b2f888e386 | ||
|
|
fea59de26b | ||
|
|
86300a0ca8 | ||
|
|
d11718c89c | ||
|
|
0574675ed6 | ||
|
|
e8f78181f1 | ||
|
|
88a7130d79 | ||
|
|
e8e4fc641e | ||
|
|
df145c8064 | ||
|
|
c6befcddb7 | ||
|
|
5a71426ea5 | ||
|
|
b3d45a4a5d | ||
|
|
cc634ba91d | ||
|
|
b12072fef9 | ||
|
|
a6c5dda7e0 | ||
|
|
cfd9e6b53b | ||
|
|
0f61675cd0 | ||
|
|
c18383d1ea | ||
|
|
3e22368962 | ||
|
|
eadaaebd58 | ||
|
|
61b4a678ea | ||
|
|
c15c1dfb0b | ||
|
|
a61348e2b7 | ||
|
|
a5af15cfe9 | ||
|
|
49ef15f11d | ||
|
|
c0fba62fa0 | ||
|
|
0acd33abe3 | ||
|
|
0294297ccc | ||
|
|
a4f82fddf4 | ||
|
|
49b25a6430 | ||
|
|
f2f2b6d1f4 | ||
|
|
5d5c034a90 | ||
|
|
0b82afabd5 | ||
|
|
2ed5a79e64 | ||
|
|
8c32ed76df | ||
|
|
ceb8179ccc | ||
|
|
19c277391e | ||
|
|
58ab641fea | ||
|
|
be2d1602bd | ||
|
|
e3c51b0e6c | ||
|
|
c34a4c85bd | ||
|
|
0f6644880a | ||
|
|
98251022d4 | ||
|
|
334a361e79 | ||
|
|
d275134f26 | ||
|
|
2e6d48ead7 | ||
|
|
43744412f4 | ||
|
|
ef5d6b9b78 | ||
|
|
e12a6ca540 | ||
|
|
1f5adf1600 | ||
|
|
68299c914b | ||
|
|
56f7b25e85 | ||
|
|
711e8e70e0 | ||
|
|
718c9f07fa | ||
|
|
43ea36d030 | ||
|
|
ce9955c6ff | ||
|
|
cd52ca80ab | ||
|
|
baf3ecd4cf | ||
|
|
968270ed48 | ||
|
|
541a07250c | ||
|
|
f057c124d1 | ||
|
|
7ea48f7a4b | ||
|
|
b6ae280446 | ||
|
|
db0315e596 | ||
|
|
88534a8ae4 | ||
|
|
82bed1e651 | ||
|
|
7c2b473d12 | ||
|
|
401b22666d | ||
|
|
0f5fd9ea13 | ||
|
|
32c080bec0 | ||
|
|
166517240e | ||
|
|
7a1e1630d8 | ||
|
|
92f859add2 | ||
|
|
a0e41f41a4 | ||
|
|
7ec8a6cad0 | ||
|
|
d9ba403927 | ||
|
|
44b274b6d4 | ||
|
|
c134aa387d | ||
|
|
f81a579386 | ||
|
|
39bbbef030 | ||
|
|
1870fe172b | ||
|
|
b23ba3e236 | ||
|
|
a0ce7f556b | ||
|
|
1664b87821 | ||
|
|
13210747d8 | ||
|
|
15b39a534d | ||
|
|
f7ee812db2 | ||
|
|
1b71cd9f44 | ||
|
|
3d801b1ac5 | ||
|
|
2f402d598d | ||
|
|
38ab3ef11c | ||
|
|
4ddcd54e8d | ||
|
|
06ebb81eb8 | ||
|
|
fa22e1bc35 | ||
|
|
4e81233ac9 | ||
|
|
fcce1c443e | ||
|
|
bc73bf0f67 | ||
|
|
efde6d36c7 | ||
|
|
e85cba5958 | ||
|
|
f9d366f028 | ||
|
|
52d1a5c96d | ||
|
|
580eccd3da | ||
|
|
b9268fcc88 | ||
|
|
96d89fe912 | ||
|
|
bd1630d278 | ||
|
|
76a6060ca3 | ||
|
|
4949e1a590 | ||
|
|
146b1e0feb | ||
|
|
6e0474a7c8 | ||
|
|
f67d2623b7 | ||
|
|
a4da2f1e62 | ||
|
|
755072de91 | ||
|
|
de47082ca6 | ||
|
|
f9a381ecca | ||
|
|
27ab16b6dc | ||
|
|
324aef9f6f | ||
|
|
03361dda34 | ||
|
|
24e64b8c78 | ||
|
|
21e0b28cf1 | ||
|
|
f9a9fcbb56 | ||
|
|
3e1b3e8ea8 | ||
|
|
143617afb1 | ||
|
|
84fe383ed4 | ||
|
|
16726ec07f | ||
|
|
a0dd5baa51 | ||
|
|
5e738ec278 | ||
|
|
71b12857e0 | ||
|
|
353ee40378 | ||
|
|
668b0ac7a6 | ||
|
|
fb89c3bad0 | ||
|
|
5bc47451e1 | ||
|
|
36ad46e60d | ||
|
|
02af69328f | ||
|
|
4aa595e3ba | ||
|
|
96031c80bf | ||
|
|
e826c9e055 | ||
|
|
f58879c1dc | ||
|
|
bdc72e5b63 | ||
|
|
df9c389cbf | ||
|
|
b6033d0bbd | ||
|
|
0b93d8d013 | ||
|
|
089fa5ec26 | ||
|
|
87d13e826f | ||
|
|
eba8c97f36 | ||
|
|
a3ab4020bf | ||
|
|
ddfa39015e | ||
|
|
6ec66d0ce5 | ||
|
|
f804caec90 | ||
|
|
ae7b87bca9 | ||
|
|
2160a86092 | ||
|
|
4e1c78374f | ||
|
|
74391ec30a | ||
|
|
dd9d017f7d | ||
|
|
9b321be270 | ||
|
|
4fe2e6bbf1 | ||
|
|
b1961163b8 | ||
|
|
bc7cb76379 | ||
|
|
63ca6333a5 | ||
|
|
ea25c49eb9 | ||
|
|
fe4c284858 | ||
|
|
9b2267510b | ||
|
|
fed5158ec5 | ||
|
|
cfb4882591 | ||
|
|
28dd255c30 | ||
|
|
bfeaf4d6a4 | ||
|
|
ef03f8188c | ||
|
|
c26f58d8a5 | ||
|
|
a125e8540d | ||
|
|
1fb7125f90 | ||
|
|
46b77fc6b7 | ||
|
|
5db6939dc9 | ||
|
|
603cc89638 | ||
|
|
f4d0e7bb6d | ||
|
|
72c04123d4 | ||
|
|
518e677a6b | ||
|
|
266c8a6eae | ||
|
|
ac6a59914b | ||
|
|
ffb93d72ac | ||
|
|
773bad1490 | ||
|
|
1dcc36deca | ||
|
|
c036c27ec7 | ||
|
|
17650775d2 | ||
|
|
5bb8714839 | ||
|
|
77b5201b7d | ||
|
|
d6fd0d5462 | ||
|
|
39c570a9ff | ||
|
|
b27218a1e3 | ||
|
|
cb81b784e8 | ||
|
|
1d9fa2a42e | ||
|
|
825e362f0e | ||
|
|
7b0b5b55c7 | ||
|
|
68ecf52594 | ||
|
|
473ea6255c | ||
|
|
217922899d | ||
|
|
270f0c3132 | ||
|
|
63651bd91d | ||
|
|
e5469479c1 | ||
|
|
42e057c808 | ||
|
|
53dcd4b229 | ||
|
|
42cb2e5112 | ||
|
|
2e8b064236 | ||
|
|
2cd159e2ce | ||
|
|
2aed79d729 | ||
|
|
ecb94ec23d | ||
|
|
5c1f9f31bd | ||
|
|
fe06416f17 | ||
|
|
98c75a9e43 | ||
|
|
b649d2240f | ||
|
|
c8883d3440 | ||
|
|
bc2953b5e7 | ||
|
|
198c9b4069 | ||
|
|
e8e6329040 | ||
|
|
c744cfe2dc | ||
|
|
d016f7a499 | ||
|
|
476965b161 | ||
|
|
c9b0196de0 | ||
|
|
9442ceb7bd | ||
|
|
f398fea414 | ||
|
|
cb4b730e42 | ||
|
|
386dc415d9 | ||
|
|
9b8b07376f | ||
|
|
f90531ae40 | ||
|
|
6cf771f2bc | ||
|
|
c50a4296a5 | ||
|
|
04128c7870 | ||
|
|
2f6ea8b387 | ||
|
|
b74e313844 | ||
|
|
4fda5ccd0e | ||
|
|
30765805fd | ||
|
|
31b29e0a56 | ||
|
|
8f8ca49e4b | ||
|
|
4ede76280b | ||
|
|
bd4ade6329 | ||
|
|
5eb0f3d640 | ||
|
|
e19570f422 | ||
|
|
c0fb0a5ec0 | ||
|
|
921569e5da | ||
|
|
8256ab5dd9 | ||
|
|
0cb719a404 | ||
|
|
5c6c123676 | ||
|
|
dfdb746a76 | ||
|
|
cb7f322f09 | ||
|
|
06cb181f73 | ||
|
|
75e659ba65 | ||
|
|
0730128a97 | ||
|
|
dbda996a7a | ||
|
|
1aedd22306 | ||
|
|
50087df162 | ||
|
|
adf7189e94 | ||
|
|
3b67abb0ea | ||
|
|
6f93c45c28 | ||
|
|
9ec0732942 | ||
|
|
ba86c64d38 | ||
|
|
c4b78ed0a6 | ||
|
|
57fdf032e9 | ||
|
|
8f8142df29 | ||
|
|
386316aba1 | ||
|
|
1ab6ca57af | ||
|
|
d6629ed188 | ||
|
|
86b12fc06c | ||
|
|
08ff629af5 | ||
|
|
d4ad483add | ||
|
|
982bd838bf | ||
|
|
30b94fb194 | ||
|
|
1a7f724bfa | ||
|
|
20d0cbff77 | ||
|
|
f9888fc67f | ||
|
|
c4eaab8a31 | ||
|
|
7cf12233d7 | ||
|
|
dae0476159 | ||
|
|
2005a7bf4f | ||
|
|
f097ae608d | ||
|
|
3bab5ca6b1 | ||
|
|
f195e86be3 | ||
|
|
84d8b08d1f | ||
|
|
70adfd4a74 | ||
|
|
6f835ded78 | ||
|
|
f56a4eab17 | ||
|
|
372e8e062c | ||
|
|
51ed72efab | ||
|
|
cd504b0e60 | ||
|
|
03400bd8d4 | ||
|
|
031ee47a3e | ||
|
|
b150e46a52 | ||
|
|
cd962dfa00 | ||
|
|
56f658711f | ||
|
|
8b1a2406e6 | ||
|
|
127a868e40 | ||
|
|
f38be747d1 | ||
|
|
f96abd2b52 | ||
|
|
2d1391a02b | ||
|
|
dbad39d7a2 | ||
|
|
6359259dbb | ||
|
|
320503dd39 | ||
|
|
20a844085f | ||
|
|
1e6973307c | ||
|
|
7ef72fe0dc | ||
|
|
b05d4e3d9f | ||
|
|
bf02afed45 | ||
|
|
1bb0d9b603 | ||
|
|
a22ddb2fe0 | ||
|
|
bada1601fc | ||
|
|
f4fdc9c2a3 | ||
|
|
afc7142250 | ||
|
|
e2cbb54b2c | ||
|
|
7f2fe465b0 | ||
|
|
d821e4b090 | ||
|
|
85f411d688 | ||
|
|
15f9cb708e | ||
|
|
de63e3799a | ||
|
|
5832b0b040 | ||
|
|
cf5c7c4f29 | ||
|
|
78a7b3642f | ||
|
|
dfff2cef7b | ||
|
|
5edcbf2e9b | ||
|
|
c1cd3324e3 | ||
|
|
6d06450649 | ||
|
|
126b1fd2de | ||
|
|
c521e26a19 | ||
|
|
d6bb77f452 | ||
|
|
ebf16a36a1 | ||
|
|
ef8c3abd7e | ||
|
|
3fd7856543 | ||
|
|
c6fb62f384 | ||
|
|
bc4475b669 | ||
|
|
cf1ede0ba8 | ||
|
|
1baf8c5217 | ||
|
|
d577eb898c | ||
|
|
c01b6e43fd | ||
|
|
86513d70dd | ||
|
|
bf9033beb6 | ||
|
|
167c9fc34e | ||
|
|
e6a875b7e4 | ||
|
|
4896874bda | ||
|
|
fa7c6a6129 | ||
|
|
b63119df33 | ||
|
|
b5d9b285f1 | ||
|
|
05364e11ed | ||
|
|
cb512d653c | ||
|
|
2a0b3a161c | ||
|
|
ab0bf8692d | ||
|
|
c21fbb2d13 | ||
|
|
15cad4a9c0 | ||
|
|
634f1210a6 | ||
|
|
9a2f893672 | ||
|
|
8d49b6396e | ||
|
|
5794a801f0 | ||
|
|
1dfa699aea | ||
|
|
a6853d2f49 | ||
|
|
26a6177bc9 | ||
|
|
9689f884ab | ||
|
|
05f690c86b | ||
|
|
3ab664f846 | ||
|
|
f3d4bae32e | ||
|
|
51142e1bf8 | ||
|
|
7815a881e8 | ||
|
|
56b10fea18 | ||
|
|
fd9cd52929 | ||
|
|
a1ca62af50 | ||
|
|
22ae284db4 | ||
|
|
281f2efeb8 | ||
|
|
89ad25405e | ||
|
|
8915bd1b21 | ||
|
|
34c74400a4 | ||
|
|
dcf0135285 | ||
|
|
59c14e9c00 | ||
|
|
efd196839a | ||
|
|
1464abbbfc | ||
|
|
f137e64a13 | ||
|
|
c96172fa04 | ||
|
|
5aa05c90e1 | ||
|
|
011e318947 | ||
|
|
6f02b1afd0 | ||
|
|
27b676b7b2 | ||
|
|
7f18e8c33b | ||
|
|
7869378436 | ||
|
|
2f2642bbd4 | ||
|
|
00d0cb8c81 | ||
|
|
2621fe7955 | ||
|
|
bd2314170d | ||
|
|
e858e979e9 | ||
|
|
49a9afadce | ||
|
|
1112922029 | ||
|
|
8026f3c3bd | ||
|
|
988eb3ac91 | ||
|
|
922a699215 | ||
|
|
c70fc68012 | ||
|
|
93940d2a9f | ||
|
|
1adacd0572 | ||
|
|
db583287b2 | ||
|
|
2f14fa1bc3 | ||
|
|
7f41228a71 | ||
|
|
553548b689 | ||
|
|
9313ebf2e7 | ||
|
|
8b09e653e0 | ||
|
|
155e4f6125 | ||
|
|
96182597c4 | ||
|
|
9ad5f04e51 | ||
|
|
e468e5a589 | ||
|
|
6ea1430a04 | ||
|
|
e6505b7d83 | ||
|
|
d6203bf350 | ||
|
|
a42e8aad97 | ||
|
|
8d2e3c2528 | ||
|
|
37d46411c7 | ||
|
|
85095f8a53 | ||
|
|
ab4dafa4be | ||
|
|
9e2e12dff8 | ||
|
|
46e650622c | ||
|
|
2ae0b7059f | ||
|
|
5229cc58b2 | ||
|
|
4ed91619dd | ||
|
|
cae54dad56 | ||
|
|
6e4fbbfa4d | ||
|
|
29ada58b4a | ||
|
|
77e6d589ff | ||
|
|
fd5dd27f16 | ||
|
|
ac6cea859a | ||
|
|
caf3040313 | ||
|
|
445ac1213c | ||
|
|
6b7af973b2 | ||
|
|
12bcf826e4 | ||
|
|
211f699aa0 | ||
|
|
383f4ca04a | ||
|
|
e4107ac952 | ||
|
|
7c966b69d5 | ||
|
|
42173386b3 | ||
|
|
add6242e51 | ||
|
|
3f00502305 | ||
|
|
6fbf7ef368 | ||
|
|
be4e7b1340 | ||
|
|
043ef3dad6 | ||
|
|
167ed87684 | ||
|
|
33fff26869 | ||
|
|
02a9485966 | ||
|
|
6f29ecbbb9 | ||
|
|
f6bfb89b29 | ||
|
|
cb401af6f6 | ||
|
|
861a632ac7 | ||
|
|
c6b7a7f8d0 | ||
|
|
2ab215daca | ||
|
|
d0efa35d22 | ||
|
|
521d0b65c7 | ||
|
|
1bd5152c80 | ||
|
|
d1328321be | ||
|
|
2843b99171 | ||
|
|
810afdaf5a | ||
|
|
fb471652c0 | ||
|
|
9e56896bd4 | ||
|
|
3b8d69206c | ||
|
|
94560132dd | ||
|
|
b4e96374bc | ||
|
|
da97b29dbe | ||
|
|
590b1fc39e | ||
|
|
be91355c20 | ||
|
|
d6de021ae6 | ||
|
|
39be169f0b | ||
|
|
5c7416458f | ||
|
|
22fe9b54d2 | ||
|
|
9586c72a17 | ||
|
|
545bcc3e4b | ||
|
|
b8786215dc | ||
|
|
ce3e1756b3 | ||
|
|
053b262aa7 | ||
|
|
fc0ebf0891 | ||
|
|
c9ccb0791d | ||
|
|
cf249d7e8c | ||
|
|
d5f4979831 | ||
|
|
5cec4eb015 | ||
|
|
760a26e484 | ||
|
|
737cffc241 | ||
|
|
d445530fa0 | ||
|
|
4fa8450d38 | ||
|
|
921b5ca2ce | ||
|
|
e73779fec1 | ||
|
|
d9fe14a012 | ||
|
|
131f34648d | ||
|
|
660a1bbe01 | ||
|
|
bb4e4282f4 | ||
|
|
6b43b788d9 | ||
|
|
dba6dce3b3 | ||
|
|
f645120641 | ||
|
|
d26269865f | ||
|
|
bec35200e9 | ||
|
|
0832dd9d40 | ||
|
|
00310d2d23 | ||
|
|
dcfea9baac | ||
|
|
d57e7eaa98 | ||
|
|
5475eed452 | ||
|
|
b6c3dde1cc | ||
|
|
c088e9d9d8 | ||
|
|
89fd9ec8c3 | ||
|
|
e61e7c8356 | ||
|
|
f77c17c6f0 | ||
|
|
70fa423026 | ||
|
|
0b6a71f8ea | ||
|
|
049c423454 | ||
|
|
839cb2cd21 | ||
|
|
61fdce4f44 | ||
|
|
2c5927d8cd | ||
|
|
2e4b403787 | ||
|
|
bed36cbf9f | ||
|
|
a2c75257f1 | ||
|
|
75435aa960 | ||
|
|
d8a99ce06a | ||
|
|
39c0fe3697 | ||
|
|
ee0b66b6bd | ||
|
|
e03d6379a6 | ||
|
|
466cba39d8 | ||
|
|
1adb9bb6b6 | ||
|
|
b888bc2091 | ||
|
|
e4609c18ef | ||
|
|
b16abc157e | ||
|
|
92cb91e2e2 | ||
|
|
35b6d63289 | ||
|
|
6ecee2abbd | ||
|
|
ea37d05a83 | ||
|
|
2ac6508fe6 | ||
|
|
7be1e3ed38 | ||
|
|
2b2833bb4f | ||
|
|
4632d6cf55 | ||
|
|
e9c3118ddd | ||
|
|
538f87e415 | ||
|
|
d439685895 | ||
|
|
00b31c3f53 | ||
|
|
3c14eed1c2 | ||
|
|
35b6a88146 | ||
|
|
7587f2cdc6 | ||
|
|
91049335eb | ||
|
|
9ac6741d24 | ||
|
|
4325c30a3f | ||
|
|
273ada7353 | ||
|
|
7adcada324 | ||
|
|
0fc783e2b3 | ||
|
|
89e8176c69 | ||
|
|
91e7969383 | ||
|
|
24c79d91c2 | ||
|
|
f58c49beaa | ||
|
|
bf88c64d1e | ||
|
|
9d7ba773ec | ||
|
|
7fad6ce651 | ||
|
|
bdbbdbb0ed | ||
|
|
627af2c236 | ||
|
|
4f4e57bb26 | ||
|
|
1f5d81b77c | ||
|
|
af4b3e7df0 | ||
|
|
22fc6871e8 | ||
|
|
d7127cead3 | ||
|
|
1f43d7916c | ||
|
|
26d6b84a57 | ||
|
|
cb6b3584ce | ||
|
|
3887665bcb | ||
|
|
cca84aedfd | ||
|
|
88f7c4f1a5 | ||
|
|
6e06fe2885 | ||
|
|
5c4223992f | ||
|
|
70e293bccb | ||
|
|
d4157b9e4e | ||
|
|
39604bedef | ||
|
|
5d42ce553f | ||
|
|
9f55454f63 | ||
|
|
bd3c38de84 | ||
|
|
cfad740c99 | ||
|
|
91285e3868 | ||
|
|
d1c83fad14 | ||
|
|
71f2f4288f | ||
|
|
6426ae559a | ||
|
|
166f2d4666 | ||
|
|
8e79f1717d | ||
|
|
5704deb460 | ||
|
|
257efb43c6 | ||
|
|
020f062a76 | ||
|
|
6b006a18e7 | ||
|
|
ecb36b6354 | ||
|
|
8b022c2bfb | ||
|
|
82adb01307 | ||
|
|
916c21fe60 | ||
|
|
868b9b476e | ||
|
|
52a86c5e38 | ||
|
|
a4604e892c | ||
|
|
3c584376ca | ||
|
|
9f31381bb6 | ||
|
|
a2e688fcb2 | ||
|
|
68e2ccb354 | ||
|
|
37a81ba594 | ||
|
|
ff6031d3c9 | ||
|
|
4996d8ccfe | ||
|
|
0b7377238a | ||
|
|
33ea46c2bc | ||
|
|
0fbf109912 | ||
|
|
a8cc43a0ff | ||
|
|
2547ece0ca | ||
|
|
1c7e4782aa | ||
|
|
6b5c9c781b | ||
|
|
e5cedc7d5f | ||
|
|
8e75551f95 | ||
|
|
15fd23c374 | ||
|
|
ce1831e2be | ||
|
|
d4c925819b | ||
|
|
43d8a1f2ff | ||
|
|
103d30ad3f | ||
|
|
c36b2adf84 | ||
|
|
8464c619e4 | ||
|
|
17413078a7 | ||
|
|
9684ce5c4b | ||
|
|
b112198991 | ||
|
|
5127c29297 | ||
|
|
aa63014073 | ||
|
|
46f6d7c11a | ||
|
|
e7924c6dac | ||
|
|
0b71729bd3 | ||
|
|
eec5871f5f | ||
|
|
e2c7166719 | ||
|
|
d3940b6259 | ||
|
|
481bd76100 | ||
|
|
6af83e3881 | ||
|
|
e6624cf631 | ||
|
|
119a4226d8 | ||
|
|
f2d3cba231 | ||
|
|
d02872983d | ||
|
|
6365bf39d9 | ||
|
|
6d7fea537e | ||
|
|
157675d9fd | ||
|
|
7f0800537e | ||
|
|
ad7842c98a | ||
|
|
9330bde991 | ||
|
|
03b85248e6 | ||
|
|
71dfc83466 | ||
|
|
1f2ba932b8 | ||
|
|
d23a261b92 | ||
|
|
3268364693 | ||
|
|
3d11c61f32 | ||
|
|
219cc9a0ab | ||
|
|
8f8675a26a | ||
|
|
699186f430 | ||
|
|
a718b692a0 | ||
|
|
ace19c0790 | ||
|
|
0f7af07c6e | ||
|
|
9804a17b79 | ||
|
|
a72171f8ef | ||
|
|
20fb056323 | ||
|
|
bf6d0f2817 | ||
|
|
72e38bfe1f | ||
|
|
d466284fab | ||
|
|
cb7c075cd2 | ||
|
|
83b0738b04 | ||
|
|
3134d71b8f | ||
|
|
eac7ad5d34 | ||
|
|
4182018cb7 | ||
|
|
1a680d4eae | ||
|
|
848bc57f29 | ||
|
|
74986d1ac6 | ||
|
|
cc646790fd | ||
|
|
09e9f34bb4 | ||
|
|
7af8744c85 | ||
|
|
e7e73193fe | ||
|
|
2505ae43a9 | ||
|
|
9e1459d5db | ||
|
|
72edab5f1c | ||
|
|
7833760fa0 | ||
|
|
d630a92c40 | ||
|
|
2f8efab275 | ||
|
|
a5819569f2 | ||
|
|
6a25bc53ef | ||
|
|
3655e7aaf1 | ||
|
|
aba028a375 | ||
|
|
f6f0f21664 | ||
|
|
0871a51cb4 | ||
|
|
63a90d26f3 | ||
|
|
7ae0e8d9c5 | ||
|
|
345dbb3521 | ||
|
|
6c8ccd2acc | ||
|
|
9f3de2d24c | ||
|
|
07408ac222 | ||
|
|
d91eae9c7e | ||
|
|
7eb860af61 | ||
|
|
6e57fd77af | ||
|
|
a14873d5b4 | ||
|
|
54bbd08f38 | ||
|
|
ca4c93c6b9 | ||
|
|
7874f6ac58 | ||
|
|
a341a838b1 | ||
|
|
51d2deeea9 | ||
|
|
fc2e0bf67b | ||
|
|
fa2ebcd0a2 | ||
|
|
363b3629a4 | ||
|
|
3b52cea811 | ||
|
|
1d5c8ee500 | ||
|
|
1eb1629d9e | ||
|
|
20b56b5b23 | ||
|
|
4165834f80 | ||
|
|
9de26d44da | ||
|
|
d293cbd5a9 | ||
|
|
43abc183ab | ||
|
|
0a788da2d2 | ||
|
|
3aada04c7f | ||
|
|
942afb43a1 | ||
|
|
5d0f65358f | ||
|
|
3ad820e083 | ||
|
|
479da5aa86 | ||
|
|
3f972f8fed | ||
|
|
983a874ddd | ||
|
|
c1ad7acfb9 | ||
|
|
41fc03287e | ||
|
|
c94f1b6ff8 | ||
|
|
b6e1a5c91a | ||
|
|
ce2335deaf | ||
|
|
d8de10d78a | ||
|
|
73e697a0df | ||
|
|
73070544ca | ||
|
|
5cfc5914f2 | ||
|
|
5849a39820 | ||
|
|
ce489a724b | ||
|
|
10392ecc28 | ||
|
|
9fdeb58fd3 | ||
|
|
8b39e6bca7 | ||
|
|
a544123b59 | ||
|
|
6e774a58fe | ||
|
|
403dca154c | ||
|
|
b4cbc792cc | ||
|
|
6d8f2221b8 | ||
|
|
505cbdd82e | ||
|
|
eb896f824d | ||
|
|
927df33d49 | ||
|
|
64f7ac0e74 | ||
|
|
607ecab31e | ||
|
|
1507b051fd | ||
|
|
6c546f37ba | ||
|
|
43e9dd5ea9 | ||
|
|
b30b354b53 | ||
|
|
0d1336bd29 | ||
|
|
1ded706f8f | ||
|
|
2933483393 | ||
|
|
41bde84a92 | ||
|
|
4e95591087 | ||
|
|
da0ad82c24 | ||
|
|
6c13449088 | ||
|
|
25520e9784 | ||
|
|
7a2ad08a7d | ||
|
|
c82457e534 | ||
|
|
bc0d50e892 | ||
|
|
b2993bcd30 | ||
|
|
78ed64932f | ||
|
|
ee4b7bebe8 | ||
|
|
3d32a5f755 | ||
|
|
40f38fc87f | ||
|
|
6311fb607d | ||
|
|
f67f0f864b | ||
|
|
ff194fadea | ||
|
|
d1e8042cf3 | ||
|
|
6d4005f984 | ||
|
|
8cf8db8456 | ||
|
|
fadf4dec96 | ||
|
|
17bc1de49e | ||
|
|
219b493550 | ||
|
|
8e1e9ec2e3 | ||
|
|
6ed6b9e120 | ||
|
|
dcad60284c | ||
|
|
33a5ecd2ce | ||
|
|
0868ff9d64 | ||
|
|
dc40f69511 | ||
|
|
8a34084df1 | ||
|
|
4e3ef7a4dd | ||
|
|
a8302fb253 | ||
|
|
8764662138 | ||
|
|
2abc434e26 | ||
|
|
8cc07bc8bd | ||
|
|
e86b2e60d3 | ||
|
|
8de2100cf7 | ||
|
|
57f36f3f97 | ||
|
|
7282f0bf38 | ||
|
|
d4666d30d2 | ||
|
|
564a24fd78 | ||
|
|
6da576dbe4 | ||
|
|
f59c567831 | ||
|
|
5f733604f0 | ||
|
|
9e62513095 | ||
|
|
b65e07a12b | ||
|
|
f25ea5355c | ||
|
|
82d3c653a7 | ||
|
|
12435b223e | ||
|
|
50d089ae59 | ||
|
|
e48beee7fc | ||
|
|
d2db58de4f | ||
|
|
ef7e679363 | ||
|
|
b4b2ba99ef | ||
|
|
f05f9b4252 | ||
|
|
9b7338e807 | ||
|
|
8aa1b0fed6 | ||
|
|
83962a8561 | ||
|
|
62da307ef1 | ||
|
|
a1f8d6941b | ||
|
|
8c4ca7c8ef | ||
|
|
95d0cb4953 | ||
|
|
c68f2aabc9 | ||
|
|
936b91a7e6 | ||
|
|
6bdf4a1a25 | ||
|
|
08a6f6bde2 | ||
|
|
75536b4790 | ||
|
|
6f31372b37 | ||
|
|
358bcdd881 | ||
|
|
65254f5db4 | ||
|
|
43bd3394c3 | ||
|
|
71c8d8d365 | ||
|
|
7608f3d7b0 | ||
|
|
2edfcbbd85 | ||
|
|
85b788709a | ||
|
|
0e4e0e624e | ||
|
|
d06cc8267b | ||
|
|
e40b79ab33 | ||
|
|
db3fcb861b | ||
|
|
20af8d5caf | ||
|
|
1580748c17 | ||
|
|
904d5f7a3b | ||
|
|
e9673eb13d | ||
|
|
81c52b4b1e | ||
|
|
8089fcc762 | ||
|
|
d48460969d | ||
|
|
87184904ed | ||
|
|
d7973fe1b6 | ||
|
|
d1ee30d1ba | ||
|
|
8479421da4 | ||
|
|
328d7b55c8 | ||
|
|
242aa6e411 | ||
|
|
01c0d4bbfd | ||
|
|
a90d42a556 | ||
|
|
7f38130004 | ||
|
|
5738e422b5 | ||
|
|
6e01d65c6e | ||
|
|
9259ad366d | ||
|
|
f782ee46ad | ||
|
|
1039694cdf | ||
|
|
ed061a5127 | ||
|
|
5aeceec38c | ||
|
|
ba7febf8d8 | ||
|
|
f5c88b6603 | ||
|
|
227b2ae350 | ||
|
|
8857b0706b | ||
|
|
fa7d5ca342 | ||
|
|
0a6ff72e70 | ||
|
|
e3da11bf6d | ||
|
|
8d75a542cd | ||
|
|
236013b7b9 | ||
|
|
f1fd5e8db1 | ||
|
|
592fcc578d | ||
|
|
fffccbd2fc | ||
|
|
fcf0464e16 | ||
|
|
c0d1a6f85f | ||
|
|
dd5e13b00a | ||
|
|
903635c181 | ||
|
|
d4f0f3616e | ||
|
|
83e08e0e20 | ||
|
|
0aa579665f | ||
|
|
65b3926ae5 | ||
|
|
e3a522cdc1 | ||
|
|
ab53591957 | ||
|
|
a361c17678 | ||
|
|
44f99ca1e6 | ||
|
|
0c39a85a31 | ||
|
|
e13d66536c | ||
|
|
491369efb1 | ||
|
|
935f163919 | ||
|
|
4ea407f613 | ||
|
|
38a7a1da88 | ||
|
|
24cdacd59e | ||
|
|
98cad3cdc8 | ||
|
|
8a02a728c8 | ||
|
|
e641547d37 | ||
|
|
14e0efd5b3 | ||
|
|
da5deaaca1 | ||
|
|
476be67ff9 | ||
|
|
15e8ee3471 | ||
|
|
82bc740363 | ||
|
|
3b17c45887 | ||
|
|
23d20847a3 | ||
|
|
c70e26db31 | ||
|
|
7c8bed0524 | ||
|
|
de22464ea8 | ||
|
|
97d7e5a42a | ||
|
|
335dcd3bf9 | ||
|
|
3534b8dfa7 | ||
|
|
74cd60d7cc | ||
|
|
d4d0e976dc | ||
|
|
0761533d0a | ||
|
|
528b387563 | ||
|
|
ce550a13bf | ||
|
|
e993d4feb2 | ||
|
|
71e9f70b8a | ||
|
|
d0ed7890df | ||
|
|
215f388992 | ||
|
|
05744bb474 | ||
|
|
8fb2baecdc | ||
|
|
a897c4165b | ||
|
|
6811d0bde2 | ||
|
|
b5710baf34 | ||
|
|
e3780050e7 | ||
|
|
490df818aa | ||
|
|
ab6aa0ad3e | ||
|
|
74568df4ff | ||
|
|
4d6c80b198 | ||
|
|
41fbd3f15f | ||
|
|
d04ac399ff | ||
|
|
3dd4169b5f | ||
|
|
4785f21316 | ||
|
|
486f1d84ed | ||
|
|
d2867d887a | ||
|
|
05ef9aac2f | ||
|
|
7584ecc8a2 | ||
|
|
c8ac9dc7ea | ||
|
|
03a337a660 | ||
|
|
3588d5186e | ||
|
|
4f5ae94b62 | ||
|
|
f3803c9e60 | ||
|
|
5c1f70348e | ||
|
|
4efc3d7b3f | ||
|
|
a4525d31b2 | ||
|
|
57fac84516 | ||
|
|
d8619b9a84 | ||
|
|
c25edd0024 | ||
|
|
27e695436f | ||
|
|
afa0023c51 | ||
|
|
f41fdef389 | ||
|
|
5415a0e033 | ||
|
|
37f41a5246 | ||
|
|
5a7e7e1367 | ||
|
|
f72e6947d5 | ||
|
|
e3adacc588 | ||
|
|
16c86e2fc3 | ||
|
|
a817d3794d | ||
|
|
0757ad0406 | ||
|
|
89d53a7f49 | ||
|
|
1f79d614c4 | ||
|
|
6a4b6cf603 | ||
|
|
213d6330b1 | ||
|
|
88c4dc405e | ||
|
|
9d3c794983 | ||
|
|
da5af2fae0 | ||
|
|
33fdde249e | ||
|
|
f693ebab21 | ||
|
|
77faa5d523 | ||
|
|
3f9390c45f | ||
|
|
42b5564d1e | ||
|
|
0706a328a4 | ||
|
|
0a142912d3 | ||
|
|
154417d80b | ||
|
|
cbcb10a272 | ||
|
|
8080c525fd | ||
|
|
aeaafefa07 | ||
|
|
e670ac2ee5 | ||
|
|
7e50c6c4b5 | ||
|
|
91e1542a82 | ||
|
|
1621abcffc | ||
|
|
aa89ea7769 | ||
|
|
6c02fea641 | ||
|
|
4abc7d7898 | ||
|
|
79f102c25d | ||
|
|
1ee458b5c1 | ||
|
|
0758397dd8 | ||
|
|
4a074111b5 | ||
|
|
da98ba662e | ||
|
|
b4cb67e77f | ||
|
|
c3d14e1fa5 | ||
|
|
5b17fdc362 | ||
|
|
a922b3cc6d | ||
|
|
67f02e2aa7 | ||
|
|
5497a137de | ||
|
|
88ced02622 | ||
|
|
ddf9227dc4 | ||
|
|
dfa65e9374 | ||
|
|
48be005774 | ||
|
|
05a47e5cf4 | ||
|
|
2b50aaed61 | ||
|
|
c4ee0e25a1 | ||
|
|
86ba8a96c4 | ||
|
|
f99de985c1 | ||
|
|
3da618e0ea | ||
|
|
68ccc8f636 | ||
|
|
376fe6271d | ||
|
|
376dce02bb | ||
|
|
3b033a17f4 | ||
|
|
8d8affdc45 | ||
|
|
6868b41cd5 | ||
|
|
ec970a6bc8 | ||
|
|
2d0424bfcf | ||
|
|
68b78ecd3d | ||
|
|
b6372a846d | ||
|
|
fa653f5a43 | ||
|
|
2996a3942f | ||
|
|
614d3ac1bf | ||
|
|
c352e872e9 | ||
|
|
6eb94f1e13 | ||
|
|
697418f863 | ||
|
|
d01ad09800 | ||
|
|
88027d7a39 | ||
|
|
755662a9d7 | ||
|
|
9d28b3ac50 | ||
|
|
30ed5d7c3c | ||
|
|
e37f8cfa78 | ||
|
|
06cc6e3a2a | ||
|
|
676cdf6ee4 | ||
|
|
3502477f8f | ||
|
|
10884452f0 | ||
|
|
148c58e172 | ||
|
|
db5ac3a0be | ||
|
|
72898a6aba | ||
|
|
ac17ded854 | ||
|
|
851de81517 | ||
|
|
f8d96543de | ||
|
|
b39e615683 | ||
|
|
19893d33e3 | ||
|
|
ebb373987a | ||
|
|
6fc18e450b | ||
|
|
b8906c9b9c | ||
|
|
409ba0db2d | ||
|
|
c8cc845d5b | ||
|
|
d63329baa1 | ||
|
|
2deb9c555e | ||
|
|
8dc6b48ebd | ||
|
|
06d2c65193 | ||
|
|
3a142cbf58 | ||
|
|
25c8467753 | ||
|
|
05a84ab778 | ||
|
|
cd1f3cb8cc | ||
|
|
eae79615a2 | ||
|
|
9ae9302b6b | ||
|
|
3dc506a19a | ||
|
|
c93bcef91f | ||
|
|
7a0ea9d90e | ||
|
|
a1ffc11619 | ||
|
|
7a2e9bef77 | ||
|
|
5e77d0062b | ||
|
|
7adbc95acc | ||
|
|
c275a0cd33 | ||
|
|
4a00d41915 | ||
|
|
2b55afbeec | ||
|
|
a802649d53 | ||
|
|
2558fcbe21 | ||
|
|
c8243b03c9 | ||
|
|
19064864bf | ||
|
|
3a4b9249a9 | ||
|
|
e934e9f05e | ||
|
|
7d9dd51cf4 | ||
|
|
83c8834421 | ||
|
|
4a00f96733 | ||
|
|
6573541873 | ||
|
|
84bea5086c | ||
|
|
01248f09bc | ||
|
|
daaba66f85 | ||
|
|
2c53343e43 | ||
|
|
9c3cf60592 | ||
|
|
0fb5267d07 | ||
|
|
11a9d3bd9b | ||
|
|
56e16a8d85 | ||
|
|
0d467973dc | ||
|
|
e17c7e2fb4 | ||
|
|
b3e4f0188e | ||
|
|
afaac95d8d | ||
|
|
44b1f0fcc0 | ||
|
|
586ed55178 | ||
|
|
a0da71598a | ||
|
|
c0f689a58f | ||
|
|
1ad43dd202 | ||
|
|
61be0b215d | ||
|
|
491090d21b | ||
|
|
1f2a721905 | ||
|
|
82326187f9 | ||
|
|
ec1b0befc7 | ||
|
|
cdde23b4dc | ||
|
|
b4287a2e98 | ||
|
|
208e02c47d | ||
|
|
e025d92fc5 | ||
|
|
da926067ab | ||
|
|
569228a5df | ||
|
|
8ad523fcec | ||
|
|
68562e2618 | ||
|
|
482db14c1a | ||
|
|
490a98df11 | ||
|
|
3f88dbad71 | ||
|
|
19e1d13460 | ||
|
|
ad0a9c02e5 | ||
|
|
76c8b318e5 | ||
|
|
0875dc332e | ||
|
|
a65749a512 | ||
|
|
bcbc5ccc78 | ||
|
|
f24ece85a6 | ||
|
|
2fefb4fd87 | ||
|
|
4f62f5f3f1 | ||
|
|
340bb7f392 | ||
|
|
8645f36c5b | ||
|
|
0eb3f1c3dc | ||
|
|
864f3e7e07 | ||
|
|
4e74da590e | ||
|
|
bdb1e475e7 | ||
|
|
b2876f6c72 | ||
|
|
8c31651a21 | ||
|
|
96fa6e3002 | ||
|
|
ba7f7e72db | ||
|
|
61168847ac | ||
|
|
3b62150abd | ||
|
|
db8a1f76c7 | ||
|
|
9b4053b1ea | ||
|
|
07b27b375f | ||
|
|
b159bbe55d | ||
|
|
3b635c7557 | ||
|
|
71ff485fbf | ||
|
|
671a2a0275 | ||
|
|
38b43cd559 | ||
|
|
d9cd790d03 | ||
|
|
788ea95fbd | ||
|
|
4ab3854aed | ||
|
|
84b847074e | ||
|
|
f0a343717f | ||
|
|
d6ac529d01 | ||
|
|
282b445a43 | ||
|
|
2d3fdd6836 | ||
|
|
5f30061c92 | ||
|
|
60288f02e8 | ||
|
|
5b6d9cee29 | ||
|
|
8c907e3fe4 | ||
|
|
06b9d39662 | ||
|
|
fef4d2e5b9 | ||
|
|
47135160d1 | ||
|
|
076c5382fa | ||
|
|
88d4324e32 | ||
|
|
776fe4768b | ||
|
|
0e9e1ad112 | ||
|
|
cdd2b6fd22 | ||
|
|
a6ced36189 | ||
|
|
deefa901ab | ||
|
|
4a4d7a44fa | ||
|
|
a64b8a7fdb | ||
|
|
6f625aa8aa | ||
|
|
2f6741e49a | ||
|
|
6080cca9ca | ||
|
|
a6d314b753 | ||
|
|
3b29e865b0 | ||
|
|
aa56bcaf44 | ||
|
|
303f8fb329 | ||
|
|
01513aa41b | ||
|
|
5fc499e19e | ||
|
|
f47998f569 | ||
|
|
040dfc71fc | ||
|
|
c2217f2d4b | ||
|
|
7e2fd9bdce | ||
|
|
b1c5ebdace | ||
|
|
67c7b6ef53 | ||
|
|
3a3c74dfa4 | ||
|
|
e89dd83f05 | ||
|
|
297a89c2d2 | ||
|
|
5448a9462e | ||
|
|
5ae0898c91 | ||
|
|
f80afc125a | ||
|
|
7487067608 | ||
|
|
139918a571 | ||
|
|
21e028a4c6 | ||
|
|
74ccf96d0b | ||
|
|
749667dceb | ||
|
|
a0e2a625d4 | ||
|
|
7f8832980d | ||
|
|
bb7099ffb0 | ||
|
|
021757ad5c | ||
|
|
fdb1fc7608 | ||
|
|
4b44103b84 | ||
|
|
63ee91c82e | ||
|
|
573784c316 | ||
|
|
78d932b528 | ||
|
|
e9b4834b6b | ||
|
|
ed06592114 | ||
|
|
6439f7817d | ||
|
|
c309856a97 | ||
|
|
74feef0f9d | ||
|
|
c46c5e59fc | ||
|
|
72d8a34f74 | ||
|
|
72d0fac80c | ||
|
|
6a01322bf9 | ||
|
|
10aabfcd09 | ||
|
|
3bd3613531 | ||
|
|
df464e3d0d | ||
|
|
f83836ade9 | ||
|
|
9f70bb010a | ||
|
|
07f4878d59 | ||
|
|
0d6add5d7f | ||
|
|
e146a1ab37 | ||
|
|
b4dd03ba2a | ||
|
|
0237dee980 | ||
|
|
9c0235ab66 | ||
|
|
0a10832491 | ||
|
|
7982a6bdc6 | ||
|
|
beda8cb7fc | ||
|
|
bf6cd242b3 | ||
|
|
985e11b754 | ||
|
|
0e4f67bf2b | ||
|
|
3993198aa7 | ||
|
|
7d0bbe9962 | ||
|
|
5b4eb8d7b9 | ||
|
|
a40f22d8aa | ||
|
|
560346f9d1 | ||
|
|
dad3d1c7a9 | ||
|
|
44ef447c0f | ||
|
|
e7dd634183 | ||
|
|
008afb97a9 | ||
|
|
7a68e4a6f7 | ||
|
|
6191c48596 | ||
|
|
1aeeed930a | ||
|
|
f4945b1ba1 | ||
|
|
5907409a84 | ||
|
|
f133b78a3e | ||
|
|
76dd74e0d9 | ||
|
|
ac95ab4a65 | ||
|
|
0697eca0e1 | ||
|
|
565547f5a1 | ||
|
|
e1ef122355 | ||
|
|
e40c24f829 | ||
|
|
1902a7dcb0 | ||
|
|
f30287be65 | ||
|
|
6913158b82 | ||
|
|
cb92f56b13 | ||
|
|
bc3b98a756 | ||
|
|
219840341c | ||
|
|
d15f0349bf | ||
|
|
071a46f572 | ||
|
|
e5b7b145e5 | ||
|
|
3f219d8585 | ||
|
|
880b88a856 | ||
|
|
a7a1bc0288 | ||
|
|
44a559e89c | ||
|
|
5b6c411fe8 | ||
|
|
304d3a0b88 | ||
|
|
ffa3f9309f | ||
|
|
8c3efd51ec | ||
|
|
cd4b7f1988 | ||
|
|
63ce7ea705 | ||
|
|
0d7b10469b | ||
|
|
762ff9b9cd | ||
|
|
6b47f5a6d1 | ||
|
|
5bb8dad631 | ||
|
|
60609637bc | ||
|
|
8551cce494 | ||
|
|
3cae6fe6ad | ||
|
|
f6090655bf | ||
|
|
17e145f481 | ||
|
|
f75fb6bd75 | ||
|
|
266a805bfe | ||
|
|
05dffcff6f | ||
|
|
812a6c9f16 | ||
|
|
249130e58d | ||
|
|
b158103f2f | ||
|
|
68b0380118 | ||
|
|
88cd9e586e | ||
|
|
84e9f1d5cc | ||
|
|
178bcd4349 | ||
|
|
904ecc31e2 | ||
|
|
2d77d2d89e | ||
|
|
647c7c45eb | ||
|
|
81bf1125aa | ||
|
|
2820f41a4b | ||
|
|
ef514bc4bd | ||
|
|
d029e18976 | ||
|
|
8fd11fd53a | ||
|
|
a243979aaf | ||
|
|
f56ae1dcc9 | ||
|
|
ac3d561f4d | ||
|
|
8459238f6c | ||
|
|
ab6d37721a | ||
|
|
4e05008aac | ||
|
|
8e8fd73dbd | ||
|
|
cc426279a6 | ||
|
|
4cc6a773ff | ||
|
|
2e61551c28 | ||
|
|
f1df2c505e | ||
|
|
d2d9bb1fd8 | ||
|
|
958c4dc124 | ||
|
|
7a4d5cc724 | ||
|
|
b0d67cd3d0 | ||
|
|
94a12b9674 | ||
|
|
06393750c7 | ||
|
|
781fe3d636 | ||
|
|
8d00f31e46 | ||
|
|
7a04611e98 | ||
|
|
e7e35d7313 | ||
|
|
eaf3acffd7 | ||
|
|
41b886c51b | ||
|
|
cac2048e31 | ||
|
|
2dcb1b5eef | ||
|
|
12a542977e | ||
|
|
e887d68f21 | ||
|
|
667836ec7c | ||
|
|
ae5e08fd30 | ||
|
|
3e4701116d | ||
|
|
88077702f3 | ||
|
|
24f55d5b91 | ||
|
|
a2d26867e6 | ||
|
|
d94348421d | ||
|
|
9c366a4811 | ||
|
|
e53cd12ffd | ||
|
|
865c54abcb | ||
|
|
10c63ed582 | ||
|
|
e46ed1ff97 | ||
|
|
0e3b71c535 | ||
|
|
bdf29856fb | ||
|
|
de5669f723 | ||
|
|
7f8946f14e | ||
|
|
7ab99f0c32 | ||
|
|
5648b836aa | ||
|
|
75ab1f05f9 | ||
|
|
9d852e052c | ||
|
|
ffb842f752 | ||
|
|
150b040dad | ||
|
|
d4df57e1a4 | ||
|
|
68e2b05f65 | ||
|
|
26dbe02968 | ||
|
|
afc1ddb43a | ||
|
|
274fdd2733 | ||
|
|
734c305e0c | ||
|
|
9806a2b5ff | ||
|
|
c34726b2b2 | ||
|
|
6914ad1f74 | ||
|
|
84974c60a7 | ||
|
|
39f459eb04 | ||
|
|
d2f1cbfcb1 | ||
|
|
c6ae5fbda1 | ||
|
|
e7edaca4db | ||
|
|
3c075bfd21 | ||
|
|
65450f8a2b | ||
|
|
b0fbae938d | ||
|
|
fdb6066bf6 | ||
|
|
bed695b127 | ||
|
|
19b9b27662 | ||
|
|
d0c82d33a7 | ||
|
|
133c2b482b | ||
|
|
b1dd38f880 | ||
|
|
7602819b98 | ||
|
|
82694bd6ce | ||
|
|
86308b30ea | ||
|
|
b68db2d02c | ||
|
|
9eb43aa74b | ||
|
|
ae880a2d46 | ||
|
|
a60297b920 | ||
|
|
6825aaff55 | ||
|
|
aa40a268f0 | ||
|
|
4edfb526e1 | ||
|
|
76bc53a499 | ||
|
|
062de4518b | ||
|
|
779651b9a0 | ||
|
|
d172abb037 | ||
|
|
c35a618b00 | ||
|
|
8c982679c7 | ||
|
|
72ee27e320 | ||
|
|
64dedd3e28 | ||
|
|
ab7d91821a | ||
|
|
5829ee9498 | ||
|
|
e8523733b0 | ||
|
|
86a014f23b |
101
.docker/app-rootless/Dockerfile
Normal file
101
.docker/app-rootless/Dockerfile
Normal file
@@ -0,0 +1,101 @@
|
||||
ARG PROXY_REGISTRY
|
||||
|
||||
FROM ${PROXY_REGISTRY}alpine:3.20
|
||||
EXPOSE 9000/tcp
|
||||
|
||||
ARG ALPINE_MIRROR
|
||||
|
||||
ENV SCRIPT_ROOT=/opt/tt-rss
|
||||
ENV SRC_DIR=/src/tt-rss/
|
||||
|
||||
# overriding those without rebuilding image won't do much
|
||||
ENV OWNER_UID=1000
|
||||
ENV OWNER_GID=1000
|
||||
|
||||
RUN [ ! -z ${ALPINE_MIRROR} ] && \
|
||||
sed -i.bak "s#dl-cdn.alpinelinux.org#${ALPINE_MIRROR}#" /etc/apk/repositories ; \
|
||||
apk add --no-cache dcron php83 php83-fpm php83-phar php83-sockets php83-pecl-apcu \
|
||||
php83-pdo php83-gd php83-pgsql php83-pdo_pgsql php83-xmlwriter php83-opcache \
|
||||
php83-mbstring php83-intl php83-xml php83-curl php83-simplexml \
|
||||
php83-session php83-tokenizer php83-dom php83-fileinfo php83-ctype \
|
||||
php83-json php83-iconv php83-pcntl php83-posix php83-zip php83-exif \
|
||||
php83-openssl git postgresql-client sudo php83-pecl-xdebug rsync tzdata && \
|
||||
sed -i 's/\(memory_limit =\) 128M/\1 256M/' /etc/php83/php.ini && \
|
||||
sed -i -e 's/^listen = 127.0.0.1:9000/listen = 9000/' \
|
||||
-e 's/;\(clear_env\) = .*/\1 = no/i' \
|
||||
-e 's/;\(pm.status_path = \/status\)/\1/i' \
|
||||
-e 's/;\(pm.status_listen\) = .*/\1 = 9001/i' \
|
||||
-e 's/^\(user\|group\) = .*/\1 = app/i' \
|
||||
-e 's/;\(php_admin_value\[error_log\]\) = .*/\1 = \/tmp\/error.log/' \
|
||||
-e 's/;\(php_admin_flag\[log_errors\]\) = .*/\1 = on/' \
|
||||
/etc/php83/php-fpm.d/www.conf && \
|
||||
mkdir -p /var/www ${SCRIPT_ROOT}/config.d && \
|
||||
addgroup -g $OWNER_GID app && \
|
||||
adduser -D -h /var/www/html -G app -u $OWNER_UID app && \
|
||||
update-ca-certificates && \
|
||||
chown -R $OWNER_UID /etc/php83 /var/log/php83
|
||||
|
||||
ARG CI_COMMIT_BRANCH
|
||||
ENV CI_COMMIT_BRANCH=${CI_COMMIT_BRANCH}
|
||||
|
||||
ARG CI_COMMIT_SHORT_SHA
|
||||
ENV CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
|
||||
|
||||
ARG CI_COMMIT_TIMESTAMP
|
||||
ENV CI_COMMIT_TIMESTAMP=${CI_COMMIT_TIMESTAMP}
|
||||
|
||||
ARG CI_COMMIT_SHA
|
||||
ENV CI_COMMIT_SHA=${CI_COMMIT_SHA}
|
||||
|
||||
ADD .docker/app-rootless/startup.sh ${SCRIPT_ROOT}
|
||||
ADD .docker/app-rootless/updater.sh ${SCRIPT_ROOT}
|
||||
|
||||
ADD .docker/app-rootless/index.php ${SCRIPT_ROOT}
|
||||
ADD .docker/app-rootless/config.docker.php ${SCRIPT_ROOT}
|
||||
|
||||
COPY . ${SRC_DIR}
|
||||
|
||||
ARG ORIGIN_REPO_XACCEL=https://git.tt-rss.org/fox/ttrss-nginx-xaccel.git
|
||||
|
||||
RUN git clone --depth=1 ${ORIGIN_REPO_XACCEL} ${SRC_DIR}/plugins.local/nginx_xaccel
|
||||
|
||||
USER $OWNER_UID
|
||||
|
||||
ENV PHP_WORKER_MAX_CHILDREN=5
|
||||
ENV PHP_WORKER_MEMORY_LIMIT=256M
|
||||
|
||||
# these are applied on every startup, if set
|
||||
ENV ADMIN_USER_PASS=""
|
||||
# see classes/UserHelper.php ACCESS_LEVEL_*
|
||||
# setting this to -2 would effectively disable built-in admin user
|
||||
# unless single user mode is enabled
|
||||
ENV ADMIN_USER_ACCESS_LEVEL=""
|
||||
|
||||
# these are applied unless user already exists
|
||||
ENV AUTO_CREATE_USER=""
|
||||
ENV AUTO_CREATE_USER_PASS=""
|
||||
ENV AUTO_CREATE_USER_ACCESS_LEVEL="0"
|
||||
ENV AUTO_CREATE_USER_ENABLE_API=""
|
||||
|
||||
# TODO: remove prefix from container variables not used by tt-rss itself:
|
||||
#
|
||||
# - TTRSS_NO_STARTUP_PLUGIN_UPDATES -> NO_STARTUP_PLUGIN_UPDATES
|
||||
# - TTRSS_XDEBUG_... -> XDEBUG_...
|
||||
|
||||
# don't try to update local plugins on startup
|
||||
ENV TTRSS_NO_STARTUP_PLUGIN_UPDATES=""
|
||||
|
||||
# TTRSS_XDEBUG_HOST defaults to host IP if unset
|
||||
ENV TTRSS_XDEBUG_ENABLED=""
|
||||
ENV TTRSS_XDEBUG_HOST=""
|
||||
ENV TTRSS_XDEBUG_PORT="9000"
|
||||
|
||||
ENV TTRSS_DB_TYPE="pgsql"
|
||||
ENV TTRSS_DB_HOST="db"
|
||||
ENV TTRSS_DB_PORT="5432"
|
||||
|
||||
ENV TTRSS_MYSQL_CHARSET="UTF8"
|
||||
ENV TTRSS_PHP_EXECUTABLE="/usr/bin/php83"
|
||||
ENV TTRSS_PLUGINS="auth_internal, note, nginx_xaccel"
|
||||
|
||||
CMD ${SCRIPT_ROOT}/startup.sh
|
||||
8
.docker/app-rootless/config.docker.php
Normal file
8
.docker/app-rootless/config.docker.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
$snippets = glob(getenv("SCRIPT_ROOT")."/config.d/*.php");
|
||||
|
||||
foreach ($snippets as $snippet) {
|
||||
require_once $snippet;
|
||||
}
|
||||
|
||||
3
.docker/app-rootless/index.php
Normal file
3
.docker/app-rootless/index.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
header("Location: /tt-rss/");
|
||||
return;
|
||||
155
.docker/app-rootless/startup.sh
Normal file
155
.docker/app-rootless/startup.sh
Normal file
@@ -0,0 +1,155 @@
|
||||
#!/bin/sh -e
|
||||
|
||||
while ! pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER; do
|
||||
echo waiting until $TTRSS_DB_HOST is ready...
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# We don't need those here (HTTP_HOST would cause false SELF_URL_PATH check failures)
|
||||
unset HTTP_PORT
|
||||
unset HTTP_HOST
|
||||
|
||||
DST_DIR=/var/www/html/tt-rss
|
||||
|
||||
if [ ! -w $DST_DIR -a ! -w /var/www/html ]; then
|
||||
echo please make sure both /var/www/html and $DST_DIR are writable to current user $(id)
|
||||
exit 1
|
||||
fi
|
||||
|
||||
[ -e $DST_DIR ] && rm -f $DST_DIR/.app_is_ready
|
||||
|
||||
export PGPASSWORD=$TTRSS_DB_PASS
|
||||
|
||||
[ ! -e /var/www/html/index.php ] && cp ${SCRIPT_ROOT}/index.php /var/www/html
|
||||
|
||||
if [ -z $SKIP_RSYNC_ON_STARTUP ]; then
|
||||
if [ ! -d $DST_DIR ]; then
|
||||
mkdir -p $DST_DIR
|
||||
|
||||
rsync -a --no-owner \
|
||||
$SRC_DIR/ $DST_DIR/
|
||||
else
|
||||
rsync -a --no-owner --delete \
|
||||
--exclude /cache \
|
||||
--exclude /lock \
|
||||
--exclude /feed-icons \
|
||||
--exclude /plugins/af_comics/filters.local \
|
||||
--exclude /plugins.local \
|
||||
--exclude /templates.local \
|
||||
--exclude /themes.local \
|
||||
$SRC_DIR/ $DST_DIR/
|
||||
|
||||
rsync -a --no-owner --delete \
|
||||
$SRC_DIR/plugins.local/nginx_xaccel \
|
||||
$DST_DIR/plugins.local/nginx_xaccel
|
||||
fi
|
||||
else
|
||||
echo "warning: working copy in $DST_DIR won't be updated, make sure you know what you're doing."
|
||||
fi
|
||||
|
||||
for d in cache lock feed-icons plugins.local themes.local templates.local cache/export cache/feeds cache/images cache/upload; do
|
||||
mkdir -p $DST_DIR/$d
|
||||
done
|
||||
|
||||
for d in cache lock feed-icons; do
|
||||
chmod 777 $DST_DIR/$d
|
||||
find $DST_DIR/$d -type f -exec chmod 666 {} \;
|
||||
done
|
||||
|
||||
cp ${SCRIPT_ROOT}/config.docker.php $DST_DIR/config.php
|
||||
chmod 644 $DST_DIR/config.php
|
||||
|
||||
if [ -z "$TTRSS_NO_STARTUP_PLUGIN_UPDATES" ]; then
|
||||
echo updating all local plugins...
|
||||
|
||||
find $DST_DIR/plugins.local -mindepth 1 -maxdepth 1 -type d | while read PLUGIN; do
|
||||
if [ -d $PLUGIN/.git ]; then
|
||||
echo updating $PLUGIN...
|
||||
|
||||
cd $PLUGIN && \
|
||||
git config core.filemode false && \
|
||||
git config pull.rebase false && \
|
||||
git pull origin master || echo warning: attempt to update plugin $PLUGIN failed.
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo skipping local plugin updates, disabled.
|
||||
fi
|
||||
|
||||
PSQL="psql -q -h $TTRSS_DB_HOST -U $TTRSS_DB_USER $TTRSS_DB_NAME"
|
||||
|
||||
$PSQL -c "create extension if not exists pg_trgm"
|
||||
|
||||
RESTORE_SCHEMA=${SCRIPT_ROOT}/restore-schema.sql.gz
|
||||
|
||||
if [ -r $RESTORE_SCHEMA ]; then
|
||||
$PSQL -c "drop schema public cascade; create schema public;"
|
||||
zcat $RESTORE_SCHEMA | $PSQL
|
||||
fi
|
||||
|
||||
# this was previously generated
|
||||
rm -f $DST_DIR/config.php.bak
|
||||
|
||||
if [ ! -z "${TTRSS_XDEBUG_ENABLED}" ]; then
|
||||
if [ -z "${TTRSS_XDEBUG_HOST}" ]; then
|
||||
export TTRSS_XDEBUG_HOST=$(ip ro sh 0/0 | cut -d " " -f 3)
|
||||
fi
|
||||
echo enabling xdebug with the following parameters:
|
||||
env | grep TTRSS_XDEBUG
|
||||
cat > /etc/php83/conf.d/50_xdebug.ini <<EOF
|
||||
zend_extension=xdebug.so
|
||||
xdebug.mode=debug
|
||||
xdebug.start_with_request = yes
|
||||
xdebug.client_port = ${TTRSS_XDEBUG_PORT}
|
||||
xdebug.client_host = ${TTRSS_XDEBUG_HOST}
|
||||
EOF
|
||||
fi
|
||||
|
||||
sed -i.bak "s/^\(memory_limit\) = \(.*\)/\1 = ${PHP_WORKER_MEMORY_LIMIT}/" \
|
||||
/etc/php83/php.ini
|
||||
|
||||
sed -i.bak "s/^\(pm.max_children\) = \(.*\)/\1 = ${PHP_WORKER_MAX_CHILDREN}/" \
|
||||
/etc/php83/php-fpm.d/www.conf
|
||||
|
||||
php83 $DST_DIR/update.php --update-schema=force-yes
|
||||
|
||||
if [ ! -z "$ADMIN_USER_PASS" ]; then
|
||||
php83 $DST_DIR/update.php --user-set-password "admin:$ADMIN_USER_PASS"
|
||||
else
|
||||
if php83 $DST_DIR/update.php --user-check-password "admin:password"; then
|
||||
RANDOM_PASS=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 16 ; echo '')
|
||||
|
||||
echo "*****************************************************************************"
|
||||
echo "* Setting initial built-in admin user password to '$RANDOM_PASS' *"
|
||||
echo "* If you want to set it manually, use ADMIN_USER_PASS environment variable. *"
|
||||
echo "*****************************************************************************"
|
||||
|
||||
php83 $DST_DIR/update.php --user-set-password "admin:$RANDOM_PASS"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -z "$ADMIN_USER_ACCESS_LEVEL" ]; then
|
||||
php83 $DST_DIR/update.php --user-set-access-level "admin:$ADMIN_USER_ACCESS_LEVEL"
|
||||
fi
|
||||
|
||||
if [ ! -z "$AUTO_CREATE_USER" ]; then
|
||||
/bin/sh -c "php83 $DST_DIR/update.php --user-exists $AUTO_CREATE_USER ||
|
||||
php83 $DST_DIR/update.php --force-yes --user-add \"$AUTO_CREATE_USER:$AUTO_CREATE_USER_PASS:$AUTO_CREATE_USER_ACCESS_LEVEL\""
|
||||
|
||||
if [ ! -z "$AUTO_CREATE_USER_ENABLE_API" ]; then
|
||||
# TODO: remove || true later
|
||||
/bin/sh -c "php83 $DST_DIR/update.php --user-enable-api \"$AUTO_CREATE_USER:$AUTO_CREATE_USER_ENABLE_API\"" || true
|
||||
fi
|
||||
|
||||
fi
|
||||
|
||||
rm -f /tmp/error.log && mkfifo /tmp/error.log && chown app:app /tmp/error.log
|
||||
|
||||
(tail -q -f /tmp/error.log >> /proc/1/fd/2) &
|
||||
|
||||
unset ADMIN_USER_PASS
|
||||
unset AUTO_CREATE_USER_PASS
|
||||
|
||||
touch $DST_DIR/.app_is_ready
|
||||
|
||||
exec /usr/sbin/php-fpm83 --nodaemonize --force-stderr
|
||||
33
.docker/app-rootless/updater.sh
Normal file
33
.docker/app-rootless/updater.sh
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh -e
|
||||
|
||||
# We don't need those here (HTTP_HOST would cause false SELF_URL_PATH check failures)
|
||||
unset HTTP_PORT
|
||||
unset HTTP_HOST
|
||||
|
||||
unset ADMIN_USER_PASS
|
||||
unset AUTO_CREATE_USER_PASS
|
||||
|
||||
# wait for the app container to delete .app_is_ready and perform rsync, etc.
|
||||
sleep 30
|
||||
|
||||
if ! id app; then
|
||||
addgroup -g $OWNER_GID app
|
||||
adduser -D -h /var/www/html -G app -u $OWNER_UID app
|
||||
fi
|
||||
|
||||
while ! pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER; do
|
||||
echo waiting until $TTRSS_DB_HOST is ready...
|
||||
sleep 3
|
||||
done
|
||||
|
||||
sed -i.bak "s/^\(memory_limit\) = \(.*\)/\1 = ${PHP_WORKER_MEMORY_LIMIT}/" \
|
||||
/etc/php83/php.ini
|
||||
|
||||
DST_DIR=/var/www/html/tt-rss
|
||||
|
||||
while [ ! -s $DST_DIR/config.php -a -e $DST_DIR/.app_is_ready ]; do
|
||||
echo waiting for app container...
|
||||
sleep 3
|
||||
done
|
||||
|
||||
sudo -E -u app /usr/bin/php83 /var/www/html/tt-rss/update_daemon2.php "$@"
|
||||
98
.docker/app/Dockerfile
Normal file
98
.docker/app/Dockerfile
Normal file
@@ -0,0 +1,98 @@
|
||||
ARG PROXY_REGISTRY
|
||||
|
||||
FROM ${PROXY_REGISTRY}alpine:3.20
|
||||
EXPOSE 9000/tcp
|
||||
|
||||
ARG ALPINE_MIRROR
|
||||
|
||||
ENV SCRIPT_ROOT=/opt/tt-rss
|
||||
ENV SRC_DIR=/src/tt-rss/
|
||||
|
||||
RUN [ ! -z ${ALPINE_MIRROR} ] && \
|
||||
sed -i.bak "s#dl-cdn.alpinelinux.org#${ALPINE_MIRROR}#" /etc/apk/repositories ; \
|
||||
apk add --no-cache dcron php83 php83-fpm php83-phar php83-sockets php83-pecl-apcu \
|
||||
php83-pdo php83-gd php83-pgsql php83-pdo_pgsql php83-xmlwriter php83-opcache \
|
||||
php83-mbstring php83-intl php83-xml php83-curl php83-simplexml \
|
||||
php83-session php83-tokenizer php83-dom php83-fileinfo php83-ctype \
|
||||
php83-json php83-iconv php83-pcntl php83-posix php83-zip php83-exif \
|
||||
php83-openssl git postgresql-client sudo php83-pecl-xdebug rsync tzdata && \
|
||||
sed -i 's/\(memory_limit =\) 128M/\1 256M/' /etc/php83/php.ini && \
|
||||
sed -i -e 's/^listen = 127.0.0.1:9000/listen = 9000/' \
|
||||
-e 's/;\(clear_env\) = .*/\1 = no/i' \
|
||||
-e 's/;\(pm.status_path = \/status\)/\1/i' \
|
||||
-e 's/;\(pm.status_listen\) = .*/\1 = 9001/i' \
|
||||
-e 's/^\(user\|group\) = .*/\1 = app/i' \
|
||||
-e 's/;\(php_admin_value\[error_log\]\) = .*/\1 = \/tmp\/error.log/' \
|
||||
-e 's/;\(php_admin_flag\[log_errors\]\) = .*/\1 = on/' \
|
||||
/etc/php83/php-fpm.d/www.conf && \
|
||||
mkdir -p /var/www ${SCRIPT_ROOT}/config.d
|
||||
|
||||
ARG CI_COMMIT_BRANCH
|
||||
ENV CI_COMMIT_BRANCH=${CI_COMMIT_BRANCH}
|
||||
|
||||
ARG CI_COMMIT_SHORT_SHA
|
||||
ENV CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
|
||||
|
||||
ARG CI_COMMIT_TIMESTAMP
|
||||
ENV CI_COMMIT_TIMESTAMP=${CI_COMMIT_TIMESTAMP}
|
||||
|
||||
ARG CI_COMMIT_SHA
|
||||
ENV CI_COMMIT_SHA=${CI_COMMIT_SHA}
|
||||
|
||||
ADD .docker/app/startup.sh ${SCRIPT_ROOT}
|
||||
ADD .docker/app/updater.sh ${SCRIPT_ROOT}
|
||||
ADD .docker/app/dcron.sh ${SCRIPT_ROOT}
|
||||
ADD .docker/app/backup.sh /etc/periodic/weekly/backup
|
||||
|
||||
RUN chmod 0755 ${SCRIPT_ROOT}/*.sh /etc/periodic/weekly/backup
|
||||
|
||||
ADD .docker/app/index.php ${SCRIPT_ROOT}
|
||||
ADD .docker/app/config.docker.php ${SCRIPT_ROOT}
|
||||
|
||||
COPY . ${SRC_DIR}
|
||||
|
||||
ARG ORIGIN_REPO_XACCEL=https://git.tt-rss.org/fox/ttrss-nginx-xaccel.git
|
||||
|
||||
RUN git clone --depth=1 ${ORIGIN_REPO_XACCEL} ${SRC_DIR}/plugins.local/nginx_xaccel
|
||||
|
||||
ENV OWNER_UID=1000
|
||||
ENV OWNER_GID=1000
|
||||
|
||||
ENV PHP_WORKER_MAX_CHILDREN=5
|
||||
ENV PHP_WORKER_MEMORY_LIMIT=256M
|
||||
|
||||
# these are applied on every startup, if set
|
||||
ENV ADMIN_USER_PASS=""
|
||||
# see classes/UserHelper.php ACCESS_LEVEL_*
|
||||
# setting this to -2 would effectively disable built-in admin user
|
||||
# unless single user mode is enabled
|
||||
ENV ADMIN_USER_ACCESS_LEVEL=""
|
||||
|
||||
# these are applied unless user already exists
|
||||
ENV AUTO_CREATE_USER=""
|
||||
ENV AUTO_CREATE_USER_PASS=""
|
||||
ENV AUTO_CREATE_USER_ACCESS_LEVEL="0"
|
||||
ENV AUTO_CREATE_USER_ENABLE_API=""
|
||||
|
||||
# TODO: remove prefix from container variables not used by tt-rss itself:
|
||||
#
|
||||
# - TTRSS_NO_STARTUP_PLUGIN_UPDATES -> NO_STARTUP_PLUGIN_UPDATES
|
||||
# - TTRSS_XDEBUG_... -> XDEBUG_...
|
||||
|
||||
# don't try to update local plugins on startup
|
||||
ENV TTRSS_NO_STARTUP_PLUGIN_UPDATES=""
|
||||
|
||||
# TTRSS_XDEBUG_HOST defaults to host IP if unset
|
||||
ENV TTRSS_XDEBUG_ENABLED=""
|
||||
ENV TTRSS_XDEBUG_HOST=""
|
||||
ENV TTRSS_XDEBUG_PORT="9000"
|
||||
|
||||
ENV TTRSS_DB_TYPE="pgsql"
|
||||
ENV TTRSS_DB_HOST="db"
|
||||
ENV TTRSS_DB_PORT="5432"
|
||||
|
||||
ENV TTRSS_MYSQL_CHARSET="UTF8"
|
||||
ENV TTRSS_PHP_EXECUTABLE="/usr/bin/php83"
|
||||
ENV TTRSS_PLUGINS="auth_internal, note, nginx_xaccel"
|
||||
|
||||
CMD ${SCRIPT_ROOT}/startup.sh
|
||||
31
.docker/app/backup.sh
Normal file
31
.docker/app/backup.sh
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh -e
|
||||
|
||||
DST_DIR=/backups
|
||||
KEEP_DAYS=28
|
||||
APP_ROOT=/var/www/html/tt-rss
|
||||
|
||||
if pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER; then
|
||||
DST_FILE=ttrss-backup-$(date +%Y%m%d).sql.gz
|
||||
|
||||
echo backing up tt-rss database to $DST_DIR/$DST_FILE...
|
||||
|
||||
export PGPASSWORD=$TTRSS_DB_PASS
|
||||
|
||||
pg_dump --clean -h $TTRSS_DB_HOST -U $TTRSS_DB_USER $TTRSS_DB_NAME | gzip > $DST_DIR/$DST_FILE
|
||||
|
||||
DST_FILE=ttrss-backup-$(date +%Y%m%d).tar.gz
|
||||
|
||||
echo backing up tt-rss local directories to $DST_DIR/$DST_FILE...
|
||||
|
||||
tar -cz -f $DST_DIR/$DST_FILE $APP_ROOT/*.local \
|
||||
$APP_ROOT/feed-icons/ \
|
||||
$APP_ROOT/config.php
|
||||
|
||||
echo cleaning up...
|
||||
|
||||
find $DST_DIR -type f -name '*.gz' -mtime +$KEEP_DAYS -delete
|
||||
|
||||
echo done.
|
||||
else
|
||||
echo backup failed: database is not ready.
|
||||
fi
|
||||
8
.docker/app/config.docker.php
Normal file
8
.docker/app/config.docker.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
$snippets = glob(getenv("SCRIPT_ROOT")."/config.d/*.php");
|
||||
|
||||
foreach ($snippets as $snippet) {
|
||||
require_once $snippet;
|
||||
}
|
||||
|
||||
5
.docker/app/dcron.sh
Normal file
5
.docker/app/dcron.sh
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# https://github.com/dubiousjim/dcron/issues/13
|
||||
set -e
|
||||
|
||||
/usr/sbin/crond "$@"
|
||||
3
.docker/app/index.php
Normal file
3
.docker/app/index.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<?php
|
||||
header("Location: /tt-rss/");
|
||||
return;
|
||||
163
.docker/app/startup.sh
Normal file
163
.docker/app/startup.sh
Normal file
@@ -0,0 +1,163 @@
|
||||
#!/bin/sh -e
|
||||
|
||||
while ! pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER; do
|
||||
echo waiting until $TTRSS_DB_HOST is ready...
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# We don't need those here (HTTP_HOST would cause false SELF_URL_PATH check failures)
|
||||
unset HTTP_PORT
|
||||
unset HTTP_HOST
|
||||
|
||||
if ! id app >/dev/null 2>&1; then
|
||||
addgroup -g $OWNER_GID app
|
||||
adduser -D -h /var/www/html -G app -u $OWNER_UID app
|
||||
fi
|
||||
|
||||
update-ca-certificates || true
|
||||
|
||||
DST_DIR=/var/www/html/tt-rss
|
||||
|
||||
[ -e $DST_DIR ] && rm -f $DST_DIR/.app_is_ready
|
||||
|
||||
export PGPASSWORD=$TTRSS_DB_PASS
|
||||
|
||||
[ ! -e /var/www/html/index.php ] && cp ${SCRIPT_ROOT}/index.php /var/www/html
|
||||
|
||||
if [ -z $SKIP_RSYNC_ON_STARTUP ]; then
|
||||
if [ ! -d $DST_DIR ]; then
|
||||
mkdir -p $DST_DIR
|
||||
chown $OWNER_UID:$OWNER_GID $DST_DIR
|
||||
|
||||
sudo -u app rsync -a --no-owner \
|
||||
$SRC_DIR/ $DST_DIR/
|
||||
else
|
||||
chown -R $OWNER_UID:$OWNER_GID $DST_DIR
|
||||
|
||||
sudo -u app rsync -a --no-owner --delete \
|
||||
--exclude /cache \
|
||||
--exclude /lock \
|
||||
--exclude /feed-icons \
|
||||
--exclude /plugins/af_comics/filters.local \
|
||||
--exclude /plugins.local \
|
||||
--exclude /templates.local \
|
||||
--exclude /themes.local \
|
||||
$SRC_DIR/ $DST_DIR/
|
||||
|
||||
sudo -u app rsync -a --no-owner --delete \
|
||||
$SRC_DIR/plugins.local/nginx_xaccel \
|
||||
$DST_DIR/plugins.local/nginx_xaccel
|
||||
fi
|
||||
else
|
||||
echo "warning: working copy in $DST_DIR won't be updated, make sure you know what you're doing."
|
||||
fi
|
||||
|
||||
for d in cache lock feed-icons plugins.local themes.local templates.local cache/export cache/feeds cache/images cache/upload; do
|
||||
sudo -u app mkdir -p $DST_DIR/$d
|
||||
done
|
||||
|
||||
for d in cache lock feed-icons; do
|
||||
chmod 777 $DST_DIR/$d
|
||||
find $DST_DIR/$d -type f -exec chmod 666 {} \;
|
||||
done
|
||||
|
||||
sudo -u app cp ${SCRIPT_ROOT}/config.docker.php $DST_DIR/config.php
|
||||
chmod 644 $DST_DIR/config.php
|
||||
|
||||
chown -R $OWNER_UID:$OWNER_GID $DST_DIR \
|
||||
/var/log/php83
|
||||
|
||||
if [ -z "$TTRSS_NO_STARTUP_PLUGIN_UPDATES" ]; then
|
||||
echo updating all local plugins...
|
||||
|
||||
find $DST_DIR/plugins.local -mindepth 1 -maxdepth 1 -type d | while read PLUGIN; do
|
||||
if [ -d $PLUGIN/.git ]; then
|
||||
echo updating $PLUGIN...
|
||||
|
||||
cd $PLUGIN && \
|
||||
sudo -u app git config core.filemode false && \
|
||||
sudo -u app git config pull.rebase false && \
|
||||
sudo -u app git pull origin master || echo warning: attempt to update plugin $PLUGIN failed.
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo skipping local plugin updates, disabled.
|
||||
fi
|
||||
|
||||
PSQL="psql -q -h $TTRSS_DB_HOST -U $TTRSS_DB_USER $TTRSS_DB_NAME"
|
||||
|
||||
$PSQL -c "create extension if not exists pg_trgm"
|
||||
|
||||
RESTORE_SCHEMA=${SCRIPT_ROOT}/restore-schema.sql.gz
|
||||
|
||||
if [ -r $RESTORE_SCHEMA ]; then
|
||||
$PSQL -c "drop schema public cascade; create schema public;"
|
||||
zcat $RESTORE_SCHEMA | $PSQL
|
||||
fi
|
||||
|
||||
# this was previously generated
|
||||
rm -f $DST_DIR/config.php.bak
|
||||
|
||||
if [ ! -z "${TTRSS_XDEBUG_ENABLED}" ]; then
|
||||
if [ -z "${TTRSS_XDEBUG_HOST}" ]; then
|
||||
export TTRSS_XDEBUG_HOST=$(ip ro sh 0/0 | cut -d " " -f 3)
|
||||
fi
|
||||
echo enabling xdebug with the following parameters:
|
||||
env | grep TTRSS_XDEBUG
|
||||
cat > /etc/php83/conf.d/50_xdebug.ini <<EOF
|
||||
zend_extension=xdebug.so
|
||||
xdebug.mode=debug
|
||||
xdebug.start_with_request = yes
|
||||
xdebug.client_port = ${TTRSS_XDEBUG_PORT}
|
||||
xdebug.client_host = ${TTRSS_XDEBUG_HOST}
|
||||
EOF
|
||||
fi
|
||||
|
||||
sed -i.bak "s/^\(memory_limit\) = \(.*\)/\1 = ${PHP_WORKER_MEMORY_LIMIT}/" \
|
||||
/etc/php83/php.ini
|
||||
|
||||
sed -i.bak "s/^\(pm.max_children\) = \(.*\)/\1 = ${PHP_WORKER_MAX_CHILDREN}/" \
|
||||
/etc/php83/php-fpm.d/www.conf
|
||||
|
||||
sudo -Eu app php83 $DST_DIR/update.php --update-schema=force-yes
|
||||
|
||||
if [ ! -z "$ADMIN_USER_PASS" ]; then
|
||||
sudo -Eu app php83 $DST_DIR/update.php --user-set-password "admin:$ADMIN_USER_PASS"
|
||||
else
|
||||
if sudo -Eu app php83 $DST_DIR/update.php --user-check-password "admin:password"; then
|
||||
RANDOM_PASS=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 16 ; echo '')
|
||||
|
||||
echo "*****************************************************************************"
|
||||
echo "* Setting initial built-in admin user password to '$RANDOM_PASS' *"
|
||||
echo "* If you want to set it manually, use ADMIN_USER_PASS environment variable. *"
|
||||
echo "*****************************************************************************"
|
||||
|
||||
sudo -Eu app php83 $DST_DIR/update.php --user-set-password "admin:$RANDOM_PASS"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -z "$ADMIN_USER_ACCESS_LEVEL" ]; then
|
||||
sudo -Eu app php83 $DST_DIR/update.php --user-set-access-level "admin:$ADMIN_USER_ACCESS_LEVEL"
|
||||
fi
|
||||
|
||||
if [ ! -z "$AUTO_CREATE_USER" ]; then
|
||||
sudo -Eu app /bin/sh -c "php83 $DST_DIR/update.php --user-exists $AUTO_CREATE_USER ||
|
||||
php83 $DST_DIR/update.php --force-yes --user-add \"$AUTO_CREATE_USER:$AUTO_CREATE_USER_PASS:$AUTO_CREATE_USER_ACCESS_LEVEL\""
|
||||
|
||||
if [ ! -z "$AUTO_CREATE_USER_ENABLE_API" ]; then
|
||||
# TODO: remove || true later
|
||||
sudo -Eu app /bin/sh -c "php83 $DST_DIR/update.php --user-enable-api \"$AUTO_CREATE_USER:$AUTO_CREATE_USER_ENABLE_API\"" || true
|
||||
fi
|
||||
|
||||
fi
|
||||
|
||||
rm -f /tmp/error.log && mkfifo /tmp/error.log && chown app:app /tmp/error.log
|
||||
|
||||
(tail -q -f /tmp/error.log >> /proc/1/fd/2) &
|
||||
|
||||
unset ADMIN_USER_PASS
|
||||
unset AUTO_CREATE_USER_PASS
|
||||
|
||||
touch $DST_DIR/.app_is_ready
|
||||
|
||||
exec /usr/sbin/php-fpm83 --nodaemonize --force-stderr
|
||||
33
.docker/app/updater.sh
Normal file
33
.docker/app/updater.sh
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh -e
|
||||
|
||||
# We don't need those here (HTTP_HOST would cause false SELF_URL_PATH check failures)
|
||||
unset HTTP_PORT
|
||||
unset HTTP_HOST
|
||||
|
||||
unset ADMIN_USER_PASS
|
||||
unset AUTO_CREATE_USER_PASS
|
||||
|
||||
# wait for the app container to delete .app_is_ready and perform rsync, etc.
|
||||
sleep 30
|
||||
|
||||
if ! id app; then
|
||||
addgroup -g $OWNER_GID app
|
||||
adduser -D -h /var/www/html -G app -u $OWNER_UID app
|
||||
fi
|
||||
|
||||
while ! pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER; do
|
||||
echo waiting until $TTRSS_DB_HOST is ready...
|
||||
sleep 3
|
||||
done
|
||||
|
||||
sed -i.bak "s/^\(memory_limit\) = \(.*\)/\1 = ${PHP_WORKER_MEMORY_LIMIT}/" \
|
||||
/etc/php83/php.ini
|
||||
|
||||
DST_DIR=/var/www/html/tt-rss
|
||||
|
||||
while [ ! -s $DST_DIR/config.php -a -e $DST_DIR/.app_is_ready ]; do
|
||||
echo waiting for app container...
|
||||
sleep 3
|
||||
done
|
||||
|
||||
sudo -E -u app /usr/bin/php83 /var/www/html/tt-rss/update_daemon2.php "$@"
|
||||
4
.docker/phpdoc/Dockerfile
Normal file
4
.docker/phpdoc/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
ARG PROXY_REGISTRY
|
||||
FROM ${PROXY_REGISTRY}nginxinc/nginx-unprivileged:1-alpine
|
||||
|
||||
COPY ./phpdoc /usr/share/nginx/html/ttrss-docs
|
||||
29
.docker/web-nginx/Dockerfile
Normal file
29
.docker/web-nginx/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
ARG PROXY_REGISTRY
|
||||
FROM ${PROXY_REGISTRY}nginx:alpine
|
||||
|
||||
HEALTHCHECK CMD curl --fail http://localhost${APP_BASE}/index.php || exit 1
|
||||
|
||||
COPY .docker/web-nginx/nginx.conf /etc/nginx/templates/nginx.conf.template
|
||||
|
||||
# By default, nginx will send the php requests to "app" server, but this server
|
||||
# name can be overridden at runtime by passing an APP_UPSTREAM env var
|
||||
ENV APP_UPSTREAM=${APP_UPSTREAM:-app}
|
||||
|
||||
# Webroot (defaults to /var/www/html)
|
||||
ENV APP_WEB_ROOT=${APP_WEB_ROOT:-/var/www/html}
|
||||
|
||||
# Base location for tt-rss (defaults to /tt-rss)
|
||||
ENV APP_BASE=${APP_BASE:-/tt-rss}
|
||||
|
||||
# Resolver for nginx (kube-dns.kube-system.svc.cluster.local for k8s)
|
||||
ENV RESOLVER=${RESOLVER:-127.0.0.11}
|
||||
|
||||
# In order to make tt-rss appear on website root without /tt-rss/ set above as follows in .env:
|
||||
# APP_WEB_ROOT=/var/www/html/tt-rss
|
||||
# APP_BASE=
|
||||
|
||||
# It's necessary to set the following NGINX_ENVSUBST_OUTPUT_DIR env var to tell
|
||||
# nginx to replace the env vars of /etc/nginx/templates/nginx.conf.template
|
||||
# and put the result in /etc/nginx/nginx.conf (instead of /etc/nginx/conf.d/nginx.conf)
|
||||
# See https://github.com/docker-library/docs/tree/master/nginx#using-environment-variables-in-nginx-configuration-new-in-119
|
||||
ENV NGINX_ENVSUBST_OUTPUT_DIR=/etc/nginx
|
||||
78
.docker/web-nginx/nginx.conf
Normal file
78
.docker/web-nginx/nginx.conf
Normal file
@@ -0,0 +1,78 @@
|
||||
worker_processes auto;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
access_log /dev/stdout;
|
||||
error_log /dev/stderr warn;
|
||||
|
||||
sendfile on;
|
||||
|
||||
index index.php;
|
||||
|
||||
resolver ${RESOLVER} valid=5s;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
root ${APP_WEB_ROOT};
|
||||
|
||||
location ${APP_BASE}/cache {
|
||||
aio threads;
|
||||
internal;
|
||||
}
|
||||
|
||||
location ${APP_BASE}/backups {
|
||||
internal;
|
||||
}
|
||||
|
||||
rewrite ${APP_BASE}/healthz ${APP_BASE}/public.php?op=healthcheck;
|
||||
|
||||
# Regular PHP handling (without PATH_INFO)
|
||||
location ~ \.php$ {
|
||||
# regex to split $uri to $fastcgi_script_name and $fastcgi_path
|
||||
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
|
||||
|
||||
# Check that the PHP script exists before passing it
|
||||
try_files $fastcgi_script_name =404;
|
||||
|
||||
fastcgi_index index.php;
|
||||
include fastcgi.conf;
|
||||
|
||||
set $backend "${APP_UPSTREAM}:9000";
|
||||
|
||||
fastcgi_pass $backend;
|
||||
}
|
||||
|
||||
# Allow PATH_INFO for PHP files in plugins.local directories with an /api/ sub directory to allow plugins to leverage when desired
|
||||
location ~ /plugins\.local/.*/api/.*\.php(/|$) {
|
||||
# regex to split $uri to $fastcgi_script_name and $fastcgi_path
|
||||
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
|
||||
|
||||
# Check that the PHP script exists before passing it
|
||||
try_files $fastcgi_script_name =404;
|
||||
|
||||
# Bypass the fact that try_files resets $fastcgi_path_info
|
||||
# see: http://trac.nginx.org/nginx/ticket/321
|
||||
set $path_info $fastcgi_path_info;
|
||||
fastcgi_param PATH_INFO $path_info;
|
||||
|
||||
fastcgi_index index.php;
|
||||
include fastcgi.conf;
|
||||
|
||||
set $backend "${APP_UPSTREAM}:9000";
|
||||
|
||||
fastcgi_pass $backend;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
}
|
||||
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
.git/
|
||||
cache/
|
||||
plugins.local/
|
||||
templates.local/
|
||||
themes.local/
|
||||
@@ -4,3 +4,6 @@ insert_final_newline = true
|
||||
|
||||
[*.php]
|
||||
indent_style = tab
|
||||
|
||||
[*.js]
|
||||
indent_style = tab
|
||||
|
||||
47
.env-dist
Normal file
47
.env-dist
Normal file
@@ -0,0 +1,47 @@
|
||||
# Copy this file to .env before building the container. Put any local modifications here.
|
||||
|
||||
# Run FPM under this UID/GID.
|
||||
# OWNER_UID=1000
|
||||
# OWNER_GID=1000
|
||||
|
||||
# FPM settings.
|
||||
#PHP_WORKER_MAX_CHILDREN=5
|
||||
#PHP_WORKER_MEMORY_LIMIT=256M
|
||||
|
||||
# ADMIN_USER_* settings are applied on every startup.
|
||||
|
||||
# Set admin user password to this value. If not set, random password will be generated on startup, look for it in the 'app' container logs.
|
||||
#ADMIN_USER_PASS=
|
||||
|
||||
# Sets admin user access level to this value. Valid values:
|
||||
# -2 - forbidden to login
|
||||
# -1 - readonly
|
||||
# 0 - default user
|
||||
# 10 - admin
|
||||
#ADMIN_USER_ACCESS_LEVEL=
|
||||
|
||||
# Auto create another user (in addition to built-in admin) unless it already exists.
|
||||
#AUTO_CREATE_USER=
|
||||
#AUTO_CREATE_USER_PASS=
|
||||
#AUTO_CREATE_USER_ACCESS_LEVEL=0
|
||||
|
||||
# Default database credentials.
|
||||
TTRSS_DB_USER=postgres
|
||||
TTRSS_DB_NAME=postgres
|
||||
TTRSS_DB_PASS=password
|
||||
|
||||
# This is a fallback value for PHP CLI SAPI, it should be set to a fully-qualified tt-rss URL
|
||||
# TTRSS_SELF_URL_PATH=http://example.com/tt-rss
|
||||
|
||||
# You can customize other config.php defines by setting overrides here. See tt-rss/.docker/app/Dockerfile for complete list. Examples:
|
||||
|
||||
# TTRSS_PLUGINS=auth_remote
|
||||
# TTRSS_SINGLE_USER_MODE=true
|
||||
# TTRSS_SESSION_COOKIE_LIFETIME=2592000
|
||||
# TTRSS_FORCE_ARTICLE_PURGE=30
|
||||
# ...
|
||||
|
||||
# Bind exposed port to 127.0.0.1 to run behind reverse proxy on the same host. If you plan expose the container, remove "127.0.0.1:".
|
||||
HTTP_PORT=127.0.0.1:8280
|
||||
#HTTP_PORT=8280
|
||||
|
||||
300
.eslintrc.js
Normal file
300
.eslintrc.js
Normal file
@@ -0,0 +1,300 @@
|
||||
module.exports = {
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"jquery": false,
|
||||
"webextensions": false
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2018
|
||||
},
|
||||
"rules": {
|
||||
"accessor-pairs": "error",
|
||||
"array-bracket-newline": "off",
|
||||
"array-bracket-spacing": "off",
|
||||
"array-callback-return": "error",
|
||||
"array-element-newline": "off",
|
||||
"arrow-body-style": "error",
|
||||
"arrow-parens": "error",
|
||||
"arrow-spacing": "error",
|
||||
"block-scoped-var": "off",
|
||||
"block-spacing": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"brace-style": "off",
|
||||
"callback-return": "off",
|
||||
"camelcase": "off",
|
||||
"capitalized-comments": "off",
|
||||
"class-methods-use-this": "error",
|
||||
"comma-dangle": "off",
|
||||
"comma-spacing": "off",
|
||||
"comma-style": [
|
||||
"error",
|
||||
"last"
|
||||
],
|
||||
"complexity": "off",
|
||||
"computed-property-spacing": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"consistent-return": "off",
|
||||
"consistent-this": "off",
|
||||
"curly": "off",
|
||||
"default-case": "off",
|
||||
"dot-location": "off",
|
||||
"dot-notation": "off",
|
||||
"eol-last": "error",
|
||||
"eqeqeq": "off",
|
||||
"func-call-spacing": "error",
|
||||
"func-name-matching": "error",
|
||||
"func-names": "off",
|
||||
"func-style": "off",
|
||||
"function-paren-newline": "off",
|
||||
"generator-star-spacing": "error",
|
||||
"global-require": "error",
|
||||
"guard-for-in": "off",
|
||||
"handle-callback-err": "off",
|
||||
"id-blacklist": "error",
|
||||
"id-length": "off",
|
||||
"id-match": "error",
|
||||
"implicit-arrow-linebreak": "off",
|
||||
"indent": "off",
|
||||
"indent-legacy": "off",
|
||||
"init-declarations": "off",
|
||||
"jsx-quotes": "error",
|
||||
"key-spacing": "off",
|
||||
"keyword-spacing": [
|
||||
"error",
|
||||
{
|
||||
"after": true,
|
||||
"before": true
|
||||
}
|
||||
],
|
||||
"line-comment-position": "off",
|
||||
"linebreak-style": [
|
||||
"error",
|
||||
"unix"
|
||||
],
|
||||
"lines-around-comment": "off",
|
||||
"lines-around-directive": "error",
|
||||
"lines-between-class-members": "error",
|
||||
"max-classes-per-file": "off",
|
||||
"max-depth": "off",
|
||||
"max-len": "off",
|
||||
"max-lines": "off",
|
||||
"max-lines-per-function": "off",
|
||||
"max-nested-callbacks": "error",
|
||||
"max-params": "off",
|
||||
"max-statements": "off",
|
||||
"max-statements-per-line": [ "warn", { "max" : 2 } ],
|
||||
"multiline-comment-style": "off",
|
||||
"multiline-ternary": "off",
|
||||
"new-cap": "warn",
|
||||
"new-parens": "error",
|
||||
"newline-after-var": "off",
|
||||
"newline-before-return": "off",
|
||||
"newline-per-chained-call": "off",
|
||||
"no-alert": "off",
|
||||
"no-array-constructor": "error",
|
||||
"no-async-promise-executor": "off",
|
||||
"no-await-in-loop": "warn",
|
||||
"no-bitwise": "off",
|
||||
"no-buffer-constructor": "error",
|
||||
"no-caller": "error",
|
||||
"no-catch-shadow": "off",
|
||||
"no-confusing-arrow": "error",
|
||||
"no-continue": "off",
|
||||
"no-console": "off",
|
||||
"no-div-regex": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
"no-else-return": "off",
|
||||
"no-empty": [
|
||||
"error",
|
||||
{
|
||||
"allowEmptyCatch": true
|
||||
}
|
||||
],
|
||||
"no-empty-function": "error",
|
||||
"no-eq-null": "off",
|
||||
"no-eval": "error",
|
||||
"no-extend-native": "off",
|
||||
"no-extra-bind": "error",
|
||||
"no-extra-label": "error",
|
||||
"no-extra-parens": "off",
|
||||
"no-floating-decimal": "error",
|
||||
"no-implicit-globals": "off",
|
||||
"no-implied-eval": "off",
|
||||
"no-inline-comments": "off",
|
||||
"no-inner-declarations": [
|
||||
"error",
|
||||
"functions"
|
||||
],
|
||||
"no-invalid-this": "error",
|
||||
"no-iterator": "error",
|
||||
"no-label-var": "error",
|
||||
"no-labels": "error",
|
||||
"no-lone-blocks": "error",
|
||||
"no-lonely-if": "error",
|
||||
"no-loop-func": "off",
|
||||
"no-magic-numbers": "off",
|
||||
"no-misleading-character-class": "off",
|
||||
"no-mixed-operators": "off",
|
||||
"no-mixed-requires": "error",
|
||||
"no-multi-assign": "error",
|
||||
"no-multi-spaces": "off",
|
||||
"no-multi-str": "error",
|
||||
"no-multiple-empty-lines": "error",
|
||||
"no-native-reassign": "error",
|
||||
"no-negated-condition": "off",
|
||||
"no-negated-in-lhs": "error",
|
||||
"no-nested-ternary": "error",
|
||||
"no-new": "warn",
|
||||
"no-new-func": "error",
|
||||
"no-new-object": "off",
|
||||
"no-new-require": "error",
|
||||
"no-new-wrappers": "error",
|
||||
"no-octal-escape": "error",
|
||||
"no-param-reassign": "off",
|
||||
"no-path-concat": "error",
|
||||
"no-plusplus": "off",
|
||||
"no-process-env": "error",
|
||||
"no-process-exit": "error",
|
||||
"no-proto": "error",
|
||||
"no-prototype-builtins": "warn",
|
||||
"no-restricted-globals": "error",
|
||||
"no-restricted-imports": "error",
|
||||
"no-restricted-modules": "error",
|
||||
"no-restricted-properties": "error",
|
||||
"no-restricted-syntax": "error",
|
||||
"no-return-assign": [
|
||||
"error",
|
||||
"except-parens"
|
||||
],
|
||||
"no-return-await": "error",
|
||||
"no-script-url": "error",
|
||||
"no-self-compare": "error",
|
||||
"no-sequences": "error",
|
||||
"no-shadow": "off",
|
||||
"no-shadow-restricted-names": "error",
|
||||
"no-spaced-func": "error",
|
||||
"no-sync": "error",
|
||||
"no-tabs": "off",
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-ternary": "off",
|
||||
"no-throw-literal": "error",
|
||||
"no-trailing-spaces": "error",
|
||||
"no-undef-init": "error",
|
||||
"no-undefined": "off",
|
||||
"no-undef": "warn",
|
||||
"no-underscore-dangle": "off",
|
||||
"no-unmodified-loop-condition": "error",
|
||||
"no-unneeded-ternary": [
|
||||
"error",
|
||||
{
|
||||
"defaultAssignment": true
|
||||
}
|
||||
],
|
||||
"no-unused-expressions": "off",
|
||||
"no-unused-vars": "warn",
|
||||
"no-use-before-define": "off",
|
||||
"no-useless-call": "error",
|
||||
"no-useless-computed-key": "error",
|
||||
"no-useless-concat": "error",
|
||||
"no-useless-constructor": "error",
|
||||
"no-useless-rename": "error",
|
||||
"no-useless-return": "off",
|
||||
"no-var": "warn",
|
||||
"no-void": "error",
|
||||
"no-warning-comments": "off",
|
||||
"no-whitespace-before-property": "error",
|
||||
"no-with": "error",
|
||||
"nonblock-statement-body-position": [
|
||||
"error",
|
||||
"any"
|
||||
],
|
||||
"object-curly-newline": "off",
|
||||
"object-curly-spacing": "off",
|
||||
"object-property-newline": "off",
|
||||
"object-shorthand": "off",
|
||||
"one-var": "off",
|
||||
"one-var-declaration-per-line": "error",
|
||||
"operator-assignment": "off",
|
||||
"operator-linebreak": [
|
||||
"error",
|
||||
"after"
|
||||
],
|
||||
"padded-blocks": "off",
|
||||
"padding-line-between-statements": "error",
|
||||
"prefer-arrow-callback": "off",
|
||||
"prefer-const": "error",
|
||||
"prefer-destructuring": "off",
|
||||
"prefer-numeric-literals": "error",
|
||||
"prefer-object-spread": "off",
|
||||
"prefer-promise-reject-errors": "error",
|
||||
"prefer-reflect": "off",
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error",
|
||||
"prefer-template": "off",
|
||||
"quote-props": "off",
|
||||
"quotes": "off",
|
||||
"radix": [
|
||||
"error",
|
||||
"as-needed"
|
||||
],
|
||||
"require-atomic-updates": "off",
|
||||
"require-await": "warn",
|
||||
"require-jsdoc": "off",
|
||||
"require-unicode-regexp": "off",
|
||||
"rest-spread-spacing": "error",
|
||||
"semi": "off",
|
||||
"semi-spacing": [
|
||||
"error",
|
||||
{
|
||||
"after": true,
|
||||
"before": false
|
||||
}
|
||||
],
|
||||
"semi-style": [
|
||||
"error",
|
||||
"last"
|
||||
],
|
||||
"sort-imports": "error",
|
||||
"sort-keys": "off",
|
||||
"sort-vars": "off",
|
||||
"space-before-blocks": "off",
|
||||
"space-before-function-paren": "off",
|
||||
"space-in-parens": "off",
|
||||
"space-infix-ops": "off",
|
||||
"space-unary-ops": [
|
||||
"error",
|
||||
{
|
||||
"nonwords": false,
|
||||
"words": false
|
||||
}
|
||||
],
|
||||
"spaced-comment": "off",
|
||||
"strict": [
|
||||
"off",
|
||||
"never"
|
||||
],
|
||||
"switch-colon-spacing": "error",
|
||||
"symbol-description": "error",
|
||||
"template-curly-spacing": "error",
|
||||
"template-tag-spacing": "error",
|
||||
"unicode-bom": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"valid-jsdoc": "error",
|
||||
"vars-on-top": "off",
|
||||
"wrap-iife": "error",
|
||||
"wrap-regex": "error",
|
||||
"yield-star-spacing": "error",
|
||||
"yoda": [
|
||||
"error",
|
||||
"never"
|
||||
]
|
||||
}
|
||||
};
|
||||
35
.gitignore
vendored
35
.gitignore
vendored
@@ -1,21 +1,18 @@
|
||||
Thumbs.db
|
||||
/deploy.exclude
|
||||
/deploy.sh
|
||||
/.env
|
||||
/docker-compose.override.yml
|
||||
/.app_is_ready
|
||||
/messages.mo
|
||||
*~
|
||||
*.DS_Store
|
||||
#*
|
||||
.idea/*
|
||||
plugins.local/*
|
||||
themes.local/*
|
||||
config.php
|
||||
feed-icons/*
|
||||
cache/*/*
|
||||
lock/*
|
||||
tags
|
||||
cache/htmlpurifier/*/*ser
|
||||
lib/htmlpurifier/library/HTMLPurifier/DefinitionCache/Serializer/*/*ser
|
||||
web.config
|
||||
/.save.cson
|
||||
/.tags*
|
||||
/.gutentags
|
||||
/node_modules
|
||||
/locale/**/*.po~
|
||||
/plugins.local/*
|
||||
/themes.local/*
|
||||
/config.php
|
||||
/feed-icons/*
|
||||
/cache/*/*
|
||||
/lock/*
|
||||
/.vscode/settings.json
|
||||
/vendor/**/.git
|
||||
/.phpunit.result.cache
|
||||
/.phpstan-tmp
|
||||
/.tools/
|
||||
|
||||
270
.gitlab-ci.yml
270
.gitlab-ci.yml
@@ -1,44 +1,226 @@
|
||||
phpmd:
|
||||
image: php:5.6
|
||||
when: manual
|
||||
script:
|
||||
- sh utils/gitlab-ci/php-lint.sh
|
||||
- curl -o /usr/bin/phpmd -L http://static.phpmd.org/php/2.6.0/phpmd.phar
|
||||
- chmod +x /usr/bin/phpmd
|
||||
- sh utils/gitlab-ci/phpmd.sh
|
||||
|
||||
schema:
|
||||
image: fox/selenium-ci
|
||||
when: manual
|
||||
script:
|
||||
- /etc/init.d/postgresql start
|
||||
- /usr/local/sbin/init-database.sh
|
||||
- sh ./utils/gitlab-ci/check-schema.sh
|
||||
|
||||
phpunit_basic:
|
||||
image: fox/selenium-ci
|
||||
when: manual
|
||||
script:
|
||||
- /etc/init.d/postgresql start
|
||||
- /usr/local/sbin/init-database.sh
|
||||
- sh ./utils/gitlab-ci/check-schema.sh
|
||||
- cp utils/gitlab-ci/config-template.php config.php
|
||||
- su -s /bin/bash www-data -c "php ./update.php --debug-feed 1"
|
||||
- wget -O /usr/bin/phpunit https://phar.phpunit.de/phpunit-5.7.phar
|
||||
- chmod +x /usr/bin/phpunit
|
||||
- phpunit tests/*.php
|
||||
|
||||
phpunit_functional:
|
||||
image: fox/selenium-ci
|
||||
when: manual
|
||||
script:
|
||||
- /etc/init.d/postgresql start
|
||||
- /etc/init.d/nginx start
|
||||
- /etc/init.d/php5-fpm start
|
||||
- /usr/local/sbin/init-database.sh
|
||||
- sh ./utils/gitlab-ci/check-schema.sh
|
||||
- ln -s `pwd` ../../tt-rss
|
||||
- cp utils/gitlab-ci/config-template.php config.php
|
||||
- chmod -R 777 cache lock feed-icons
|
||||
- /usr/local/sbin/init-selenium.sh
|
||||
- phpunit tests/functional/*.php
|
||||
stages:
|
||||
- lint
|
||||
- build
|
||||
- push
|
||||
- test
|
||||
- publish
|
||||
|
||||
variables:
|
||||
ESLINT_PATHS: js plugins
|
||||
REGISTRY_PROJECT: cthulhoo
|
||||
IMAGE_TAR_FPM: image-fpm.tar
|
||||
IMAGE_TAR_WEB: image-web.tar
|
||||
|
||||
include:
|
||||
- project: 'ci/ci-templates'
|
||||
ref: master
|
||||
file: .ci-build-docker-kaniko.yml
|
||||
- project: 'ci/ci-templates'
|
||||
ref: master
|
||||
file: .ci-registry-push.yml
|
||||
- project: 'ci/ci-templates'
|
||||
ref: master
|
||||
file: .ci-lint-common.yml
|
||||
- project: 'ci/ci-templates'
|
||||
ref: master
|
||||
file: .ci-integration-test.yml
|
||||
- project: 'ci/ci-templates'
|
||||
ref: master
|
||||
file: .ci-update-helm-imagetag.yml
|
||||
|
||||
phpunit:
|
||||
extends: .phpunit
|
||||
variables:
|
||||
PHPUNIT_ARGS: --exclude integration --coverage-filter classes --coverage-filter include
|
||||
|
||||
eslint:
|
||||
extends: .eslint
|
||||
|
||||
phpstan:
|
||||
extends: .phpstan
|
||||
|
||||
ttrss-fpm-pgsql-static:build:
|
||||
extends: .build-docker-kaniko-no-push
|
||||
variables:
|
||||
DOCKERFILE: ${CI_PROJECT_DIR}/.docker/app/Dockerfile
|
||||
IMAGE_TAR: ${IMAGE_TAR_FPM}
|
||||
|
||||
ttrss-fpm-pgsql-static:push-master-commit-only:
|
||||
extends: .crane-image-registry-push-master-commit-only
|
||||
variables:
|
||||
IMAGE_TAR: ${IMAGE_TAR_FPM}
|
||||
needs:
|
||||
- job: ttrss-fpm-pgsql-static:build
|
||||
|
||||
ttrss-fpm-pgsql-static:push-branch:
|
||||
extends: .crane-image-registry-push-branch
|
||||
variables:
|
||||
IMAGE_TAR: ${IMAGE_TAR_FPM}
|
||||
needs:
|
||||
- job: ttrss-fpm-pgsql-static:build
|
||||
|
||||
ttrss-web-nginx:build:
|
||||
extends: .build-docker-kaniko-no-push
|
||||
variables:
|
||||
DOCKERFILE: ${CI_PROJECT_DIR}/.docker/web-nginx/Dockerfile
|
||||
IMAGE_TAR: ${IMAGE_TAR_WEB}
|
||||
|
||||
ttrss-web-nginx:push-master-commit-only:
|
||||
extends: .crane-image-registry-push-master-commit-only
|
||||
variables:
|
||||
IMAGE_TAR: ${IMAGE_TAR_WEB}
|
||||
needs:
|
||||
- job: ttrss-web-nginx:build
|
||||
|
||||
ttrss-web-nginx:push-branch:
|
||||
extends: .crane-image-registry-push-branch
|
||||
variables:
|
||||
IMAGE_TAR: ${IMAGE_TAR_WEB}
|
||||
needs:
|
||||
- job: ttrss-web-nginx:build
|
||||
|
||||
phpdoc:build:
|
||||
image: ${PHP_IMAGE}
|
||||
stage: publish
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
script:
|
||||
- php83 /phpDocumentor.phar -d classes -d include -t phpdoc --visibility=public
|
||||
artifacts:
|
||||
paths:
|
||||
- phpdoc
|
||||
|
||||
phpdoc:publish:
|
||||
extends: .build-docker-kaniko
|
||||
stage: publish
|
||||
needs:
|
||||
- job: phpdoc:build
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $REGISTRY_USER != null && $REGISTRY_PASSWORD != null
|
||||
variables:
|
||||
DOCKERFILE: ${CI_PROJECT_DIR}/.docker/phpdoc/Dockerfile
|
||||
NAME: ttrss-phpdoc
|
||||
VERSION: latest
|
||||
|
||||
phpunit-integration:
|
||||
image: ${PHP_IMAGE}
|
||||
variables:
|
||||
TEST_HELM_REPO: oci://registry.fakecake.org/infra/helm-charts/tt-rss
|
||||
extends: .integration-test
|
||||
script:
|
||||
- export K8S_NAMESPACE=$(kubectl get pods -o=custom-columns=NS:.metadata.namespace | tail -1)
|
||||
- export API_URL="http://tt-rss-${CI_COMMIT_SHORT_SHA}-app.$K8S_NAMESPACE.svc.cluster.local/tt-rss/api/"
|
||||
- export TTRSS_DB_HOST=tt-rss-${CI_COMMIT_SHORT_SHA}-app.$K8S_NAMESPACE.svc.cluster.local
|
||||
- export TTRSS_DB_USER=postgres
|
||||
- export TTRSS_DB_NAME=postgres
|
||||
- export TTRSS_DB_PASS=password
|
||||
- php83 vendor/bin/phpunit --group integration --do-not-cache-result --log-junit phpunit-report.xml --coverage-cobertura phpunit-coverage.xml --coverage-text --colors=never
|
||||
artifacts:
|
||||
when: always
|
||||
reports:
|
||||
junit: phpunit-report.xml
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: phpunit-coverage.xml
|
||||
coverage: '/^\s*Lines:\s*\d+.\d+\%/'
|
||||
|
||||
selenium:
|
||||
image: ${SELENIUM_IMAGE}
|
||||
variables:
|
||||
TEST_HELM_REPO: oci://registry.fakecake.org/infra/helm-charts/tt-rss
|
||||
SELENIUM_GRID_ENDPOINT: http://selenium-hub.selenium-grid.svc.cluster.local:4444/wd/hub
|
||||
extends: .integration-test
|
||||
script:
|
||||
- export K8S_NAMESPACE=$(kubectl get pods -o=custom-columns=NS:.metadata.namespace | tail -1)
|
||||
- |
|
||||
for i in `seq 1 3`; do
|
||||
echo attempt $i...
|
||||
python3 tests/integration/selenium_test.py && break
|
||||
sleep 3
|
||||
done
|
||||
needs:
|
||||
- job: phpunit-integration
|
||||
artifacts:
|
||||
when: always
|
||||
reports:
|
||||
junit: selenium-report.xml
|
||||
|
||||
ttrss-fpm-pgsql-static:publish:
|
||||
stage: publish
|
||||
extends: .crane-image-registry-push-master
|
||||
variables:
|
||||
IMAGE_TAR: ${IMAGE_TAR_FPM}
|
||||
needs:
|
||||
- job: ttrss-fpm-pgsql-static:build
|
||||
- job: phpunit-integration
|
||||
- job: selenium
|
||||
|
||||
ttrss-fpm-pgsql-static:publish-docker-hub:
|
||||
stage: publish
|
||||
extends: .crane-image-registry-push-master-docker-hub
|
||||
variables:
|
||||
IMAGE_TAR: ${IMAGE_TAR_FPM}
|
||||
needs:
|
||||
- job: ttrss-fpm-pgsql-static:build
|
||||
- job: phpunit-integration
|
||||
- job: selenium
|
||||
|
||||
ttrss-fpm-pgsql-static:publish-gitlab:
|
||||
stage: publish
|
||||
extends: .crane-image-registry-push-master-gitlab
|
||||
variables:
|
||||
IMAGE_TAR: ${IMAGE_TAR_FPM}
|
||||
needs:
|
||||
- job: ttrss-fpm-pgsql-static:build
|
||||
- job: phpunit-integration
|
||||
- job: selenium
|
||||
|
||||
ttrss-web-nginx:publish:
|
||||
stage: publish
|
||||
extends: .crane-image-registry-push-master
|
||||
variables:
|
||||
IMAGE_TAR: ${IMAGE_TAR_WEB}
|
||||
needs:
|
||||
- job: ttrss-web-nginx:build
|
||||
- job: phpunit-integration
|
||||
- job: selenium
|
||||
|
||||
ttrss-web-nginx:publish-docker-hub:
|
||||
stage: publish
|
||||
extends: .crane-image-registry-push-master-docker-hub
|
||||
variables:
|
||||
IMAGE_TAR: ${IMAGE_TAR_WEB}
|
||||
needs:
|
||||
- job: ttrss-web-nginx:build
|
||||
- job: phpunit-integration
|
||||
- job: selenium
|
||||
|
||||
ttrss-web-nginx:publish-gitlab:
|
||||
stage: publish
|
||||
extends: .crane-image-registry-push-master-gitlab
|
||||
variables:
|
||||
IMAGE_TAR: ${IMAGE_TAR_WEB}
|
||||
needs:
|
||||
- job: ttrss-web-nginx:build
|
||||
- job: phpunit-integration
|
||||
- job: selenium
|
||||
|
||||
update-demo:
|
||||
stage: publish
|
||||
extends: .update-helm-imagetag
|
||||
variables:
|
||||
CHART_REPO: gitlab.fakecake.org/git/helm-charts/tt-rss.git
|
||||
CHART_VALUES: values-demo.yaml
|
||||
ACCESS_TOKEN: ${DEMO_HELM_TOKEN}
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $DEMO_HELM_TOKEN != null
|
||||
|
||||
update-prod:
|
||||
stage: publish
|
||||
extends: .update-helm-imagetag
|
||||
variables:
|
||||
CHART_REPO: gitlab.fakecake.org/git/helm-charts/tt-rss-prod.git
|
||||
CHART_VALUES: values-prod.yaml
|
||||
ACCESS_TOKEN: ${PROD_HELM_TOKEN}
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $PROD_HELM_TOKEN != null
|
||||
|
||||
24
.vscode/launch.json
vendored
Normal file
24
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Listen for XDebug",
|
||||
"type": "php",
|
||||
"request": "launch",
|
||||
"pathMappings": {
|
||||
"/var/www/html/tt-rss": "${workspaceRoot}",
|
||||
},
|
||||
"port": 9000
|
||||
},
|
||||
{
|
||||
"name": "Launch Chrome",
|
||||
"request": "launch",
|
||||
"type": "chrome",
|
||||
"pathMapping": {
|
||||
"/tt-rss/": "${workspaceFolder}"
|
||||
},
|
||||
"urlFilter": "*/tt-rss/*",
|
||||
"runtimeExecutable": "chrome.exe",
|
||||
}
|
||||
]
|
||||
}
|
||||
46
.vscode/tasks.json
vendored
Normal file
46
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "shell",
|
||||
"label": "phpstan (watcher)",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"fileLocation": [
|
||||
"relative",
|
||||
"${workspaceRoot}"
|
||||
],
|
||||
"owner": "phpstan-watcher",
|
||||
"pattern": {
|
||||
"regexp": "^/app/(.*?):([0-9\\?]*):(.*)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"message": 3
|
||||
},
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": "Using configuration file",
|
||||
"endsPattern": "All done"
|
||||
}
|
||||
},
|
||||
"command": "chmod +x ${workspaceRoot}/utils/phpstan-watcher.sh && ${workspaceRoot}/utils/phpstan-watcher.sh"
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"label": "phpunit",
|
||||
"command": "chmod +x ${workspaceRoot}/utils/phpunit.sh && ${workspaceRoot}/utils/phpunit.sh",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"type": "gulp",
|
||||
"task": "default",
|
||||
"problemMatcher": [],
|
||||
"label": "gulp: default",
|
||||
"options": {
|
||||
"env": {
|
||||
"PATH": "${env:PATH}:/usr/lib/sdk/node16/bin/"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,20 +1,39 @@
|
||||
## Contributing code the right way
|
||||
## Contributing code the right way
|
||||
|
||||
New user accounts on Gogs are not allowed to fork repositories because of spam. To get
|
||||
initial fork access, do the following:
|
||||
TLDR: it works *almost* like Github.
|
||||
|
||||
1. Register on the forums and on Gogs
|
||||
2. Create a thread describing your proposed changes in Development subforum while
|
||||
including your Gogs username
|
||||
3. If your changes make sense to me, I'll update your repo limit and you'll be able to
|
||||
fork things and file pull requests
|
||||
Due to spam, new Gitlab users are set to [external](https://docs.gitlab.com/ee/user/admin_area/external_users.html). In order to do anything, you'll need to ask for your account to be promoted. Sorry for the inconvenience.
|
||||
|
||||
If you already have a fully functional Gogs account it works pretty much like Github:
|
||||
1. Register on the [Gitlab](https://gitlab.tt-rss.org);
|
||||
2. Post on the forums asking for your account to be promoted;
|
||||
3. Fork the repository you're interested in;
|
||||
4. Do the needful;
|
||||
6. File a PR against master branch and verify that CI pipeline (especially, PHPStan) passes;
|
||||
|
||||
1. Fork the repository you're interested in
|
||||
2. Do the needful
|
||||
3. File a pull request with your changes against master branch
|
||||
If you have any other questions, see this [forum thread](https://discourse.tt-rss.org/t/how-to-contribute-code-via-pull-requests-on-git-tt-rss-org/1850).
|
||||
|
||||
That's it. If you have any other questions, see this forum thread:
|
||||
Please don't inline patches in forum posts, attach files instead (``.patch`` or ``.diff`` file extensions should work).
|
||||
|
||||
https://discourse.tt-rss.org/t/how-to-contribute-code-via-pull-requests-on-git-tt-rss-org/1850
|
||||
### FAQ
|
||||
|
||||
#### How do I push or pull without SSH?
|
||||
|
||||
You can't use SSH directly because tt-rss Gitlab is behind Cloudflare. You can use HTTPS with personal access tokens instead.
|
||||
|
||||
Create a personal access token in [Gitlab preferences](https://gitlab.tt-rss.org/-/user_settings/personal_access_tokens);
|
||||
|
||||
Optionally, configure Git to transparently work with tt-rss Gitlab repositories using HTTPS:
|
||||
|
||||
```
|
||||
git config --global \
|
||||
--add url."https://gitlab-token:your-personal-access-token@gitlab.tt-rss.org/".insteadOf \
|
||||
"git@gitlab.tt-rss.org:"
|
||||
```
|
||||
|
||||
Alternatively, checkout over HTTPS while adding the token manually:
|
||||
|
||||
```
|
||||
git clone https://gitlab-token:your-personal-access-token@gitlab.tt-rss.org/tt-rss/tt-rss.git tt-rss
|
||||
```
|
||||
|
||||
That's it.
|
||||
|
||||
@@ -20,6 +20,3 @@ You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Copyright (c) 2005 Andrew Dolgov (unless explicitly stated otherwise).
|
||||
|
||||
Uses Silk icons by Mark James: http://www.famfamfam.com/lab/icons/silk/
|
||||
|
||||
|
||||
@@ -1,85 +1,58 @@
|
||||
<?php
|
||||
error_reporting(E_ERROR | E_PARSE);
|
||||
|
||||
require_once "../config.php";
|
||||
|
||||
set_include_path(dirname(__FILE__) . PATH_SEPARATOR .
|
||||
dirname(dirname(__FILE__)) . PATH_SEPARATOR .
|
||||
dirname(dirname(__FILE__)) . "/include" . PATH_SEPARATOR .
|
||||
set_include_path(__DIR__ . PATH_SEPARATOR .
|
||||
dirname(__DIR__) . PATH_SEPARATOR .
|
||||
dirname(__DIR__) . "/include" . PATH_SEPARATOR .
|
||||
get_include_path());
|
||||
|
||||
chdir("..");
|
||||
|
||||
define('TTRSS_SESSION_NAME', 'ttrss_api_sid');
|
||||
define('NO_SESSION_AUTOSTART', true);
|
||||
|
||||
require_once "autoload.php";
|
||||
require_once "db.php";
|
||||
require_once "db-prefs.php";
|
||||
require_once "functions.php";
|
||||
require_once "sessions.php";
|
||||
|
||||
ini_set('session.use_cookies', 0);
|
||||
ini_set("session.gc_maxlifetime", 86400);
|
||||
ini_set('session.use_cookies', "0");
|
||||
ini_set("session.gc_maxlifetime", "86400");
|
||||
|
||||
define('AUTH_DISABLE_OTP', true);
|
||||
ob_start();
|
||||
|
||||
if (defined('ENABLE_GZIP_OUTPUT') && ENABLE_GZIP_OUTPUT &&
|
||||
function_exists("ob_gzhandler")) {
|
||||
$_REQUEST = json_decode((string)file_get_contents("php://input"), true);
|
||||
|
||||
ob_start("ob_gzhandler");
|
||||
} else {
|
||||
ob_start();
|
||||
}
|
||||
|
||||
$input = file_get_contents("php://input");
|
||||
|
||||
if (defined('_API_DEBUG_HTTP_ENABLED') && _API_DEBUG_HTTP_ENABLED) {
|
||||
// Override $_REQUEST with JSON-encoded data if available
|
||||
// fallback on HTTP parameters
|
||||
if ($input) {
|
||||
$input = json_decode($input, true);
|
||||
if ($input) $_REQUEST = $input;
|
||||
}
|
||||
} else {
|
||||
// Accept JSON only
|
||||
$input = json_decode($input, true);
|
||||
$_REQUEST = $input;
|
||||
}
|
||||
|
||||
if ($_REQUEST["sid"]) {
|
||||
if (!empty($_REQUEST["sid"])) {
|
||||
session_id($_REQUEST["sid"]);
|
||||
@session_start();
|
||||
} else if (defined('_API_DEBUG_HTTP_ENABLED')) {
|
||||
@session_start();
|
||||
session_start();
|
||||
}
|
||||
|
||||
startup_gettext();
|
||||
|
||||
if (!init_plugins()) return;
|
||||
|
||||
if ($_SESSION["uid"]) {
|
||||
if (!validate_session()) {
|
||||
if (!empty($_SESSION["uid"])) {
|
||||
if (!Sessions::validate_session()) {
|
||||
header("Content-Type: text/json");
|
||||
|
||||
print json_encode(array("seq" => -1,
|
||||
"status" => 1,
|
||||
"content" => array("error" => "NOT_LOGGED_IN")));
|
||||
print json_encode([
|
||||
"seq" => -1,
|
||||
"status" => API::STATUS_ERR,
|
||||
"content" => [ "error" => API::E_NOT_LOGGED_IN ]
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
load_user_plugins( $_SESSION["uid"]);
|
||||
UserHelper::load_user_plugins($_SESSION["uid"]);
|
||||
}
|
||||
|
||||
$method = strtolower($_REQUEST["op"]);
|
||||
$method = strtolower($_REQUEST["op"] ?? "");
|
||||
|
||||
$handler = new API($_REQUEST);
|
||||
|
||||
if ($handler->before($method)) {
|
||||
if ($method && method_exists($handler, $method)) {
|
||||
$handler->$method();
|
||||
} else if (method_exists($handler, 'index')) {
|
||||
} else /* if (method_exists($handler, 'index')) */ {
|
||||
$handler->index($method);
|
||||
}
|
||||
$handler->after();
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xsl:stylesheet
|
||||
xmlns:atom="http://www.w3.org/2005/Atom"
|
||||
xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
|
||||
|
||||
<xsl:output method="html"/>
|
||||
|
||||
<xsl:template match="/atom:feed">
|
||||
<html>
|
||||
<head>
|
||||
<title><xsl:value-of select="atom:title"/></title>
|
||||
<link rel="stylesheet" type="text/css" href="css/default.css"/>
|
||||
<script language="javascript" src="lib/xsl_mop-up.js"></script>
|
||||
</head>
|
||||
|
||||
<body onload="go_decoding()" class="ttrss_utility">
|
||||
|
||||
<div id="cometestme" style="display:none;">
|
||||
<xsl:text disable-output-escaping="yes">&amp;</xsl:text>
|
||||
</div>
|
||||
|
||||
<div class="rss">
|
||||
|
||||
<h1><xsl:value-of select="atom:title"/></h1>
|
||||
|
||||
<p class="description">This feed has been exported from
|
||||
<a target="_new" class="extlink" href="http://tt-rss.org">Tiny Tiny RSS</a>.
|
||||
It contains the following items:</p>
|
||||
|
||||
<xsl:for-each select="atom:entry">
|
||||
<h2><a target="_new" href="{atom:link/@href}"><xsl:value-of select="atom:title"/></a></h2>
|
||||
|
||||
<div name="decodeme" class="content">
|
||||
<xsl:value-of select="atom:content" disable-output-escaping="yes"/>
|
||||
</div>
|
||||
|
||||
<xsl:if test="enclosure">
|
||||
<p><a href="{enclosure/@url}">Extra...</a></p>
|
||||
</xsl:if>
|
||||
|
||||
|
||||
</xsl:for-each>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</xsl:template>
|
||||
|
||||
</xsl:stylesheet>
|
||||
|
||||
154
backend.php
154
backend.php
@@ -1,24 +1,11 @@
|
||||
<?php
|
||||
set_include_path(dirname(__FILE__) ."/include" . PATH_SEPARATOR .
|
||||
set_include_path(__DIR__ ."/include" . PATH_SEPARATOR .
|
||||
get_include_path());
|
||||
|
||||
/* remove ill effects of magic quotes */
|
||||
|
||||
if (get_magic_quotes_gpc()) {
|
||||
function stripslashes_deep($value) {
|
||||
$value = is_array($value) ?
|
||||
array_map('stripslashes_deep', $value) : stripslashes($value);
|
||||
return $value;
|
||||
}
|
||||
|
||||
$_POST = array_map('stripslashes_deep', $_POST);
|
||||
$_GET = array_map('stripslashes_deep', $_GET);
|
||||
$_COOKIE = array_map('stripslashes_deep', $_COOKIE);
|
||||
$_REQUEST = array_map('stripslashes_deep', $_REQUEST);
|
||||
}
|
||||
|
||||
$op = $_REQUEST["op"];
|
||||
@$method = $_REQUEST['subop'] ? $_REQUEST['subop'] : $_REQUEST["method"];
|
||||
$op = $_REQUEST['op'] ?? '';
|
||||
$method = !empty($_REQUEST['subop']) ?
|
||||
$_REQUEST['subop'] :
|
||||
$_REQUEST["method"] ?? false;
|
||||
|
||||
if (!$method)
|
||||
$method = 'index';
|
||||
@@ -27,52 +14,57 @@
|
||||
|
||||
/* Public calls compatibility shim */
|
||||
|
||||
$public_calls = array("globalUpdateFeeds", "rss", "getUnread", "getProfiles", "share",
|
||||
"fbexport", "logout", "pubsub");
|
||||
$public_calls = array("globalUpdateFeeds", "rss", "getUnread", "getProfiles", "share");
|
||||
|
||||
if (array_search($op, $public_calls) !== false) {
|
||||
header("Location: public.php?" . $_SERVER['QUERY_STRING']);
|
||||
return;
|
||||
}
|
||||
|
||||
@$csrf_token = $_REQUEST['csrf_token'];
|
||||
$csrf_token = $_POST['csrf_token'] ?? "";
|
||||
|
||||
require_once "autoload.php";
|
||||
require_once "sessions.php";
|
||||
require_once "functions.php";
|
||||
require_once "config.php";
|
||||
require_once "db.php";
|
||||
require_once "db-prefs.php";
|
||||
|
||||
$op = (string)clean($op);
|
||||
$method = (string)clean($method);
|
||||
|
||||
startup_gettext();
|
||||
|
||||
$script_started = microtime(true);
|
||||
if (!init_plugins()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!init_plugins()) return;
|
||||
$span = OpenTelemetry\API\Trace\Span::getCurrent();
|
||||
|
||||
header("Content-Type: text/json; charset=utf-8");
|
||||
|
||||
if (ENABLE_GZIP_OUTPUT && function_exists("ob_gzhandler")) {
|
||||
ob_start("ob_gzhandler");
|
||||
if (Config::get(Config::SINGLE_USER_MODE)) {
|
||||
UserHelper::authenticate("admin", null);
|
||||
}
|
||||
|
||||
if (SINGLE_USER_MODE) {
|
||||
authenticate_user( "admin", null);
|
||||
}
|
||||
|
||||
if ($_SESSION["uid"]) {
|
||||
if (!validate_session()) {
|
||||
if (!empty($_SESSION["uid"])) {
|
||||
if (!Sessions::validate_session()) {
|
||||
header("Content-Type: text/json");
|
||||
print error_json(6);
|
||||
print Errors::to_json(Errors::E_UNAUTHORIZED);
|
||||
|
||||
$span->setAttribute('error', Errors::E_UNAUTHORIZED);
|
||||
return;
|
||||
}
|
||||
load_user_plugins( $_SESSION["uid"]);
|
||||
UserHelper::load_user_plugins($_SESSION["uid"]);
|
||||
}
|
||||
|
||||
if (Config::is_migration_needed()) {
|
||||
print Errors::to_json(Errors::E_SCHEMA_MISMATCH);
|
||||
|
||||
$span->setAttribute('error', Errors::E_SCHEMA_MISMATCH);
|
||||
return;
|
||||
}
|
||||
|
||||
$purge_intervals = array(
|
||||
0 => __("Use default"),
|
||||
-1 => __("Never purge"),
|
||||
5 => __("1 week old"),
|
||||
7 => __("1 week old"),
|
||||
14 => __("2 weeks old"),
|
||||
31 => __("1 month old"),
|
||||
60 => __("2 months old"),
|
||||
@@ -99,49 +91,103 @@
|
||||
1440 => __("Daily"),
|
||||
10080 => __("Weekly"));
|
||||
|
||||
$access_level_names = array(
|
||||
0 => __("User"),
|
||||
5 => __("Power User"),
|
||||
10 => __("Administrator"));
|
||||
$access_level_names = [
|
||||
UserHelper::ACCESS_LEVEL_DISABLED => __("Disabled"),
|
||||
UserHelper::ACCESS_LEVEL_READONLY => __("Read Only"),
|
||||
UserHelper::ACCESS_LEVEL_USER => __("User"),
|
||||
UserHelper::ACCESS_LEVEL_POWERUSER => __("Power User"),
|
||||
UserHelper::ACCESS_LEVEL_ADMIN => __("Administrator")
|
||||
];
|
||||
|
||||
$op = str_replace("-", "_", $op);
|
||||
// shortcut syntax for plugin methods (?op=plugin--pmethod&...params)
|
||||
/* if (strpos($op, PluginHost::PUBLIC_METHOD_DELIMITER) !== false) {
|
||||
list ($plugin, $pmethod) = explode(PluginHost::PUBLIC_METHOD_DELIMITER, $op, 2);
|
||||
|
||||
// TODO: better implementation that won't modify $_REQUEST
|
||||
$_REQUEST["plugin"] = $plugin;
|
||||
$method = $pmethod;
|
||||
$op = "pluginhandler";
|
||||
} */
|
||||
|
||||
// $op = str_replace(, "_", $op);
|
||||
|
||||
$override = PluginHost::getInstance()->lookup_handler($op, $method);
|
||||
|
||||
if (class_exists($op) || $override) {
|
||||
|
||||
if ($override) {
|
||||
$handler = $override;
|
||||
} else {
|
||||
$handler = new $op($_REQUEST);
|
||||
if (strpos($method, "_") === 0) {
|
||||
user_error("Refusing to invoke method $method of handler $op which starts with underscore.", E_USER_WARNING);
|
||||
header("Content-Type: text/json");
|
||||
print Errors::to_json(Errors::E_UNAUTHORIZED);
|
||||
|
||||
$span->setAttribute('error', Errors::E_UNAUTHORIZED);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($handler && implements_interface($handler, 'IHandler')) {
|
||||
if ($override) {
|
||||
/** @var Plugin|IHandler|ICatchall $handler */
|
||||
$handler = $override;
|
||||
} else {
|
||||
$reflection = new ReflectionClass($op);
|
||||
$handler = $reflection->newInstanceWithoutConstructor();
|
||||
}
|
||||
|
||||
if (implements_interface($handler, 'IHandler')) {
|
||||
$span->addEvent("construct/$op");
|
||||
$handler->__construct($_REQUEST);
|
||||
|
||||
if (validate_csrf($csrf_token) || $handler->csrf_ignore($method)) {
|
||||
if ($handler->before($method)) {
|
||||
|
||||
$span->addEvent("before/$method");
|
||||
$before = $handler->before($method);
|
||||
|
||||
if ($before) {
|
||||
$span->addEvent("method/$method");
|
||||
if ($method && method_exists($handler, $method)) {
|
||||
$handler->$method();
|
||||
$reflection = new ReflectionMethod($handler, $method);
|
||||
|
||||
if ($reflection->getNumberOfRequiredParameters() == 0) {
|
||||
$handler->$method();
|
||||
} else {
|
||||
user_error("Refusing to invoke method $method of handler $op which has required parameters.", E_USER_WARNING);
|
||||
header("Content-Type: text/json");
|
||||
|
||||
$span->setAttribute('error', Errors::E_UNAUTHORIZED);
|
||||
print Errors::to_json(Errors::E_UNAUTHORIZED);
|
||||
}
|
||||
} else {
|
||||
if (method_exists($handler, "catchall")) {
|
||||
$handler->catchall($method);
|
||||
} else {
|
||||
header("Content-Type: text/json");
|
||||
|
||||
$span->setAttribute('error', Errors::E_UNKNOWN_METHOD);
|
||||
print Errors::to_json(Errors::E_UNKNOWN_METHOD, ["info" => get_class($handler) . "->$method"]);
|
||||
}
|
||||
}
|
||||
|
||||
$span->addEvent("after/$method");
|
||||
$handler->after();
|
||||
return;
|
||||
} else {
|
||||
header("Content-Type: text/json");
|
||||
print error_json(6);
|
||||
print Errors::to_json(Errors::E_UNAUTHORIZED);
|
||||
|
||||
$span->setAttribute('error', Errors::E_UNAUTHORIZED);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
user_error("Refusing to invoke method $method of handler $op with invalid CSRF token.", E_USER_WARNING);
|
||||
header("Content-Type: text/json");
|
||||
print error_json(6);
|
||||
print Errors::to_json(Errors::E_UNAUTHORIZED);
|
||||
|
||||
$span->setAttribute('error', Errors::E_UNAUTHORIZED);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
header("Content-Type: text/json");
|
||||
print error_json(13);
|
||||
print Errors::to_json(Errors::E_UNKNOWN_METHOD, [ "info" => (isset($handler) ? get_class($handler) : "UNKNOWN:".$op) . "->$method"]);
|
||||
|
||||
?>
|
||||
$span->setAttribute('error', Errors::E_UNKNOWN_METHOD);
|
||||
|
||||
0
cache/export/.empty
vendored
Executable file → Normal file
0
cache/export/.empty
vendored
Executable file → Normal file
0
cache/images/.empty
vendored
Executable file → Normal file
0
cache/images/.empty
vendored
Executable file → Normal file
953
classes/API.php
Normal file
953
classes/API.php
Normal file
@@ -0,0 +1,953 @@
|
||||
<?php
|
||||
class API extends Handler {
|
||||
|
||||
const API_LEVEL = 21;
|
||||
|
||||
const STATUS_OK = 0;
|
||||
const STATUS_ERR = 1;
|
||||
|
||||
const E_API_DISABLED = "API_DISABLED";
|
||||
const E_NOT_LOGGED_IN = "NOT_LOGGED_IN";
|
||||
const E_LOGIN_ERROR = "LOGIN_ERROR";
|
||||
const E_INCORRECT_USAGE = "INCORRECT_USAGE";
|
||||
const E_UNKNOWN_METHOD = "UNKNOWN_METHOD";
|
||||
const E_OPERATION_FAILED = "E_OPERATION_FAILED";
|
||||
const E_NOT_FOUND = "E_NOT_FOUND";
|
||||
|
||||
/** @var int|null */
|
||||
private $seq;
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $reply
|
||||
*/
|
||||
private function _wrap(int $status, array $reply): bool {
|
||||
print json_encode([
|
||||
"seq" => $this->seq,
|
||||
"status" => $status,
|
||||
"content" => $reply
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function before(string $method): bool {
|
||||
if (parent::before($method)) {
|
||||
header("Content-Type: text/json");
|
||||
|
||||
if (empty($_SESSION["uid"]) && $method != "login" && $method != "isloggedin") {
|
||||
$this->_wrap(self::STATUS_ERR, array("error" => self::E_NOT_LOGGED_IN));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!empty($_SESSION["uid"]) && $method != "logout" && !get_pref(Prefs::ENABLE_API_ACCESS)) {
|
||||
$this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED));
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->seq = (int) clean($_REQUEST['seq'] ?? 0);
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getVersion(): bool {
|
||||
$rv = array("version" => Config::get_version());
|
||||
return $this->_wrap(self::STATUS_OK, $rv);
|
||||
}
|
||||
|
||||
function getApiLevel(): bool {
|
||||
$rv = array("level" => self::API_LEVEL);
|
||||
return $this->_wrap(self::STATUS_OK, $rv);
|
||||
}
|
||||
|
||||
function login(): bool {
|
||||
|
||||
if (session_status() == PHP_SESSION_ACTIVE) {
|
||||
session_destroy();
|
||||
}
|
||||
|
||||
session_start();
|
||||
|
||||
$login = clean($_REQUEST["user"]);
|
||||
$password = clean($_REQUEST["password"]);
|
||||
|
||||
if (Config::get(Config::SINGLE_USER_MODE)) $login = "admin";
|
||||
|
||||
if ($uid = UserHelper::find_user_by_login($login)) {
|
||||
if (get_pref(Prefs::ENABLE_API_ACCESS, $uid)) {
|
||||
if (UserHelper::authenticate($login, $password, false, Auth_Base::AUTH_SERVICE_API)) {
|
||||
|
||||
// needed for _get_config()
|
||||
UserHelper::load_user_plugins($_SESSION['uid']);
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, array("session_id" => session_id(),
|
||||
"config" => $this->_get_config(),
|
||||
"api_level" => self::API_LEVEL));
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR));
|
||||
}
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED));
|
||||
}
|
||||
}
|
||||
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR));
|
||||
}
|
||||
|
||||
function logout(): bool {
|
||||
UserHelper::logout();
|
||||
return $this->_wrap(self::STATUS_OK, array("status" => "OK"));
|
||||
}
|
||||
|
||||
function isLoggedIn(): bool {
|
||||
return $this->_wrap(self::STATUS_OK, array("status" => (bool)($_SESSION["uid"] ?? '')));
|
||||
}
|
||||
|
||||
function getUnread(): bool {
|
||||
$feed_id = clean($_REQUEST["feed_id"] ?? "");
|
||||
$is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false);
|
||||
|
||||
if ($feed_id) {
|
||||
return $this->_wrap(self::STATUS_OK, array("unread" => Feeds::_get_counters($feed_id, $is_cat, true)));
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_OK, array("unread" => Feeds::_get_global_unread()));
|
||||
}
|
||||
}
|
||||
|
||||
/* Method added for ttrss-reader for Android */
|
||||
function getCounters(): bool {
|
||||
return $this->_wrap(self::STATUS_OK, Counters::get_all());
|
||||
}
|
||||
|
||||
function getFeeds(): bool {
|
||||
$cat_id = (int) clean($_REQUEST["cat_id"]);
|
||||
$unread_only = self::_param_to_bool($_REQUEST["unread_only"] ?? false);
|
||||
$limit = (int) clean($_REQUEST["limit"] ?? 0);
|
||||
$offset = (int) clean($_REQUEST["offset"] ?? 0);
|
||||
$include_nested = self::_param_to_bool($_REQUEST["include_nested"] ?? false);
|
||||
|
||||
$feeds = $this->_api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested);
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, $feeds);
|
||||
}
|
||||
|
||||
function getCategories(): bool {
|
||||
$unread_only = self::_param_to_bool($_REQUEST["unread_only"] ?? false);
|
||||
$enable_nested = self::_param_to_bool($_REQUEST["enable_nested"] ?? false);
|
||||
$include_empty = self::_param_to_bool($_REQUEST["include_empty"] ?? false);
|
||||
|
||||
// TODO do not return empty categories, return Uncategorized and standard virtual cats
|
||||
|
||||
$categories = ORM::for_table('ttrss_feed_categories')
|
||||
->select_many('id', 'title', 'order_id')
|
||||
->select_many_expr([
|
||||
'num_feeds' => '(SELECT COUNT(id) FROM ttrss_feeds WHERE ttrss_feed_categories.id IS NOT NULL AND cat_id = ttrss_feed_categories.id)',
|
||||
'num_cats' => '(SELECT COUNT(id) FROM ttrss_feed_categories AS c2 WHERE c2.parent_cat = ttrss_feed_categories.id)',
|
||||
])
|
||||
->where('owner_uid', $_SESSION['uid']);
|
||||
|
||||
if ($enable_nested) {
|
||||
$categories->where_null('parent_cat');
|
||||
}
|
||||
|
||||
$cats = [];
|
||||
|
||||
foreach ($categories->find_many() as $category) {
|
||||
if ($include_empty || $category->num_feeds > 0 || $category->num_cats > 0) {
|
||||
$unread = Feeds::_get_counters($category->id, true, true);
|
||||
|
||||
if ($enable_nested)
|
||||
$unread += Feeds::_get_cat_children_unread($category->id);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
array_push($cats, [
|
||||
'id' => (int) $category->id,
|
||||
'title' => $category->title,
|
||||
'unread' => (int) $unread,
|
||||
'order_id' => (int) $category->order_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([Feeds::CATEGORY_LABELS, Feeds::CATEGORY_SPECIAL, Feeds::CATEGORY_UNCATEGORIZED] as $cat_id) {
|
||||
if ($include_empty || !$this->_is_cat_empty($cat_id)) {
|
||||
$unread = Feeds::_get_counters($cat_id, true, true);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
array_push($cats, [
|
||||
'id' => $cat_id,
|
||||
'title' => Feeds::_get_cat_title($cat_id),
|
||||
'unread' => (int) $unread,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, $cats);
|
||||
}
|
||||
|
||||
function getHeadlines(): bool {
|
||||
$feed_id = clean($_REQUEST["feed_id"] ?? "");
|
||||
|
||||
if (!empty($feed_id) || is_numeric($feed_id)) { // is_numeric for feed_id "0"
|
||||
$limit = (int)clean($_REQUEST["limit"] ?? 0 );
|
||||
|
||||
if (!$limit || $limit >= 200) $limit = 200;
|
||||
|
||||
$offset = (int)clean($_REQUEST["skip"] ?? 0);
|
||||
$filter = clean($_REQUEST["filter"] ?? "");
|
||||
$is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false);
|
||||
$show_excerpt = self::_param_to_bool($_REQUEST["show_excerpt"] ?? false);
|
||||
$show_content = self::_param_to_bool($_REQUEST["show_content"] ?? false);
|
||||
/* all_articles, unread, adaptive, marked, updated */
|
||||
$view_mode = clean($_REQUEST["view_mode"] ?? null);
|
||||
$include_attachments = self::_param_to_bool($_REQUEST["include_attachments"] ?? false);
|
||||
$since_id = (int)clean($_REQUEST["since_id"] ?? 0);
|
||||
$include_nested = self::_param_to_bool($_REQUEST["include_nested"] ?? false);
|
||||
$sanitize_content = self::_param_to_bool($_REQUEST["sanitize"] ?? true);
|
||||
$force_update = self::_param_to_bool($_REQUEST["force_update"] ?? false);
|
||||
$has_sandbox = self::_param_to_bool($_REQUEST["has_sandbox"] ?? false);
|
||||
$excerpt_length = (int)clean($_REQUEST["excerpt_length"] ?? 0);
|
||||
$check_first_id = (int)clean($_REQUEST["check_first_id"] ?? 0);
|
||||
$include_header = self::_param_to_bool($_REQUEST["include_header"] ?? false);
|
||||
|
||||
$_SESSION['hasSandbox'] = $has_sandbox;
|
||||
|
||||
list($override_order, $skip_first_id_check) = Feeds::_order_to_override_query(clean($_REQUEST["order_by"] ?? ""));
|
||||
|
||||
/* do not rely on params below */
|
||||
|
||||
$search = clean($_REQUEST["search"] ?? "");
|
||||
|
||||
list($headlines, $headlines_header) = $this->_api_get_headlines($feed_id, $limit, $offset,
|
||||
$filter, $is_cat, $show_excerpt, $show_content, $view_mode, $override_order,
|
||||
$include_attachments, $since_id, $search,
|
||||
$include_nested, $sanitize_content, $force_update, $excerpt_length, $check_first_id, $skip_first_id_check);
|
||||
|
||||
if ($include_header) {
|
||||
return $this->_wrap(self::STATUS_OK, array($headlines_header, $headlines));
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_OK, $headlines);
|
||||
}
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE));
|
||||
}
|
||||
}
|
||||
|
||||
function updateArticle(): bool {
|
||||
$article_ids = explode(",", clean($_REQUEST["article_ids"]));
|
||||
$mode = (int) clean($_REQUEST["mode"]);
|
||||
$data = clean($_REQUEST["data"] ?? "");
|
||||
$field_raw = (int)clean($_REQUEST["field"]);
|
||||
|
||||
$field = "";
|
||||
$set_to = "";
|
||||
$additional_fields = "";
|
||||
|
||||
switch ($field_raw) {
|
||||
case 0:
|
||||
$field = "marked";
|
||||
$additional_fields = ",last_marked = NOW()";
|
||||
break;
|
||||
case 1:
|
||||
$field = "published";
|
||||
$additional_fields = ",last_published = NOW()";
|
||||
break;
|
||||
case 2:
|
||||
$field = "unread";
|
||||
$additional_fields = ",last_read = NOW()";
|
||||
break;
|
||||
case 3:
|
||||
$field = "note";
|
||||
break;
|
||||
case 4:
|
||||
$field = "score";
|
||||
break;
|
||||
};
|
||||
|
||||
switch ($mode) {
|
||||
case 1:
|
||||
$set_to = "true";
|
||||
break;
|
||||
case 0:
|
||||
$set_to = "false";
|
||||
break;
|
||||
case 2:
|
||||
$set_to = "NOT $field";
|
||||
break;
|
||||
}
|
||||
|
||||
if ($field == "note") $set_to = $this->pdo->quote($data);
|
||||
if ($field == "score") $set_to = (int) $data;
|
||||
|
||||
if ($field && $set_to && count($article_ids) > 0) {
|
||||
|
||||
$article_qmarks = arr_qmarks($article_ids);
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
$field = $set_to $additional_fields
|
||||
WHERE ref_id IN ($article_qmarks) AND owner_uid = ?");
|
||||
$sth->execute([...$article_ids, $_SESSION['uid']]);
|
||||
|
||||
$num_updated = $sth->rowCount();
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, array("status" => "OK",
|
||||
"updated" => $num_updated));
|
||||
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE));
|
||||
}
|
||||
}
|
||||
|
||||
function getArticle(): bool {
|
||||
$article_ids = explode(',', clean($_REQUEST['article_id'] ?? ''));
|
||||
$sanitize_content = self::_param_to_bool($_REQUEST['sanitize'] ?? true);
|
||||
|
||||
// @phpstan-ignore-next-line
|
||||
if (count($article_ids)) {
|
||||
$entries = ORM::for_table('ttrss_entries')
|
||||
->table_alias('e')
|
||||
->select_many('e.id', 'e.guid', 'e.title', 'e.link', 'e.author', 'e.content', 'e.lang', 'e.comments',
|
||||
'ue.feed_id', 'ue.int_id', 'ue.marked', 'ue.unread', 'ue.published', 'ue.score', 'ue.note')
|
||||
->select_many_expr([
|
||||
'updated' => SUBSTRING_FOR_DATE.'(updated,1,16)',
|
||||
'feed_title' => '(SELECT title FROM ttrss_feeds WHERE id = ue.feed_id)',
|
||||
'site_url' => '(SELECT site_url FROM ttrss_feeds WHERE id = ue.feed_id)',
|
||||
'hide_images' => '(SELECT hide_images FROM ttrss_feeds WHERE id = feed_id)',
|
||||
])
|
||||
->join('ttrss_user_entries', [ 'ue.ref_id', '=', 'e.id'], 'ue')
|
||||
->where_in('e.id', array_map('intval', $article_ids))
|
||||
->where('ue.owner_uid', $_SESSION['uid'])
|
||||
->find_many();
|
||||
|
||||
$articles = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$article = [
|
||||
'id' => $entry->id,
|
||||
'guid' => $entry->guid,
|
||||
'title' => $entry->title,
|
||||
'link' => $entry->link,
|
||||
'labels' => Article::_get_labels($entry->id),
|
||||
'unread' => self::_param_to_bool($entry->unread),
|
||||
'marked' => self::_param_to_bool($entry->marked),
|
||||
'published' => self::_param_to_bool($entry->published),
|
||||
'comments' => $entry->comments,
|
||||
'author' => $entry->author,
|
||||
'updated' => (int) strtotime($entry->updated ?? ''),
|
||||
'feed_id' => $entry->feed_id,
|
||||
'attachments' => Article::_get_enclosures($entry->id),
|
||||
'score' => (int) $entry->score,
|
||||
'feed_title' => $entry->feed_title,
|
||||
'note' => $entry->note,
|
||||
'lang' => $entry->lang,
|
||||
];
|
||||
|
||||
if ($sanitize_content) {
|
||||
$article['content'] = Sanitizer::sanitize(
|
||||
$entry->content,
|
||||
self::_param_to_bool($entry->hide_images),
|
||||
null, $entry->site_url, null, $entry->id);
|
||||
} else {
|
||||
$article['content'] = $entry->content;
|
||||
}
|
||||
|
||||
$hook_object = ['article' => &$article];
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_API,
|
||||
function ($result) use (&$article) {
|
||||
$article = $result;
|
||||
},
|
||||
$hook_object);
|
||||
|
||||
$article['content'] = DiskCache::rewrite_urls($article['content']);
|
||||
|
||||
array_push($articles, $article);
|
||||
}
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, $articles);
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, ['error' => self::E_INCORRECT_USAGE]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, string>|bool|int|string>
|
||||
*/
|
||||
private function _get_config(): array {
|
||||
$config = [
|
||||
"icons_dir" => Config::get(Config::ICONS_DIR),
|
||||
"icons_url" => Config::get(Config::ICONS_URL)
|
||||
];
|
||||
|
||||
$config["daemon_is_running"] = file_is_locked("update_daemon.lock");
|
||||
$config["custom_sort_types"] = $this->_get_custom_sort_types();
|
||||
|
||||
$config["num_feeds"] = ORM::for_table('ttrss_feeds')
|
||||
->where('owner_uid', $_SESSION['uid'])
|
||||
->count();
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
function getConfig(): bool {
|
||||
$config = $this->_get_config();
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, $config);
|
||||
}
|
||||
|
||||
function updateFeed(): bool {
|
||||
$feed_id = (int) clean($_REQUEST["feed_id"]);
|
||||
|
||||
if (!ini_get("open_basedir")) {
|
||||
RSSUtils::update_rss_feed($feed_id);
|
||||
}
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, array("status" => "OK"));
|
||||
}
|
||||
|
||||
function catchupFeed(): bool {
|
||||
$feed_id = clean($_REQUEST["feed_id"]);
|
||||
$is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false);
|
||||
$mode = clean($_REQUEST["mode"] ?? "");
|
||||
|
||||
if (!in_array($mode, ["all", "1day", "1week", "2week"]))
|
||||
$mode = "all";
|
||||
|
||||
Feeds::_catchup($feed_id, $is_cat, $_SESSION["uid"], $mode);
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, array("status" => "OK"));
|
||||
}
|
||||
|
||||
function getPref(): bool {
|
||||
$pref_name = clean($_REQUEST["pref_name"]);
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, array("value" => get_pref($pref_name)));
|
||||
}
|
||||
|
||||
function getLabels(): bool {
|
||||
$article_id = (int)clean($_REQUEST['article_id'] ?? -1);
|
||||
|
||||
$rv = [];
|
||||
|
||||
$labels = ORM::for_table('ttrss_labels2')
|
||||
->where('owner_uid', $_SESSION['uid'])
|
||||
->order_by_asc('caption')
|
||||
->find_many();
|
||||
|
||||
if ($article_id)
|
||||
$article_labels = Article::_get_labels($article_id);
|
||||
else
|
||||
$article_labels = [];
|
||||
|
||||
foreach ($labels as $label) {
|
||||
$checked = false;
|
||||
foreach ($article_labels as $al) {
|
||||
if (Labels::feed_to_label_id($al[0]) == $label->id) {
|
||||
$checked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
array_push($rv, [
|
||||
'id' => (int) Labels::label_to_feed_id($label->id),
|
||||
'caption' => $label->caption,
|
||||
'fg_color' => $label->fg_color,
|
||||
'bg_color' => $label->bg_color,
|
||||
'checked' => $checked,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, $rv);
|
||||
}
|
||||
|
||||
function setArticleLabel(): bool {
|
||||
|
||||
$article_ids = explode(",", clean($_REQUEST["article_ids"]));
|
||||
$label_id = (int) clean($_REQUEST['label_id']);
|
||||
$assign = self::_param_to_bool(clean($_REQUEST['assign']));
|
||||
|
||||
$label = Labels::find_caption(Labels::feed_to_label_id($label_id), $_SESSION["uid"]);
|
||||
|
||||
$num_updated = 0;
|
||||
|
||||
if ($label) {
|
||||
|
||||
foreach ($article_ids as $id) {
|
||||
|
||||
if ($assign)
|
||||
Labels::add_article((int)$id, $label, $_SESSION["uid"]);
|
||||
else
|
||||
Labels::remove_article((int)$id, $label, $_SESSION["uid"]);
|
||||
|
||||
++$num_updated;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, array("status" => "OK",
|
||||
"updated" => $num_updated));
|
||||
|
||||
}
|
||||
|
||||
function index(string $method): bool {
|
||||
$plugin = PluginHost::getInstance()->get_api_method(strtolower($method));
|
||||
|
||||
if ($plugin && method_exists($plugin, $method)) {
|
||||
$reply = $plugin->$method();
|
||||
|
||||
return $this->_wrap($reply[0], $reply[1]);
|
||||
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_UNKNOWN_METHOD, "method" => $method));
|
||||
}
|
||||
}
|
||||
|
||||
function shareToPublished(): bool {
|
||||
$title = clean($_REQUEST["title"]);
|
||||
$url = clean($_REQUEST["url"]);
|
||||
$sanitize_content = self::_param_to_bool($_REQUEST["sanitize"] ?? true);
|
||||
|
||||
if ($sanitize_content)
|
||||
$content = clean($_REQUEST["content"]);
|
||||
else
|
||||
$content = $_REQUEST["content"];
|
||||
|
||||
if (Article::_create_published_article($title, $url, $content, "", $_SESSION["uid"])) {
|
||||
return $this->_wrap(self::STATUS_OK, array("status" => 'OK'));
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_OPERATION_FAILED));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{'id': int, 'title': string, 'unread': int, 'cat_id': int}>
|
||||
*/
|
||||
private static function _api_get_feeds(int $cat_id, bool $unread_only, int $limit, int $offset, bool $include_nested = false): array {
|
||||
$feeds = [];
|
||||
|
||||
/* Labels */
|
||||
|
||||
/* API only: -4 (Feeds::CATEGORY_ALL) All feeds, including virtual feeds */
|
||||
if ($cat_id == Feeds::CATEGORY_ALL || $cat_id == Feeds::CATEGORY_LABELS) {
|
||||
$counters = Counters::get_labels();
|
||||
|
||||
foreach (array_values($counters) as $cv) {
|
||||
$unread = $cv['counter'];
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
$row = [
|
||||
'id' => (int) $cv['id'],
|
||||
'title' => $cv['description'],
|
||||
'unread' => $cv['counter'],
|
||||
'cat_id' => Feeds::CATEGORY_LABELS,
|
||||
];
|
||||
|
||||
array_push($feeds, $row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Virtual feeds */
|
||||
|
||||
$vfeeds = PluginHost::getInstance()->get_feeds(Feeds::CATEGORY_SPECIAL);
|
||||
|
||||
if (is_array($vfeeds)) {
|
||||
foreach ($vfeeds as $feed) {
|
||||
if (!implements_interface($feed['sender'], 'IVirtualFeed'))
|
||||
continue;
|
||||
|
||||
/** @var IVirtualFeed $feed['sender'] */
|
||||
$unread = $feed['sender']->get_unread($feed['id']);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
$row = [
|
||||
'id' => PluginHost::pfeed_to_feed_id($feed['id']),
|
||||
'title' => $feed['title'],
|
||||
'unread' => $unread,
|
||||
'cat_id' => Feeds::CATEGORY_SPECIAL,
|
||||
];
|
||||
|
||||
array_push($feeds, $row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($cat_id == Feeds::CATEGORY_ALL || $cat_id == Feeds::CATEGORY_SPECIAL) {
|
||||
foreach ([Feeds::FEED_STARRED, Feeds::FEED_PUBLISHED, Feeds::FEED_FRESH,
|
||||
Feeds::FEED_ALL, Feeds::FEED_RECENTLY_READ, Feeds::FEED_ARCHIVED] as $i) {
|
||||
$unread = Feeds::_get_counters($i, false, true);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
$title = Feeds::_get_title($i);
|
||||
|
||||
$row = [
|
||||
'id' => $i,
|
||||
'title' => $title,
|
||||
'unread' => $unread,
|
||||
'cat_id' => Feeds::CATEGORY_SPECIAL,
|
||||
];
|
||||
|
||||
array_push($feeds, $row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Child cats */
|
||||
|
||||
if ($include_nested && $cat_id) {
|
||||
$categories = ORM::for_table('ttrss_feed_categories')
|
||||
->where(['parent_cat' => $cat_id, 'owner_uid' => $_SESSION['uid']])
|
||||
->order_by_asc('order_id')
|
||||
->order_by_asc('title')
|
||||
->find_many();
|
||||
|
||||
foreach ($categories as $category) {
|
||||
$unread = Feeds::_get_counters($category->id, true, true) +
|
||||
Feeds::_get_cat_children_unread($category->id);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
$row = [
|
||||
'id' => (int) $category->id,
|
||||
'title' => $category->title,
|
||||
'unread' => $unread,
|
||||
'is_cat' => true,
|
||||
'order_id' => (int) $category->order_id,
|
||||
];
|
||||
array_push($feeds, $row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Real feeds */
|
||||
|
||||
/* API only: -3 (Feeds::CATEGORY_ALL_EXCEPT_VIRTUAL) All feeds, excluding virtual feeds (e.g. Labels and such) */
|
||||
$feeds_obj = ORM::for_table('ttrss_feeds')
|
||||
->select_many('id', 'feed_url', 'cat_id', 'title', 'order_id')
|
||||
->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated')
|
||||
->where('owner_uid', $_SESSION['uid'])
|
||||
->order_by_asc('order_id')
|
||||
->order_by_asc('title');
|
||||
|
||||
if ($limit) $feeds_obj->limit($limit);
|
||||
if ($offset) $feeds_obj->offset($offset);
|
||||
|
||||
if ($cat_id != Feeds::CATEGORY_ALL_EXCEPT_VIRTUAL && $cat_id != Feeds::CATEGORY_ALL) {
|
||||
$feeds_obj->where_raw('(cat_id = ? OR (? = 0 AND cat_id IS NULL))', [$cat_id, $cat_id]);
|
||||
}
|
||||
|
||||
foreach ($feeds_obj->find_many() as $feed) {
|
||||
$unread = Feeds::_get_counters($feed->id, false, true);
|
||||
$has_icon = Feeds::_has_icon($feed->id);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
$row = [
|
||||
'feed_url' => $feed->feed_url,
|
||||
'title' => $feed->title,
|
||||
'id' => (int) $feed->id,
|
||||
'unread' => (int) $unread,
|
||||
'has_icon' => $has_icon,
|
||||
'cat_id' => (int) $feed->cat_id,
|
||||
'last_updated' => (int) strtotime($feed->last_updated ?? ''),
|
||||
'order_id' => (int) $feed->order_id,
|
||||
];
|
||||
|
||||
array_push($feeds, $row);
|
||||
}
|
||||
}
|
||||
|
||||
return $feeds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|int $feed_id
|
||||
* @return array{0: array<int, array<string, mixed>>, 1: array<string, mixed>} $headlines, $headlines_header
|
||||
*/
|
||||
private static function _api_get_headlines($feed_id, int $limit, int $offset,
|
||||
string $filter, bool $is_cat, bool $show_excerpt, bool $show_content, ?string $view_mode, string $order,
|
||||
bool $include_attachments, int $since_id, string $search = "", bool $include_nested = false,
|
||||
bool $sanitize_content = true, bool $force_update = false, int $excerpt_length = 100, ?int $check_first_id = null,
|
||||
bool $skip_first_id_check = false): array {
|
||||
|
||||
if ($force_update && is_numeric($feed_id) && $feed_id > 0) {
|
||||
// Update the feed if required with some basic flood control
|
||||
|
||||
$feed = ORM::for_table('ttrss_feeds')
|
||||
->select_many('id', 'cache_images')
|
||||
->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated')
|
||||
->find_one($feed_id);
|
||||
|
||||
if ($feed) {
|
||||
$last_updated = strtotime($feed->last_updated ?? '');
|
||||
$cache_images = self::_param_to_bool($feed->cache_images);
|
||||
|
||||
if (!$cache_images && time() - $last_updated > 120) {
|
||||
RSSUtils::update_rss_feed($feed_id, true);
|
||||
} else {
|
||||
$feed->last_updated = '1970-01-01';
|
||||
$feed->last_update_started = '1970-01-01';
|
||||
$feed->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$qfh_ret = [];
|
||||
|
||||
if (!$is_cat && is_numeric($feed_id) && $feed_id < PLUGIN_FEED_BASE_INDEX && $feed_id > LABEL_BASE_INDEX) {
|
||||
$pfeed_id = PluginHost::feed_to_pfeed_id($feed_id);
|
||||
|
||||
/** @var IVirtualFeed|false $handler */
|
||||
$handler = PluginHost::getInstance()->get_feed_handler($pfeed_id);
|
||||
|
||||
if ($handler) {
|
||||
$params = array(
|
||||
"feed" => $feed_id,
|
||||
"limit" => $limit,
|
||||
"view_mode" => $view_mode,
|
||||
"cat_view" => $is_cat,
|
||||
"search" => $search,
|
||||
"override_order" => $order,
|
||||
"offset" => $offset,
|
||||
"since_id" => 0,
|
||||
"include_children" => $include_nested,
|
||||
"check_first_id" => $check_first_id,
|
||||
"skip_first_id_check" => $skip_first_id_check
|
||||
);
|
||||
|
||||
$qfh_ret = $handler->get_headlines($pfeed_id, $params);
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
$params = array(
|
||||
"feed" => $feed_id,
|
||||
"limit" => $limit,
|
||||
"view_mode" => $view_mode,
|
||||
"cat_view" => $is_cat,
|
||||
"search" => $search,
|
||||
"override_order" => $order,
|
||||
"offset" => $offset,
|
||||
"since_id" => $since_id,
|
||||
"include_children" => $include_nested,
|
||||
"check_first_id" => $check_first_id,
|
||||
"skip_first_id_check" => $skip_first_id_check
|
||||
);
|
||||
|
||||
$qfh_ret = Feeds::_get_headlines($params);
|
||||
}
|
||||
|
||||
$result = $qfh_ret[0];
|
||||
$feed_title = $qfh_ret[1];
|
||||
$first_id = $qfh_ret[6];
|
||||
|
||||
$headlines = array();
|
||||
|
||||
$headlines_header = array(
|
||||
'id' => $feed_id,
|
||||
'first_id' => $first_id,
|
||||
'is_cat' => $is_cat);
|
||||
|
||||
if (!is_numeric($result)) {
|
||||
while ($line = $result->fetch()) {
|
||||
$line["content_preview"] = truncate_string(strip_tags($line["content"]), $excerpt_length);
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES,
|
||||
function ($result) use (&$line) {
|
||||
$line = $result;
|
||||
},
|
||||
$line, $excerpt_length);
|
||||
|
||||
$is_updated = ($line["last_read"] == "" &&
|
||||
($line["unread"] != "t" && $line["unread"] != "1"));
|
||||
|
||||
$tags = explode(",", $line["tag_cache"]);
|
||||
|
||||
$label_cache = $line["label_cache"];
|
||||
$labels = false;
|
||||
|
||||
if ($label_cache) {
|
||||
$label_cache = json_decode($label_cache, true);
|
||||
|
||||
if ($label_cache) {
|
||||
if (($label_cache["no-labels"] ?? 0) == 1)
|
||||
$labels = [];
|
||||
else
|
||||
$labels = $label_cache;
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_array($labels)) $labels = Article::_get_labels($line["id"]);
|
||||
|
||||
$headline_row = array(
|
||||
"id" => (int)$line["id"],
|
||||
"guid" => $line["guid"],
|
||||
"unread" => self::_param_to_bool($line["unread"]),
|
||||
"marked" => self::_param_to_bool($line["marked"]),
|
||||
"published" => self::_param_to_bool($line["published"]),
|
||||
"updated" => (int)strtotime($line["updated"] ?? ''),
|
||||
"is_updated" => $is_updated,
|
||||
"title" => $line["title"],
|
||||
"link" => $line["link"],
|
||||
"feed_id" => $line["feed_id"] ? $line['feed_id'] : 0,
|
||||
"tags" => $tags,
|
||||
);
|
||||
|
||||
$enclosures = Article::_get_enclosures($line['id']);
|
||||
|
||||
if ($include_attachments)
|
||||
$headline_row['attachments'] = $enclosures;
|
||||
|
||||
if ($show_excerpt)
|
||||
$headline_row["excerpt"] = $line["content_preview"];
|
||||
|
||||
if ($show_content) {
|
||||
|
||||
if ($sanitize_content) {
|
||||
$headline_row["content"] = Sanitizer::sanitize(
|
||||
$line["content"],
|
||||
self::_param_to_bool($line['hide_images']),
|
||||
null, $line["site_url"], null, $line["id"]);
|
||||
} else {
|
||||
$headline_row["content"] = $line["content"];
|
||||
}
|
||||
}
|
||||
|
||||
// unify label output to ease parsing
|
||||
if (($labels["no-labels"] ?? 0) == 1) $labels = [];
|
||||
|
||||
$headline_row["labels"] = $labels;
|
||||
|
||||
$headline_row["feed_title"] = $line["feed_title"] ?? $feed_title;
|
||||
|
||||
$headline_row["comments_count"] = (int)$line["num_comments"];
|
||||
$headline_row["comments_link"] = $line["comments"];
|
||||
|
||||
$headline_row["always_display_attachments"] = self::_param_to_bool($line["always_display_enclosures"]);
|
||||
|
||||
$headline_row["author"] = $line["author"];
|
||||
|
||||
$headline_row["score"] = (int)$line["score"];
|
||||
$headline_row["note"] = $line["note"];
|
||||
$headline_row["lang"] = $line["lang"];
|
||||
|
||||
$headline_row["site_url"] = $line["site_url"];
|
||||
|
||||
if ($show_content) {
|
||||
$hook_object = ["headline" => &$headline_row];
|
||||
|
||||
list ($flavor_image, $flavor_stream, $flavor_kind) = Article::_get_image($enclosures,
|
||||
$line["content"], // unsanitized
|
||||
$line["site_url"] ?? "", // could be null if archived article
|
||||
$headline_row);
|
||||
|
||||
$headline_row["flavor_image"] = $flavor_image;
|
||||
$headline_row["flavor_stream"] = $flavor_stream;
|
||||
|
||||
/* optional */
|
||||
if ($flavor_kind)
|
||||
$headline_row["flavor_kind"] = $flavor_kind;
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_API,
|
||||
function ($result) use (&$headline_row) {
|
||||
$headline_row = $result;
|
||||
},
|
||||
$hook_object);
|
||||
|
||||
$headline_row["content"] = DiskCache::rewrite_urls($headline_row['content']);
|
||||
}
|
||||
|
||||
array_push($headlines, $headline_row);
|
||||
}
|
||||
} else if (is_numeric($result) && $result == -1) {
|
||||
$headlines_header['first_id_changed'] = true;
|
||||
}
|
||||
|
||||
return array($headlines, $headlines_header);
|
||||
}
|
||||
|
||||
function unsubscribeFeed(): bool {
|
||||
$feed_id = (int) clean($_REQUEST["feed_id"]);
|
||||
|
||||
$feed_exists = ORM::for_table('ttrss_feeds')
|
||||
->where(['id' => $feed_id, 'owner_uid' => $_SESSION['uid']])
|
||||
->count();
|
||||
|
||||
if ($feed_exists) {
|
||||
Pref_Feeds::remove_feed($feed_id, $_SESSION['uid']);
|
||||
return $this->_wrap(self::STATUS_OK, ['status' => 'OK']);
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, ['error' => self::E_OPERATION_FAILED]);
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeToFeed(): bool {
|
||||
$feed_url = clean($_REQUEST["feed_url"]);
|
||||
$category_id = (int) clean($_REQUEST["category_id"]);
|
||||
$login = clean($_REQUEST["login"] ?? "");
|
||||
$password = clean($_REQUEST["password"] ?? "");
|
||||
|
||||
if ($feed_url) {
|
||||
$rc = Feeds::_subscribe($feed_url, $category_id, $login, $password);
|
||||
|
||||
return $this->_wrap(self::STATUS_OK, array("status" => $rc));
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE));
|
||||
}
|
||||
}
|
||||
|
||||
function getFeedTree(): bool {
|
||||
$include_empty = self::_param_to_bool($_REQUEST['include_empty'] ?? false);
|
||||
|
||||
$pf = new Pref_Feeds($_REQUEST);
|
||||
|
||||
$_REQUEST['mode'] = 2;
|
||||
$_REQUEST['force_show_empty'] = $include_empty;
|
||||
|
||||
return $this->_wrap(self::STATUS_OK,
|
||||
array("categories" => $pf->_makefeedtree()));
|
||||
}
|
||||
|
||||
function getFeedIcon(): bool {
|
||||
$id = (int)$_REQUEST['id'];
|
||||
$cache = DiskCache::instance('feed-icons');
|
||||
|
||||
if ($cache->exists((string)$id)) {
|
||||
return $cache->send((string)$id) > 0;
|
||||
} else {
|
||||
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_NOT_FOUND));
|
||||
}
|
||||
}
|
||||
|
||||
// only works for labels or uncategorized for the time being
|
||||
private function _is_cat_empty(int $id): bool {
|
||||
if ($id == Feeds::CATEGORY_LABELS) {
|
||||
$label_count = ORM::for_table('ttrss_labels2')
|
||||
->where('owner_uid', $_SESSION['uid'])
|
||||
->count();
|
||||
|
||||
return $label_count == 0;
|
||||
} else if ($id == Feeds::CATEGORY_UNCATEGORIZED) {
|
||||
$uncategorized_count = ORM::for_table('ttrss_feeds')
|
||||
->where_null('cat_id')
|
||||
->where('owner_uid', $_SESSION['uid'])
|
||||
->count();
|
||||
|
||||
return $uncategorized_count == 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private function _get_custom_sort_types(): array {
|
||||
$ret = [];
|
||||
|
||||
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_HEADLINES_CUSTOM_SORT_MAP, function ($result) use (&$ret) {
|
||||
foreach ($result as $sort_value => $sort_title) {
|
||||
$ret[$sort_value] = $sort_title;
|
||||
}
|
||||
});
|
||||
|
||||
return $ret;
|
||||
}
|
||||
}
|
||||
730
classes/Article.php
Normal file
730
classes/Article.php
Normal file
@@ -0,0 +1,730 @@
|
||||
<?php
|
||||
class Article extends Handler_Protected {
|
||||
const ARTICLE_KIND_ALBUM = 1;
|
||||
const ARTICLE_KIND_VIDEO = 2;
|
||||
const ARTICLE_KIND_YOUTUBE = 3;
|
||||
|
||||
const CATCHUP_MODE_MARK_AS_READ = 0;
|
||||
const CATCHUP_MODE_MARK_AS_UNREAD = 1;
|
||||
const CATCHUP_MODE_TOGGLE = 2;
|
||||
|
||||
function redirect(): void {
|
||||
$article = ORM::for_table('ttrss_entries')
|
||||
->table_alias('e')
|
||||
->join('ttrss_user_entries', [ 'ref_id', '=', 'e.id'], 'ue')
|
||||
->where('ue.owner_uid', $_SESSION['uid'])
|
||||
->find_one((int)$_REQUEST['id']);
|
||||
|
||||
if ($article) {
|
||||
$article_url = UrlHelper::validate($article->link);
|
||||
|
||||
if ($article_url) {
|
||||
header("Location: $article_url");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
|
||||
print "Article not found or has an empty URL.";
|
||||
}
|
||||
|
||||
static function _create_published_article(string $title, string $url, string $content, string $labels_str, int $owner_uid): bool {
|
||||
|
||||
$guid = 'SHA1:' . sha1("ttshared:" . $url . $owner_uid); // include owner_uid to prevent global GUID clash
|
||||
|
||||
if (!$content) {
|
||||
$pluginhost = new PluginHost();
|
||||
$pluginhost->load_all(PluginHost::KIND_ALL, $owner_uid);
|
||||
//$pluginhost->load_data();
|
||||
|
||||
$pluginhost->run_hooks_callback(PluginHost::HOOK_GET_FULL_TEXT,
|
||||
function ($result) use (&$content) {
|
||||
if ($result) {
|
||||
$content = $result;
|
||||
return true;
|
||||
}
|
||||
},
|
||||
$url);
|
||||
}
|
||||
|
||||
$content_hash = sha1($content);
|
||||
|
||||
if ($labels_str != "") {
|
||||
$labels = explode(",", $labels_str);
|
||||
} else {
|
||||
$labels = array();
|
||||
}
|
||||
|
||||
$rc = false;
|
||||
|
||||
if (!$title) $title = $url;
|
||||
if (!$title && !$url) return false;
|
||||
|
||||
if (filter_var($url, FILTER_VALIDATE_URL) === false) return false;
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// only check for our user data here, others might have shared this with different content etc
|
||||
$sth = $pdo->prepare("SELECT id FROM ttrss_entries, ttrss_user_entries WHERE
|
||||
guid = ? AND ref_id = id AND owner_uid = ? LIMIT 1");
|
||||
$sth->execute([$guid, $owner_uid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$ref_id = $row['id'];
|
||||
|
||||
$sth = $pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE
|
||||
ref_id = ? AND owner_uid = ? LIMIT 1");
|
||||
$sth->execute([$ref_id, $owner_uid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$int_id = $row['int_id'];
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_entries SET
|
||||
content = ?, content_hash = ? WHERE id = ?");
|
||||
$sth->execute([$content, $content_hash, $ref_id]);
|
||||
|
||||
if (Config::get(Config::DB_TYPE) == "pgsql") {
|
||||
$sth = $pdo->prepare("UPDATE ttrss_entries
|
||||
SET tsvector_combined = to_tsvector( :ts_content)
|
||||
WHERE id = :id");
|
||||
$params = [
|
||||
":ts_content" => mb_substr(\Soundasleep\Html2Text::convert($content), 0, 900000),
|
||||
":id" => $ref_id];
|
||||
$sth->execute($params);
|
||||
}
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET published = true,
|
||||
last_published = NOW() WHERE
|
||||
int_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$int_id, $owner_uid]);
|
||||
|
||||
} else {
|
||||
|
||||
$sth = $pdo->prepare("INSERT INTO ttrss_user_entries
|
||||
(ref_id, uuid, feed_id, orig_feed_id, owner_uid, published, tag_cache, label_cache,
|
||||
last_read, note, unread, last_published)
|
||||
VALUES
|
||||
(?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())");
|
||||
$sth->execute([$ref_id, $owner_uid]);
|
||||
}
|
||||
|
||||
if (count($labels) != 0) {
|
||||
foreach ($labels as $label) {
|
||||
Labels::add_article($ref_id, trim($label), $owner_uid);
|
||||
}
|
||||
}
|
||||
|
||||
$rc = true;
|
||||
|
||||
} else {
|
||||
$sth = $pdo->prepare("INSERT INTO ttrss_entries
|
||||
(title, guid, link, updated, content, content_hash, date_entered, date_updated)
|
||||
VALUES
|
||||
(?, ?, ?, NOW(), ?, ?, NOW(), NOW())");
|
||||
$sth->execute([$title, $guid, $url, $content, $content_hash]);
|
||||
|
||||
$sth = $pdo->prepare("SELECT id FROM ttrss_entries WHERE guid = ?");
|
||||
$sth->execute([$guid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$ref_id = $row["id"];
|
||||
if (Config::get(Config::DB_TYPE) == "pgsql"){
|
||||
$sth = $pdo->prepare("UPDATE ttrss_entries
|
||||
SET tsvector_combined = to_tsvector( :ts_content)
|
||||
WHERE id = :id");
|
||||
$params = [
|
||||
":ts_content" => mb_substr(\Soundasleep\Html2Text::convert($content), 0, 900000),
|
||||
":id" => $ref_id];
|
||||
$sth->execute($params);
|
||||
}
|
||||
$sth = $pdo->prepare("INSERT INTO ttrss_user_entries
|
||||
(ref_id, uuid, feed_id, orig_feed_id, owner_uid, published, tag_cache, label_cache,
|
||||
last_read, note, unread, last_published)
|
||||
VALUES
|
||||
(?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())");
|
||||
$sth->execute([$ref_id, $owner_uid]);
|
||||
|
||||
if (count($labels) != 0) {
|
||||
foreach ($labels as $label) {
|
||||
Labels::add_article($ref_id, trim($label), $owner_uid);
|
||||
}
|
||||
}
|
||||
|
||||
$rc = true;
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
return $rc;
|
||||
}
|
||||
|
||||
function printArticleTags(): void {
|
||||
$id = (int) clean($_REQUEST['id'] ?? 0);
|
||||
|
||||
print json_encode(["id" => $id,
|
||||
"tags" => self::_get_tags($id)]);
|
||||
}
|
||||
|
||||
function setScore(): void {
|
||||
$ids = array_map("intval", clean($_REQUEST['ids'] ?? []));
|
||||
$score = (int)clean($_REQUEST['score']);
|
||||
|
||||
$ids_qmarks = arr_qmarks($ids);
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
score = ? WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
|
||||
$sth->execute([$score, ...$ids, $_SESSION['uid']]);
|
||||
|
||||
print json_encode(["id" => $ids, "score" => $score]);
|
||||
}
|
||||
|
||||
function setArticleTags(): void {
|
||||
|
||||
$id = clean($_REQUEST["id"]);
|
||||
|
||||
//$tags_str = clean($_REQUEST["tags_str"]);
|
||||
//$tags = array_unique(array_map('trim', explode(",", $tags_str)));
|
||||
|
||||
$tags = FeedItem_Common::normalize_categories(explode(",", clean($_REQUEST["tags_str"] ?? "")));
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE
|
||||
ref_id = ? AND owner_uid = ? LIMIT 1");
|
||||
$sth->execute([$id, $_SESSION['uid']]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
|
||||
$tags_to_cache = array();
|
||||
|
||||
$int_id = $row['int_id'];
|
||||
|
||||
$dsth = $this->pdo->prepare("DELETE FROM ttrss_tags WHERE
|
||||
post_int_id = ? AND owner_uid = ?");
|
||||
$dsth->execute([$int_id, $_SESSION['uid']]);
|
||||
|
||||
$csth = $this->pdo->prepare("SELECT post_int_id FROM ttrss_tags
|
||||
WHERE post_int_id = ? AND owner_uid = ? AND tag_name = ?");
|
||||
|
||||
$usth = $this->pdo->prepare("INSERT INTO ttrss_tags
|
||||
(post_int_id, owner_uid, tag_name)
|
||||
VALUES (?, ?, ?)");
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
$csth->execute([$int_id, $_SESSION['uid'], $tag]);
|
||||
|
||||
if (!$csth->fetch()) {
|
||||
$usth->execute([$int_id, $_SESSION['uid'], $tag]);
|
||||
}
|
||||
|
||||
array_push($tags_to_cache, $tag);
|
||||
}
|
||||
|
||||
/* update tag cache */
|
||||
|
||||
$tags_str = join(",", $tags_to_cache);
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries
|
||||
SET tag_cache = ? WHERE ref_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$tags_str, $id, $_SESSION['uid']]);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
|
||||
// get latest tags from the database, original $tags is sometimes JSON-encoded as a hash ({}) - ???
|
||||
print json_encode(["id" => (int)$id, "tags" => $this->_get_tags($id)]);
|
||||
}
|
||||
|
||||
function completeTags(): void {
|
||||
$search = clean($_REQUEST["search"]);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT DISTINCT tag_name FROM ttrss_tags
|
||||
WHERE owner_uid = ? AND
|
||||
tag_name LIKE ? ORDER BY tag_name
|
||||
LIMIT 10");
|
||||
|
||||
$sth->execute([$_SESSION['uid'], "$search%"]);
|
||||
|
||||
$results = [];
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
array_push($results, $line["tag_name"]);
|
||||
}
|
||||
|
||||
print json_encode($results);
|
||||
}
|
||||
|
||||
function assigntolabel(): void {
|
||||
$this->_label_ops(true);
|
||||
}
|
||||
|
||||
function removefromlabel(): void {
|
||||
$this->_label_ops(false);
|
||||
}
|
||||
|
||||
private function _label_ops(bool $assign): void {
|
||||
$reply = array();
|
||||
|
||||
$ids = array_map("intval", array_filter(explode(",", clean($_REQUEST["ids"] ?? "")), "strlen"));
|
||||
$label_id = clean($_REQUEST["lid"]);
|
||||
|
||||
$label = Labels::find_caption($label_id, $_SESSION["uid"]);
|
||||
|
||||
$reply["labels-for"] = [];
|
||||
|
||||
if ($label) {
|
||||
foreach ($ids as $id) {
|
||||
if ($assign)
|
||||
Labels::add_article($id, $label, $_SESSION["uid"]);
|
||||
else
|
||||
Labels::remove_article($id, $label, $_SESSION["uid"]);
|
||||
|
||||
array_push($reply["labels-for"],
|
||||
["id" => (int)$id, "labels" => $this->_get_labels($id)]);
|
||||
}
|
||||
}
|
||||
|
||||
$reply["message"] = "UPDATE_COUNTERS";
|
||||
|
||||
print json_encode($reply);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $id article id
|
||||
* @return array{'formatted': string, 'entries': array<int, array<string, mixed>>}
|
||||
*/
|
||||
static function _format_enclosures(int $id, bool $always_display_enclosures, string $article_content, bool $hide_images = false): array {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
|
||||
$enclosures = self::_get_enclosures($id);
|
||||
$enclosures_formatted = "";
|
||||
|
||||
/*foreach ($enclosures as &$enc) {
|
||||
array_push($enclosures, [
|
||||
"type" => $enc["content_type"],
|
||||
"filename" => basename($enc["content_url"]),
|
||||
"url" => $enc["content_url"],
|
||||
"title" => $enc["title"],
|
||||
"width" => (int) $enc["width"],
|
||||
"height" => (int) $enc["height"]
|
||||
]);
|
||||
}*/
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_FORMAT_ENCLOSURES,
|
||||
function ($result) use (&$enclosures_formatted, &$enclosures) {
|
||||
if (is_array($result)) {
|
||||
$enclosures_formatted = $result[0];
|
||||
$enclosures = $result[1];
|
||||
} else {
|
||||
$enclosures_formatted = $result;
|
||||
}
|
||||
},
|
||||
$enclosures_formatted, $enclosures, $id, $always_display_enclosures, $article_content, $hide_images);
|
||||
|
||||
if (!empty($enclosures_formatted)) {
|
||||
$span->end();
|
||||
return [
|
||||
'formatted' => $enclosures_formatted,
|
||||
'entries' => []
|
||||
];
|
||||
}
|
||||
|
||||
$rv = [
|
||||
'formatted' => '',
|
||||
'entries' => []
|
||||
];
|
||||
|
||||
$rv['can_inline'] = isset($_SESSION["uid"]) &&
|
||||
empty($_SESSION["bw_limit"]) &&
|
||||
!get_pref(Prefs::STRIP_IMAGES) &&
|
||||
($always_display_enclosures || !preg_match("/<img/i", $article_content));
|
||||
|
||||
$rv['inline_text_only'] = $hide_images && $rv['can_inline'];
|
||||
|
||||
foreach ($enclosures as $enc) {
|
||||
|
||||
// this is highly approximate
|
||||
$enc["filename"] = basename($enc["content_url"]);
|
||||
|
||||
$rendered_enc = "";
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ENCLOSURE,
|
||||
function ($result) use (&$rendered_enc) {
|
||||
$rendered_enc = $result;
|
||||
},
|
||||
$enc, $id, $rv);
|
||||
|
||||
if ($rendered_enc) {
|
||||
$rv['formatted'] .= $rendered_enc;
|
||||
} else {
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ENCLOSURE_ENTRY,
|
||||
function ($result) use (&$enc) {
|
||||
$enc = $result;
|
||||
},
|
||||
$enc, $id, $rv);
|
||||
|
||||
array_push($rv['entries'], $enc);
|
||||
}
|
||||
}
|
||||
|
||||
$span->end();
|
||||
return $rv;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
static function _get_tags(int $id, int $owner_uid = 0, ?string $tag_cache = null): array {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
|
||||
$a_id = $id;
|
||||
|
||||
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT DISTINCT tag_name,
|
||||
owner_uid as owner FROM ttrss_tags
|
||||
WHERE post_int_id = (SELECT int_id FROM ttrss_user_entries WHERE
|
||||
ref_id = ? AND owner_uid = ? LIMIT 1) ORDER BY tag_name");
|
||||
|
||||
$tags = array();
|
||||
|
||||
/* check cache first */
|
||||
|
||||
if (!$tag_cache) {
|
||||
$csth = $pdo->prepare("SELECT tag_cache FROM ttrss_user_entries
|
||||
WHERE ref_id = ? AND owner_uid = ?");
|
||||
$csth->execute([$id, $owner_uid]);
|
||||
|
||||
if ($row = $csth->fetch()) {
|
||||
$tag_cache = $row["tag_cache"];
|
||||
}
|
||||
}
|
||||
|
||||
if ($tag_cache) {
|
||||
$tags = explode(",", $tag_cache);
|
||||
} else {
|
||||
|
||||
/* do it the hard way */
|
||||
|
||||
$sth->execute([$a_id, $owner_uid]);
|
||||
|
||||
while ($tmp_line = $sth->fetch()) {
|
||||
array_push($tags, $tmp_line["tag_name"]);
|
||||
}
|
||||
|
||||
/* update the cache */
|
||||
|
||||
$tags_str = join(",", $tags);
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries
|
||||
SET tag_cache = ? WHERE ref_id = ?
|
||||
AND owner_uid = ?");
|
||||
$sth->execute([$tags_str, $id, $owner_uid]);
|
||||
}
|
||||
|
||||
$span->end();
|
||||
return $tags;
|
||||
}
|
||||
|
||||
function getmetadatabyid(): void {
|
||||
$article = ORM::for_table('ttrss_entries')
|
||||
->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue')
|
||||
->where('ue.owner_uid', $_SESSION['uid'])
|
||||
->find_one((int)$_REQUEST['id']);
|
||||
|
||||
if ($article) {
|
||||
echo json_encode(["link" => $article->link, "title" => $article->title]);
|
||||
} else {
|
||||
echo json_encode([]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
static function _get_enclosures(int $id): array {
|
||||
$encs = ORM::for_table('ttrss_enclosures')
|
||||
->where('post_id', $id)
|
||||
->find_many();
|
||||
|
||||
$rv = [];
|
||||
|
||||
$cache = DiskCache::instance("images");
|
||||
|
||||
foreach ($encs as $enc) {
|
||||
$cache_key = sha1($enc->content_url);
|
||||
|
||||
if ($cache->exists($cache_key)) {
|
||||
$enc->content_url = $cache->get_url($cache_key);
|
||||
}
|
||||
|
||||
array_push($rv, $enc->as_array());
|
||||
}
|
||||
|
||||
return $rv;
|
||||
|
||||
}
|
||||
|
||||
static function _purge_orphans(): void {
|
||||
|
||||
// purge orphaned posts in main content table
|
||||
|
||||
if (Config::get(Config::DB_TYPE) == "mysql")
|
||||
$limit_qpart = "LIMIT 5000";
|
||||
else
|
||||
$limit_qpart = "";
|
||||
|
||||
$pdo = Db::pdo();
|
||||
$res = $pdo->query("DELETE FROM ttrss_entries WHERE
|
||||
NOT EXISTS (SELECT ref_id FROM ttrss_user_entries WHERE ref_id = id) $limit_qpart");
|
||||
|
||||
if (Debug::enabled()) {
|
||||
$rows = $res->rowCount();
|
||||
Debug::log("Purged $rows orphaned posts.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $ids
|
||||
* @param int $cmode Article::CATCHUP_MODE_*
|
||||
*/
|
||||
static function _catchup_by_id($ids, int $cmode, ?int $owner_uid = null): void {
|
||||
|
||||
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$ids_qmarks = arr_qmarks($ids);
|
||||
|
||||
if ($cmode == Article::CATCHUP_MODE_MARK_AS_UNREAD) {
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
unread = true
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
} else if ($cmode == Article::CATCHUP_MODE_TOGGLE) {
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
unread = NOT unread,last_read = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
} else {
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
unread = false,last_read = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
}
|
||||
|
||||
$sth->execute([...$ids, $owner_uid]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<int, int|string>>
|
||||
*/
|
||||
static function _get_labels(int $id, ?int $owner_uid = null): array {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
|
||||
$rv = array();
|
||||
|
||||
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT label_cache FROM
|
||||
ttrss_user_entries WHERE ref_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$id, $owner_uid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$label_cache = $row["label_cache"];
|
||||
|
||||
if ($label_cache) {
|
||||
$tmp = json_decode($label_cache, true);
|
||||
|
||||
if (empty($tmp) || ($tmp["no-labels"] ?? 0) == 1)
|
||||
return $rv;
|
||||
else
|
||||
return $tmp;
|
||||
}
|
||||
}
|
||||
|
||||
$sth = $pdo->prepare("SELECT DISTINCT label_id,caption,fg_color,bg_color
|
||||
FROM ttrss_labels2, ttrss_user_labels2
|
||||
WHERE id = label_id
|
||||
AND article_id = ?
|
||||
AND owner_uid = ?
|
||||
ORDER BY caption");
|
||||
$sth->execute([$id, $owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$rk = array(Labels::label_to_feed_id($line["label_id"]),
|
||||
$line["caption"], $line["fg_color"],
|
||||
$line["bg_color"]);
|
||||
array_push($rv, $rk);
|
||||
}
|
||||
|
||||
if (count($rv) > 0)
|
||||
// PHPStan has issues with the shape of $rv for some reason (array vs non-empty-array).
|
||||
// @phpstan-ignore-next-line
|
||||
Labels::update_cache($owner_uid, $id, $rv);
|
||||
else
|
||||
Labels::update_cache($owner_uid, $id, array("no-labels" => 1));
|
||||
|
||||
$span->end();
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $enclosures
|
||||
* @param array<string, mixed> $headline
|
||||
*
|
||||
* @return array<int, Article::ARTICLE_KIND_*|string>
|
||||
*/
|
||||
static function _get_image(array $enclosures, string $content, string $site_url, array $headline) {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
|
||||
$article_image = "";
|
||||
$article_stream = "";
|
||||
$article_kind = 0;
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_IMAGE,
|
||||
function ($result, $plugin) use (&$article_image, &$article_stream, &$content) {
|
||||
list ($article_image, $article_stream, $content) = $result;
|
||||
|
||||
// run until first hard match
|
||||
return !empty($article_image);
|
||||
},
|
||||
$enclosures, $content, $site_url, $headline);
|
||||
|
||||
if (!$article_image && !$article_stream) {
|
||||
$tmpdoc = new DOMDocument();
|
||||
|
||||
if (@$tmpdoc->loadHTML('<?xml encoding="UTF-8">' . mb_substr($content, 0, 131070))) {
|
||||
$tmpxpath = new DOMXPath($tmpdoc);
|
||||
$elems = $tmpxpath->query('(//img[@src]|//video[@poster]|//iframe[contains(@src , "youtube.com/embed/")])');
|
||||
|
||||
foreach ($elems as $e) {
|
||||
if ($e->nodeName == "iframe") {
|
||||
$matches = [];
|
||||
if (preg_match("/\/embed\/([\w-]+)/", $e->getAttribute("src"), $matches)) {
|
||||
$article_image = "https://img.youtube.com/vi/" . $matches[1] . "/hqdefault.jpg";
|
||||
$article_stream = "https://youtu.be/" . $matches[1];
|
||||
$article_kind = Article::ARTICLE_KIND_YOUTUBE;
|
||||
break;
|
||||
}
|
||||
} else if ($e->nodeName == "video") {
|
||||
$article_image = $e->getAttribute("poster");
|
||||
|
||||
/** @var DOMElement|null $src */
|
||||
$src = $tmpxpath->query("//source[@src]", $e)->item(0);
|
||||
|
||||
if ($src) {
|
||||
$article_stream = $src->getAttribute("src");
|
||||
$article_kind = Article::ARTICLE_KIND_VIDEO;
|
||||
}
|
||||
|
||||
break;
|
||||
} else if ($e->nodeName == 'img') {
|
||||
if (mb_strpos($e->getAttribute("src"), "data:") !== 0) {
|
||||
$article_image = $e->getAttribute("src");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$article_image)
|
||||
foreach ($enclosures as $enc) {
|
||||
if (strpos($enc["content_type"], "image/") !== false) {
|
||||
$article_image = $enc["content_url"];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($article_image) {
|
||||
$article_image = UrlHelper::rewrite_relative($site_url, $article_image);
|
||||
|
||||
if (!$article_kind && (count($enclosures) > 1 || (isset($elems) && $elems->length > 1)))
|
||||
$article_kind = Article::ARTICLE_KIND_ALBUM;
|
||||
}
|
||||
|
||||
if ($article_stream)
|
||||
$article_stream = UrlHelper::rewrite_relative($site_url, $article_stream);
|
||||
}
|
||||
|
||||
$cache = DiskCache::instance("images");
|
||||
|
||||
if ($article_image && $cache->exists(sha1($article_image)))
|
||||
$article_image = $cache->get_url(sha1($article_image));
|
||||
|
||||
if ($article_stream && $cache->exists(sha1($article_stream)))
|
||||
$article_stream = $cache->get_url(sha1($article_stream));
|
||||
|
||||
$span->end();
|
||||
|
||||
return [$article_image, $article_stream, $article_kind];
|
||||
}
|
||||
|
||||
/**
|
||||
* only cached, returns label ids (not label feed ids)
|
||||
*
|
||||
* @param array<int, int> $article_ids
|
||||
* @return array<int, int>
|
||||
*/
|
||||
static function _labels_of(array $article_ids) {
|
||||
if (count($article_ids) == 0)
|
||||
return [];
|
||||
|
||||
$span = Tracer::start(__METHOD__);
|
||||
|
||||
$entries = ORM::for_table('ttrss_entries')
|
||||
->table_alias('e')
|
||||
->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue')
|
||||
->where_in('id', $article_ids)
|
||||
->find_many();
|
||||
|
||||
$rv = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$labels = json_decode($entry->label_cache);
|
||||
|
||||
if (isset($labels) && is_array($labels)) {
|
||||
foreach ($labels as $label) {
|
||||
if (empty($label["no-labels"]))
|
||||
array_push($rv, Labels::feed_to_label_id($label[0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$span->end();
|
||||
|
||||
return array_unique($rv);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $article_ids
|
||||
* @return array<int, int>
|
||||
*/
|
||||
static function _feeds_of(array $article_ids) {
|
||||
if (count($article_ids) == 0)
|
||||
return [];
|
||||
|
||||
$span = Tracer::start(__METHOD__);
|
||||
|
||||
$entries = ORM::for_table('ttrss_entries')
|
||||
->table_alias('e')
|
||||
->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue')
|
||||
->where_in('id', $article_ids)
|
||||
->find_many();
|
||||
|
||||
$rv = [];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
array_push($rv, $entry->feed_id);
|
||||
}
|
||||
|
||||
$span->end();
|
||||
|
||||
return array_unique($rv);
|
||||
}
|
||||
}
|
||||
59
classes/Auth_Base.php
Normal file
59
classes/Auth_Base.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
abstract class Auth_Base extends Plugin implements IAuthModule {
|
||||
protected $pdo;
|
||||
|
||||
const AUTH_SERVICE_API = '_api';
|
||||
|
||||
function __construct() {
|
||||
$this->pdo = Db::pdo();
|
||||
}
|
||||
|
||||
function hook_auth_user($login, $password, $service = '') {
|
||||
return $this->authenticate($login, $password, $service);
|
||||
}
|
||||
|
||||
/** Auto-creates specified user if allowed by system configuration.
|
||||
* Can be used instead of find_user_by_login() by external auth modules
|
||||
* @param string $login
|
||||
* @param string|false $password
|
||||
* @return null|int
|
||||
* @throws Exception
|
||||
* @throws PDOException
|
||||
*/
|
||||
function auto_create_user(string $login, $password = false) {
|
||||
if ($login && Config::get(Config::AUTH_AUTO_CREATE)) {
|
||||
$user_id = UserHelper::find_user_by_login($login);
|
||||
|
||||
if (!$user_id) {
|
||||
|
||||
if (!$password) $password = make_password();
|
||||
|
||||
$user = ORM::for_table('ttrss_users')->create();
|
||||
|
||||
$user->salt = UserHelper::get_salt();
|
||||
$user->login = mb_strtolower($login);
|
||||
$user->pwd_hash = UserHelper::hash_password($password, $user->salt);
|
||||
$user->access_level = 0;
|
||||
$user->created = Db::NOW();
|
||||
$user->save();
|
||||
|
||||
return UserHelper::find_user_by_login($login);
|
||||
|
||||
} else {
|
||||
return $user_id;
|
||||
}
|
||||
}
|
||||
|
||||
return UserHelper::find_user_by_login($login);
|
||||
}
|
||||
|
||||
|
||||
/** replaced with UserHelper::find_user_by_login()
|
||||
* @param string $login
|
||||
* @return null|int
|
||||
* @deprecated
|
||||
*/
|
||||
function find_user_by_login(string $login) {
|
||||
return UserHelper::find_user_by_login($login);
|
||||
}
|
||||
}
|
||||
36
classes/Cache_Adapter.php
Normal file
36
classes/Cache_Adapter.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
interface Cache_Adapter {
|
||||
public function set_dir(string $dir) : void;
|
||||
public function get_dir(): string;
|
||||
public function make_dir(): bool;
|
||||
public function is_writable(?string $filename = null): bool;
|
||||
public function exists(string $filename): bool;
|
||||
/**
|
||||
* @return int|false -1 if the file doesn't exist, false if an error occurred, size in bytes otherwise
|
||||
*/
|
||||
public function get_size(string $filename);
|
||||
/**
|
||||
* @return int|false -1 if the file doesn't exist, false if an error occurred, timestamp otherwise
|
||||
*/
|
||||
public function get_mtime(string $filename);
|
||||
/**
|
||||
* @param mixed $data
|
||||
*
|
||||
* @return int|false Bytes written or false if an error occurred.
|
||||
*/
|
||||
public function put(string $filename, $data);
|
||||
public function get(string $filename): ?string;
|
||||
public function get_full_path(string $filename): string;
|
||||
public function remove(string $filename) : bool;
|
||||
/**
|
||||
* @return false|null|string false if detection failed, null if the file doesn't exist, string mime content type otherwise
|
||||
*/
|
||||
public function get_mime_type(string $filename);
|
||||
/**
|
||||
* @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent
|
||||
*/
|
||||
public function send(string $filename);
|
||||
|
||||
/** Catchall function to expire all subfolders/prefixes in the cache, invoked on the backend */
|
||||
public function expire_all(): void;
|
||||
}
|
||||
155
classes/Cache_Local.php
Normal file
155
classes/Cache_Local.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
class Cache_Local implements Cache_Adapter {
|
||||
private string $dir;
|
||||
|
||||
public function remove(string $filename): bool {
|
||||
return unlink($this->get_full_path($filename));
|
||||
}
|
||||
|
||||
public function get_mtime(string $filename) {
|
||||
return filemtime($this->get_full_path($filename));
|
||||
}
|
||||
|
||||
public function set_dir(string $dir) : void {
|
||||
$cache_dir = Config::get(Config::CACHE_DIR);
|
||||
|
||||
// use absolute path local to current dir if CACHE_DIR is relative
|
||||
// TODO: maybe add a special method to Config() for this?
|
||||
if ($cache_dir[0] != '/')
|
||||
$cache_dir = dirname(__DIR__) . "/$cache_dir";
|
||||
|
||||
$this->dir = $cache_dir . "/" . basename(clean($dir));
|
||||
|
||||
$this->make_dir();
|
||||
}
|
||||
|
||||
public function get_dir(): string {
|
||||
return $this->dir;
|
||||
}
|
||||
|
||||
public function make_dir(): bool {
|
||||
if (!is_dir($this->dir)) {
|
||||
return mkdir($this->dir);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function is_writable(?string $filename = null): bool {
|
||||
if ($filename) {
|
||||
if (file_exists($this->get_full_path($filename)))
|
||||
return is_writable($this->get_full_path($filename));
|
||||
else
|
||||
return is_writable($this->dir);
|
||||
} else {
|
||||
return is_writable($this->dir);
|
||||
}
|
||||
}
|
||||
|
||||
public function exists(string $filename): bool {
|
||||
return file_exists($this->get_full_path($filename));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|false -1 if the file doesn't exist, false if an error occurred, size in bytes otherwise
|
||||
*/
|
||||
public function get_size(string $filename) {
|
||||
if ($this->exists($filename))
|
||||
return filesize($this->get_full_path($filename));
|
||||
else
|
||||
return -1;
|
||||
}
|
||||
|
||||
public function get_full_path(string $filename): string {
|
||||
return $this->dir . "/" . basename(clean($filename));
|
||||
}
|
||||
|
||||
public function get(string $filename): ?string {
|
||||
if ($this->exists($filename))
|
||||
return file_get_contents($this->get_full_path($filename));
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
*
|
||||
* @return int|false Bytes written or false if an error occurred.
|
||||
*/
|
||||
public function put(string $filename, $data) {
|
||||
return file_put_contents($this->get_full_path($filename), $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|null|string false if detection failed, null if the file doesn't exist, string mime content type otherwise
|
||||
*/
|
||||
public function get_mime_type(string $filename) {
|
||||
if ($this->exists($filename))
|
||||
return mime_content_type($this->get_full_path($filename));
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent
|
||||
*/
|
||||
public function send(string $filename) {
|
||||
return $this->send_local_file($this->get_full_path($filename));
|
||||
}
|
||||
|
||||
public function expire_all(): void {
|
||||
$dirs = array_filter(glob(Config::get(Config::CACHE_DIR) . "/*"), "is_dir");
|
||||
|
||||
foreach ($dirs as $cache_dir) {
|
||||
$num_deleted = 0;
|
||||
|
||||
if (is_writable($cache_dir) && !file_exists("$cache_dir/.no-auto-expiry")) {
|
||||
$files = glob("$cache_dir/*");
|
||||
|
||||
if ($files) {
|
||||
foreach ($files as $file) {
|
||||
if (time() - filemtime($file) > 86400 * Config::get(Config::CACHE_MAX_DAYS)) {
|
||||
unlink($file);
|
||||
|
||||
++$num_deleted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Debug::log("Expired $cache_dir: removed $num_deleted files.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* this is essentially a wrapper for readfile() which allows plugins to hook
|
||||
* output with httpd-specific "fast" implementation i.e. X-Sendfile or whatever else
|
||||
*
|
||||
* hook function should return true if request was handled (or at least attempted to)
|
||||
*
|
||||
* note that this can be called without user context so the plugin to handle this
|
||||
* should be loaded systemwide in config.php
|
||||
*
|
||||
* @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent
|
||||
*/
|
||||
private function send_local_file(string $filename) {
|
||||
if (file_exists($filename)) {
|
||||
|
||||
if (is_writable($filename) && !$this->exists('.no-auto-expiry')) {
|
||||
touch($filename);
|
||||
}
|
||||
|
||||
$tmppluginhost = new PluginHost();
|
||||
|
||||
$tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_SYSTEM);
|
||||
//$tmppluginhost->load_data();
|
||||
|
||||
if ($tmppluginhost->run_hooks_until(PluginHost::HOOK_SEND_LOCAL_FILE, true, $filename))
|
||||
return true;
|
||||
|
||||
return readfile($filename);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
712
classes/Config.php
Normal file
712
classes/Config.php
Normal file
@@ -0,0 +1,712 @@
|
||||
<?php
|
||||
class Config {
|
||||
private const _ENVVAR_PREFIX = "TTRSS_";
|
||||
|
||||
const T_BOOL = 1;
|
||||
const T_STRING = 2;
|
||||
const T_INT = 3;
|
||||
|
||||
const SCHEMA_VERSION = 147;
|
||||
|
||||
/** override default values, defined below in _DEFAULTS[], prefixing with _ENVVAR_PREFIX:
|
||||
*
|
||||
* DB_TYPE becomes:
|
||||
*
|
||||
* .env (docker environment):
|
||||
*
|
||||
* TTRSS_DB_TYPE=pgsql
|
||||
*
|
||||
* or config.php:
|
||||
*
|
||||
* putenv('TTRSS_DB_TYPE=pgsql');
|
||||
*
|
||||
* note lack of quotes and spaces before and after "=".
|
||||
*
|
||||
*/
|
||||
|
||||
/** database type: pgsql or mysql */
|
||||
const DB_TYPE = "DB_TYPE";
|
||||
|
||||
/** database server hostname */
|
||||
const DB_HOST = "DB_HOST";
|
||||
|
||||
/** database user */
|
||||
const DB_USER = "DB_USER";
|
||||
|
||||
/** database name */
|
||||
const DB_NAME = "DB_NAME";
|
||||
|
||||
/** database password */
|
||||
const DB_PASS = "DB_PASS";
|
||||
|
||||
/** database server port */
|
||||
const DB_PORT = "DB_PORT";
|
||||
|
||||
/** connection charset for MySQL. if you have a legacy database and/or experience
|
||||
* garbage unicode characters with this option, try setting it to a blank string. */
|
||||
const MYSQL_CHARSET = "MYSQL_CHARSET";
|
||||
|
||||
/** this is a fallback falue for the CLI SAPI, it should be set to a fully-qualified tt-rss URL */
|
||||
const SELF_URL_PATH = "SELF_URL_PATH";
|
||||
|
||||
/** operate in single user mode, disables all functionality related to
|
||||
* multiple users and authentication. enabling this assumes you have
|
||||
* your tt-rss directory protected by other means (e.g. http auth). */
|
||||
const SINGLE_USER_MODE = "SINGLE_USER_MODE";
|
||||
|
||||
/** enables fallback update mode where tt-rss tries to update feeds in
|
||||
* background while tt-rss is open in your browser.
|
||||
* if you don't have a lot of feeds and don't want to or can't run
|
||||
* background processes while not running tt-rss, this method is generally
|
||||
* viable to keep your feeds up to date. */
|
||||
const SIMPLE_UPDATE_MODE = "SIMPLE_UPDATE_MODE";
|
||||
|
||||
/** use this PHP CLI executable to start various tasks */
|
||||
const PHP_EXECUTABLE = "PHP_EXECUTABLE";
|
||||
|
||||
/** base directory for lockfiles (must be writable) */
|
||||
const LOCK_DIRECTORY = "LOCK_DIRECTORY";
|
||||
|
||||
/** base directory for local cache (must be writable) */
|
||||
const CACHE_DIR = "CACHE_DIR";
|
||||
|
||||
/** directory for feed favicons (directory must be writable) */
|
||||
const ICONS_DIR = "ICONS_DIR";
|
||||
|
||||
/** URL for feed favicons */
|
||||
const ICONS_URL = "ICONS_URL";
|
||||
|
||||
/** auto create users authenticated via external modules */
|
||||
const AUTH_AUTO_CREATE = "AUTH_AUTO_CREATE";
|
||||
|
||||
/** auto log in users authenticated via external modules i.e. auth_remote */
|
||||
const AUTH_AUTO_LOGIN = "AUTH_AUTO_LOGIN";
|
||||
|
||||
/** unconditinally purge all articles older than this amount, in days
|
||||
* overrides user-controlled purge interval */
|
||||
const FORCE_ARTICLE_PURGE = "FORCE_ARTICLE_PURGE";
|
||||
|
||||
/** default lifetime of a session (e.g. login) cookie. In seconds,
|
||||
* 0 means cookie will be deleted when browser closes. */
|
||||
const SESSION_COOKIE_LIFETIME = "SESSION_COOKIE_LIFETIME";
|
||||
|
||||
/** send email using this name */
|
||||
const SMTP_FROM_NAME = "SMTP_FROM_NAME";
|
||||
|
||||
/** send email using this address */
|
||||
const SMTP_FROM_ADDRESS = "SMTP_FROM_ADDRESS";
|
||||
|
||||
/** default subject for email digest */
|
||||
const DIGEST_SUBJECT = "DIGEST_SUBJECT";
|
||||
|
||||
/** enable built-in update checker, both for core code and plugins (using git) */
|
||||
const CHECK_FOR_UPDATES = "CHECK_FOR_UPDATES";
|
||||
|
||||
/** system plugins enabled for all users, comma separated list, no quotes
|
||||
* keep at least one auth module in there (i.e. auth_internal) */
|
||||
const PLUGINS = "PLUGINS";
|
||||
|
||||
/** available options: sql (default, event log), syslog, stdout (for debugging) */
|
||||
const LOG_DESTINATION = "LOG_DESTINATION";
|
||||
|
||||
/** link this stylesheet on all pages (if it exists), should be placed in themes.local */
|
||||
const LOCAL_OVERRIDE_STYLESHEET = "LOCAL_OVERRIDE_STYLESHEET";
|
||||
|
||||
/** same but this javascript file (you can use that for polyfills), should be placed in themes.local */
|
||||
const LOCAL_OVERRIDE_JS = "LOCAL_OVERRIDE_JS";
|
||||
|
||||
/** in seconds, terminate update tasks that ran longer than this interval */
|
||||
const DAEMON_MAX_CHILD_RUNTIME = "DAEMON_MAX_CHILD_RUNTIME";
|
||||
|
||||
/** max concurrent update jobs forking update daemon starts */
|
||||
const DAEMON_MAX_JOBS = "DAEMON_MAX_JOBS";
|
||||
|
||||
/** log level for update daemon */
|
||||
const DAEMON_LOG_LEVEL = "DAEMON_LOG_LEVEL";
|
||||
|
||||
/** How long to wait for response when requesting feed from a site (seconds) */
|
||||
const FEED_FETCH_TIMEOUT = "FEED_FETCH_TIMEOUT";
|
||||
|
||||
/** How long to wait for response when requesting uncached feed from a site (seconds) */
|
||||
const FEED_FETCH_NO_CACHE_TIMEOUT = "FEED_FETCH_NO_CACHE_TIMEOUT";
|
||||
|
||||
/** Default timeout when fetching files from remote sites */
|
||||
const FILE_FETCH_TIMEOUT = "FILE_FETCH_TIMEOUT";
|
||||
|
||||
/** How long to wait for initial response from website when fetching remote files */
|
||||
const FILE_FETCH_CONNECT_TIMEOUT = "FILE_FETCH_CONNECT_TIMEOUT";
|
||||
|
||||
/** stop updating feeds if user haven't logged in for X days */
|
||||
const DAEMON_UPDATE_LOGIN_LIMIT = "DAEMON_UPDATE_LOGIN_LIMIT";
|
||||
|
||||
/** how many feeds to update in one batch */
|
||||
const DAEMON_FEED_LIMIT = "DAEMON_FEED_LIMIT";
|
||||
|
||||
/** default sleep interval between feed updates (sec) */
|
||||
const DAEMON_SLEEP_INTERVAL = "DAEMON_SLEEP_INTERVAL";
|
||||
|
||||
/** do not cache files larger than that (bytes) */
|
||||
const MAX_CACHE_FILE_SIZE = "MAX_CACHE_FILE_SIZE";
|
||||
|
||||
/** do not download files larger than that (bytes) */
|
||||
const MAX_DOWNLOAD_FILE_SIZE = "MAX_DOWNLOAD_FILE_SIZE";
|
||||
|
||||
/** max file size for downloaded favicons (bytes) */
|
||||
const MAX_FAVICON_FILE_SIZE = "MAX_FAVICON_FILE_SIZE";
|
||||
|
||||
/** max age in days for various automatically cached (temporary) files */
|
||||
const CACHE_MAX_DAYS = "CACHE_MAX_DAYS";
|
||||
|
||||
/** max interval between forced unconditional updates for servers
|
||||
* not complying with http if-modified-since (seconds) */
|
||||
const MAX_CONDITIONAL_INTERVAL = "MAX_CONDITIONAL_INTERVAL";
|
||||
|
||||
/** automatically disable updates for feeds which failed to
|
||||
* update for this amount of days; 0 disables */
|
||||
const DAEMON_UNSUCCESSFUL_DAYS_LIMIT = "DAEMON_UNSUCCESSFUL_DAYS_LIMIT";
|
||||
|
||||
/** log all sent emails in the event log */
|
||||
const LOG_SENT_MAIL = "LOG_SENT_MAIL";
|
||||
|
||||
/** use HTTP proxy for requests */
|
||||
const HTTP_PROXY = "HTTP_PROXY";
|
||||
|
||||
/** prevent users from changing passwords */
|
||||
const FORBID_PASSWORD_CHANGES = "FORBID_PASSWORD_CHANGES";
|
||||
|
||||
/** default session cookie name */
|
||||
const SESSION_NAME = "SESSION_NAME";
|
||||
|
||||
/** enable plugin update checker (using git) */
|
||||
const CHECK_FOR_PLUGIN_UPDATES = "CHECK_FOR_PLUGIN_UPDATES";
|
||||
|
||||
/** allow installing first party plugins using plugin installer in prefs */
|
||||
const ENABLE_PLUGIN_INSTALLER = "ENABLE_PLUGIN_INSTALLER";
|
||||
|
||||
/** minimum amount of seconds required between authentication attempts */
|
||||
const AUTH_MIN_INTERVAL = "AUTH_MIN_INTERVAL";
|
||||
|
||||
/** http user agent (changing this is not recommended) */
|
||||
const HTTP_USER_AGENT = "HTTP_USER_AGENT";
|
||||
|
||||
/** delay updates for this feed if received HTTP 429 (Too Many Requests) for this amount of seconds (base value, actual delay is base...base*2) */
|
||||
const HTTP_429_THROTTLE_INTERVAL = "HTTP_429_THROTTLE_INTERVAL";
|
||||
|
||||
/** host running Jaeger collector to receive traces (disabled if empty) */
|
||||
const OPENTELEMETRY_ENDPOINT = "OPENTELEMETRY_ENDPOINT";
|
||||
|
||||
/** Jaeger service name */
|
||||
const OPENTELEMETRY_SERVICE = "OPENTELEMETRY_SERVICE";
|
||||
|
||||
/** default values for all global configuration options */
|
||||
private const _DEFAULTS = [
|
||||
Config::DB_TYPE => [ "pgsql", Config::T_STRING ],
|
||||
Config::DB_HOST => [ "db", Config::T_STRING ],
|
||||
Config::DB_USER => [ "", Config::T_STRING ],
|
||||
Config::DB_NAME => [ "", Config::T_STRING ],
|
||||
Config::DB_PASS => [ "", Config::T_STRING ],
|
||||
Config::DB_PORT => [ "5432", Config::T_STRING ],
|
||||
Config::MYSQL_CHARSET => [ "UTF8", Config::T_STRING ],
|
||||
Config::SELF_URL_PATH => [ "https://example.com/tt-rss", Config::T_STRING ],
|
||||
Config::SINGLE_USER_MODE => [ "", Config::T_BOOL ],
|
||||
Config::SIMPLE_UPDATE_MODE => [ "", Config::T_BOOL ],
|
||||
Config::PHP_EXECUTABLE => [ "/usr/bin/php", Config::T_STRING ],
|
||||
Config::LOCK_DIRECTORY => [ "lock", Config::T_STRING ],
|
||||
Config::CACHE_DIR => [ "cache", Config::T_STRING ],
|
||||
Config::ICONS_DIR => [ "feed-icons", Config::T_STRING ],
|
||||
Config::ICONS_URL => [ "feed-icons", Config::T_STRING ],
|
||||
Config::AUTH_AUTO_CREATE => [ "true", Config::T_BOOL ],
|
||||
Config::AUTH_AUTO_LOGIN => [ "true", Config::T_BOOL ],
|
||||
Config::FORCE_ARTICLE_PURGE => [ 0, Config::T_INT ],
|
||||
Config::SESSION_COOKIE_LIFETIME => [ 86400, Config::T_INT ],
|
||||
Config::SMTP_FROM_NAME => [ "Tiny Tiny RSS", Config::T_STRING ],
|
||||
Config::SMTP_FROM_ADDRESS => [ "noreply@localhost", Config::T_STRING ],
|
||||
Config::DIGEST_SUBJECT => [ "[tt-rss] New headlines for last 24 hours",
|
||||
Config::T_STRING ],
|
||||
Config::CHECK_FOR_UPDATES => [ "true", Config::T_BOOL ],
|
||||
Config::PLUGINS => [ "auth_internal", Config::T_STRING ],
|
||||
Config::LOG_DESTINATION => [ Logger::LOG_DEST_SQL, Config::T_STRING ],
|
||||
Config::LOCAL_OVERRIDE_STYLESHEET => [ "local-overrides.css",
|
||||
Config::T_STRING ],
|
||||
Config::LOCAL_OVERRIDE_JS => [ "local-overrides.js",
|
||||
Config::T_STRING ],
|
||||
Config::DAEMON_MAX_CHILD_RUNTIME => [ 1800, Config::T_INT ],
|
||||
Config::DAEMON_MAX_JOBS => [ 2, Config::T_INT ],
|
||||
Config::DAEMON_LOG_LEVEL => [ Debug::LOG_NORMAL, Config::T_INT ],
|
||||
Config::FEED_FETCH_TIMEOUT => [ 45, Config::T_INT ],
|
||||
Config::FEED_FETCH_NO_CACHE_TIMEOUT => [ 15, Config::T_INT ],
|
||||
Config::FILE_FETCH_TIMEOUT => [ 45, Config::T_INT ],
|
||||
Config::FILE_FETCH_CONNECT_TIMEOUT => [ 15, Config::T_INT ],
|
||||
Config::DAEMON_UPDATE_LOGIN_LIMIT => [ 30, Config::T_INT ],
|
||||
Config::DAEMON_FEED_LIMIT => [ 50, Config::T_INT ],
|
||||
Config::DAEMON_SLEEP_INTERVAL => [ 120, Config::T_INT ],
|
||||
Config::MAX_CACHE_FILE_SIZE => [ 64*1024*1024, Config::T_INT ],
|
||||
Config::MAX_DOWNLOAD_FILE_SIZE => [ 16*1024*1024, Config::T_INT ],
|
||||
Config::MAX_FAVICON_FILE_SIZE => [ 1*1024*1024, Config::T_INT ],
|
||||
Config::CACHE_MAX_DAYS => [ 7, Config::T_INT ],
|
||||
Config::MAX_CONDITIONAL_INTERVAL => [ 3600*12, Config::T_INT ],
|
||||
Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT => [ 30, Config::T_INT ],
|
||||
Config::LOG_SENT_MAIL => [ "", Config::T_BOOL ],
|
||||
Config::HTTP_PROXY => [ "", Config::T_STRING ],
|
||||
Config::FORBID_PASSWORD_CHANGES => [ "", Config::T_BOOL ],
|
||||
Config::SESSION_NAME => [ "ttrss_sid", Config::T_STRING ],
|
||||
Config::CHECK_FOR_PLUGIN_UPDATES => [ "true", Config::T_BOOL ],
|
||||
Config::ENABLE_PLUGIN_INSTALLER => [ "true", Config::T_BOOL ],
|
||||
Config::AUTH_MIN_INTERVAL => [ 5, Config::T_INT ],
|
||||
Config::HTTP_USER_AGENT => [ 'Tiny Tiny RSS/%s (https://tt-rss.org/)',
|
||||
Config::T_STRING ],
|
||||
Config::HTTP_429_THROTTLE_INTERVAL => [ 3600, Config::T_INT ],
|
||||
Config::OPENTELEMETRY_ENDPOINT => [ "", Config::T_STRING ],
|
||||
Config::OPENTELEMETRY_SERVICE => [ "tt-rss", Config::T_STRING ],
|
||||
];
|
||||
|
||||
/** @var Config|null */
|
||||
private static $instance;
|
||||
|
||||
/** @var array<string, array<bool|int|string>> */
|
||||
private $params = [];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private $version = [];
|
||||
|
||||
/** @var Db_Migrations|null $migrations */
|
||||
private $migrations;
|
||||
|
||||
public static function get_instance() : Config {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function __clone() {
|
||||
//
|
||||
}
|
||||
|
||||
function __construct() {
|
||||
$ref = new ReflectionClass(get_class($this));
|
||||
|
||||
foreach ($ref->getConstants() as $const => $cvalue) {
|
||||
if (isset(self::_DEFAULTS[$const])) {
|
||||
$override = getenv(self::_ENVVAR_PREFIX . $const);
|
||||
|
||||
list ($defval, $deftype) = self::_DEFAULTS[$const];
|
||||
|
||||
$this->params[$cvalue] = [ self::cast_to($override !== false ? $override : $defval, $deftype), $deftype ];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** determine tt-rss version (using git)
|
||||
*
|
||||
* package maintainers who don't use git: if version_static.txt exists in tt-rss root
|
||||
* directory, its contents are displayed instead of git commit-based version, this could be generated
|
||||
* based on source git tree commit used when creating the package
|
||||
* @return array<string, mixed>|string
|
||||
*/
|
||||
static function get_version(bool $as_string = true) {
|
||||
return self::get_instance()->_get_version($as_string);
|
||||
}
|
||||
|
||||
// returns version showing (if possible) full timestamp of commit id
|
||||
static function get_version_html() : string {
|
||||
$version = self::get_version(false);
|
||||
|
||||
return sprintf("<span title=\"%s\n%s\n%s\">%s</span>",
|
||||
date("Y-m-d H:i:s", ($version['timestamp'] ?? 0)),
|
||||
$version['commit'] ?? '',
|
||||
$version['branch'] ?? '',
|
||||
$version['version']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|string
|
||||
*/
|
||||
private function _get_version(bool $as_string = true) {
|
||||
$root_dir = self::get_self_dir();
|
||||
|
||||
if (empty($this->version)) {
|
||||
$this->version["status"] = -1;
|
||||
|
||||
if (getenv("CI_COMMIT_SHORT_SHA") && getenv("CI_COMMIT_TIMESTAMP")) {
|
||||
|
||||
$this->version["branch"] = getenv("CI_COMMIT_BRANCH");
|
||||
$this->version["timestamp"] = strtotime(getenv("CI_COMMIT_TIMESTAMP"));
|
||||
$this->version["version"] = sprintf("%s-%s", date("y.m", $this->version["timestamp"]), getenv("CI_COMMIT_SHORT_SHA"));
|
||||
$this->version["commit"] = getenv("CI_COMMIT_SHORT_SHA");
|
||||
$this->version["status"] = 0;
|
||||
|
||||
} else if (PHP_OS === "Darwin") {
|
||||
$this->version["version"] = "UNKNOWN (Unsupported, Darwin)";
|
||||
} else if (file_exists("$root_dir/version_static.txt")) {
|
||||
$this->version["version"] = trim(file_get_contents("$root_dir/version_static.txt")) . " (Unsupported)";
|
||||
} else if (ini_get("open_basedir")) {
|
||||
$this->version["version"] = "UNKNOWN (Unsupported, open_basedir)";
|
||||
} else if (is_dir("$root_dir/.git")) {
|
||||
$this->version = self::get_version_from_git($root_dir);
|
||||
|
||||
if ($this->version["status"] != 0) {
|
||||
user_error("Unable to determine version: " . $this->version["version"], E_USER_WARNING);
|
||||
|
||||
$this->version["version"] = "UNKNOWN (Unsupported, Git error)";
|
||||
} else if (!getenv("SCRIPT_ROOT") || !file_exists("/.dockerenv")) {
|
||||
$this->version["version"] .= " (Unsupported)";
|
||||
}
|
||||
|
||||
} else {
|
||||
$this->version["version"] = "UNKNOWN (Unsupported)";
|
||||
}
|
||||
}
|
||||
|
||||
return $as_string ? $this->version["version"] : $this->version;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int|string>
|
||||
*/
|
||||
static function get_version_from_git(string $dir): array {
|
||||
$descriptorspec = [
|
||||
1 => ["pipe", "w"], // STDOUT
|
||||
2 => ["pipe", "w"], // STDERR
|
||||
];
|
||||
|
||||
$rv = [
|
||||
"status" => -1,
|
||||
"version" => "",
|
||||
"branch" => "",
|
||||
"commit" => "",
|
||||
"timestamp" => 0,
|
||||
];
|
||||
|
||||
$proc = proc_open("git --no-pager log --pretty=\"version-%ct-%h\" -n1 HEAD",
|
||||
$descriptorspec, $pipes, $dir);
|
||||
|
||||
if (is_resource($proc)) {
|
||||
$stdout = trim(stream_get_contents($pipes[1]));
|
||||
$stderr = trim(stream_get_contents($pipes[2]));
|
||||
$status = proc_close($proc);
|
||||
|
||||
$rv["status"] = $status;
|
||||
|
||||
list($check, $timestamp, $commit) = explode("-", $stdout);
|
||||
|
||||
if ($check == "version") {
|
||||
|
||||
$rv["version"] = sprintf("%s-%s", date("y.m", (int)$timestamp), $commit);
|
||||
$rv["commit"] = $commit;
|
||||
$rv["timestamp"] = $timestamp;
|
||||
|
||||
// proc_close() may return -1 even if command completed successfully
|
||||
// so if it looks like we got valid data, we ignore it
|
||||
|
||||
if ($rv["status"] == -1)
|
||||
$rv["status"] = 0;
|
||||
|
||||
} else {
|
||||
$rv["version"] = T_sprintf("Git error [RC=%d]: %s", $status, $stderr);
|
||||
}
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
static function get_migrations() : Db_Migrations {
|
||||
return self::get_instance()->_get_migrations();
|
||||
}
|
||||
|
||||
private function _get_migrations() : Db_Migrations {
|
||||
if (empty($this->migrations)) {
|
||||
$this->migrations = new Db_Migrations();
|
||||
$this->migrations->initialize(self::get_self_dir() . "/sql", "ttrss_version", true, self::SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
return $this->migrations;
|
||||
}
|
||||
|
||||
static function is_migration_needed() : bool {
|
||||
return self::get_migrations()->is_migration_needed();
|
||||
}
|
||||
|
||||
static function get_schema_version() : int {
|
||||
return self::get_migrations()->get_version();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|int|string
|
||||
*/
|
||||
static function cast_to(string $value, int $type_hint) {
|
||||
switch ($type_hint) {
|
||||
case self::T_BOOL:
|
||||
return sql_bool_to_bool($value);
|
||||
case self::T_INT:
|
||||
return (int) $value;
|
||||
default:
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|int|string
|
||||
*/
|
||||
private function _get(string $param) {
|
||||
list ($value, $type_hint) = $this->params[$param];
|
||||
|
||||
return $this->cast_to($value, $type_hint);
|
||||
}
|
||||
|
||||
private function _add(string $param, string $default, int $type_hint): void {
|
||||
$override = getenv(self::_ENVVAR_PREFIX . $param);
|
||||
|
||||
$this->params[$param] = [ self::cast_to($override !== false ? $override : $default, $type_hint), $type_hint ];
|
||||
}
|
||||
|
||||
static function add(string $param, string $default, int $type_hint = Config::T_STRING): void {
|
||||
$instance = self::get_instance();
|
||||
|
||||
$instance->_add($param, $default, $type_hint);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|int|string
|
||||
*/
|
||||
static function get(string $param) {
|
||||
$instance = self::get_instance();
|
||||
|
||||
return $instance->_get($param);
|
||||
}
|
||||
|
||||
static function is_server_https() : bool {
|
||||
return (!empty($_SERVER['HTTPS']) && ($_SERVER['HTTPS'] != 'off')) ||
|
||||
(!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https');
|
||||
}
|
||||
|
||||
/** returns fully-qualified external URL to tt-rss (no trailing slash)
|
||||
* SELF_URL_PATH configuration variable is used as a fallback for the CLI SAPI
|
||||
* */
|
||||
static function get_self_url(bool $always_detect = false) : string {
|
||||
if (!$always_detect && php_sapi_name() == "cli") {
|
||||
return self::get(Config::SELF_URL_PATH);
|
||||
} else {
|
||||
$proto = self::is_server_https() ? 'https' : 'http';
|
||||
|
||||
$self_url_path = $proto . '://' . $_SERVER["HTTP_HOST"] . parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
|
||||
$self_url_path = preg_replace("/(\/api\/{1,})?(\w+\.php)?(\?.*$)?$/", "", $self_url_path);
|
||||
$self_url_path = preg_replace("/(\/plugins(.local))\/.{1,}$/", "", $self_url_path);
|
||||
|
||||
return rtrim($self_url_path, "/");
|
||||
}
|
||||
}
|
||||
/* sanity check stuff */
|
||||
|
||||
/** checks for mysql tables not using InnoDB (tt-rss is incompatible with MyISAM)
|
||||
* @return array<int, array<string, string>> A list of entries identifying tt-rss tables with bad config
|
||||
*/
|
||||
private static function check_mysql_tables() {
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT engine, table_name FROM information_schema.tables WHERE
|
||||
table_schema = ? AND table_name LIKE 'ttrss_%' AND engine != 'InnoDB'");
|
||||
$sth->execute([self::get(Config::DB_NAME)]);
|
||||
|
||||
$bad_tables = [];
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
array_push($bad_tables, $line);
|
||||
}
|
||||
|
||||
return $bad_tables;
|
||||
}
|
||||
|
||||
static function sanity_check(): void {
|
||||
|
||||
/*
|
||||
we don't actually need the DB object right now but some checks below might use ORM which won't be initialized
|
||||
because it is set up in the Db constructor, which is why it's a good idea to invoke it as early as possible
|
||||
|
||||
it is a bit of a hack, maybe ORM should be initialized somewhere else (functions.php?)
|
||||
*/
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$errors = [];
|
||||
|
||||
if (strpos(self::get(Config::PLUGINS), "auth_") === false) {
|
||||
array_push($errors, "Please enable at least one authentication module via PLUGINS");
|
||||
}
|
||||
|
||||
/* we assume our dependencies are sane under docker, so some sanity checks are skipped.
|
||||
this also allows tt-rss process to run under root if requested (I'm using this for development
|
||||
under podman because of uidmapping issues with rootless containers, don't use in production -fox) */
|
||||
if (!getenv("container")) {
|
||||
if (function_exists('posix_getuid') && posix_getuid() == 0) {
|
||||
array_push($errors, "Please don't run this script as root.");
|
||||
}
|
||||
|
||||
if (version_compare(PHP_VERSION, '7.4.0', '<')) {
|
||||
array_push($errors, "PHP version 7.4.0 or newer required. You're using " . PHP_VERSION . ".");
|
||||
}
|
||||
|
||||
if (!class_exists("UConverter")) {
|
||||
array_push($errors, "PHP UConverter class is missing, it's provided by the Internationalization (intl) module.");
|
||||
}
|
||||
|
||||
if (!function_exists("curl_init") && !ini_get("allow_url_fopen")) {
|
||||
array_push($errors, "PHP configuration option allow_url_fopen is disabled, and CURL functions are not present. Either enable allow_url_fopen or install PHP extension for CURL.");
|
||||
}
|
||||
|
||||
if (!function_exists("json_encode")) {
|
||||
array_push($errors, "PHP support for JSON is required, but was not found.");
|
||||
}
|
||||
|
||||
if (!function_exists("flock")) {
|
||||
array_push($errors, "PHP support for flock() function is required.");
|
||||
}
|
||||
|
||||
if (!class_exists("PDO")) {
|
||||
array_push($errors, "PHP support for PDO is required but was not found.");
|
||||
}
|
||||
|
||||
if (!function_exists("mb_strlen")) {
|
||||
array_push($errors, "PHP support for mbstring functions is required but was not found.");
|
||||
}
|
||||
|
||||
if (!function_exists("hash")) {
|
||||
array_push($errors, "PHP support for hash() function is required but was not found.");
|
||||
}
|
||||
|
||||
if (ini_get("safe_mode")) {
|
||||
array_push($errors, "PHP safe mode setting is obsolete and not supported by tt-rss.");
|
||||
}
|
||||
|
||||
if (!function_exists("mime_content_type")) {
|
||||
array_push($errors, "PHP function mime_content_type() is missing, try enabling fileinfo module.");
|
||||
}
|
||||
|
||||
if (!class_exists("DOMDocument")) {
|
||||
array_push($errors, "PHP support for DOMDocument is required, but was not found.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_writable(self::get(Config::CACHE_DIR) . "/images")) {
|
||||
array_push($errors, "Image cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/images)");
|
||||
}
|
||||
|
||||
if (!is_writable(self::get(Config::CACHE_DIR) . "/upload")) {
|
||||
array_push($errors, "Upload cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/upload)");
|
||||
}
|
||||
|
||||
if (!is_writable(self::get(Config::CACHE_DIR) . "/export")) {
|
||||
array_push($errors, "Data export cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/export)");
|
||||
}
|
||||
|
||||
if (!is_writable(self::get(Config::ICONS_DIR))) {
|
||||
array_push($errors, "ICONS_DIR defined in config.php is not writable (chmod -R 777 ".self::get(Config::ICONS_DIR).").\n");
|
||||
}
|
||||
|
||||
if (!is_writable(self::get(Config::LOCK_DIRECTORY))) {
|
||||
array_push($errors, "LOCK_DIRECTORY is not writable (chmod -R 777 ".self::get(Config::LOCK_DIRECTORY).").\n");
|
||||
}
|
||||
|
||||
// ttrss_users won't be there on initial startup (before migrations are done)
|
||||
if (!Config::is_migration_needed() && self::get(Config::SINGLE_USER_MODE)) {
|
||||
if (UserHelper::get_login_by_id(1) != "admin") {
|
||||
array_push($errors, "SINGLE_USER_MODE is enabled but default admin account (ID: 1) is not found.");
|
||||
}
|
||||
}
|
||||
|
||||
// skip check for CLI scripts so that we could install database schema if it is missing.
|
||||
if (php_sapi_name() != "cli") {
|
||||
if (self::get_schema_version() < 0) {
|
||||
array_push($errors, "Base database schema is missing. Either load it manually or perform a migration (<code>update.php --update-schema</code>)");
|
||||
}
|
||||
}
|
||||
|
||||
if (self::get(Config::DB_TYPE) == "mysql") {
|
||||
$bad_tables = self::check_mysql_tables();
|
||||
|
||||
if (count($bad_tables) > 0) {
|
||||
$bad_tables_fmt = [];
|
||||
|
||||
foreach ($bad_tables as $bt) {
|
||||
array_push($bad_tables_fmt, sprintf("%s (%s)", $bt['table_name'], $bt['engine']));
|
||||
}
|
||||
|
||||
$msg = "<p>The following tables use an unsupported MySQL engine: <b>" .
|
||||
implode(", ", $bad_tables_fmt) . "</b>.</p>";
|
||||
|
||||
$msg .= "<p>The only supported engine on MySQL is InnoDB. MyISAM lacks functionality to run
|
||||
tt-rss.
|
||||
Please backup your data (via OPML) and re-import the schema before continuing.</p>
|
||||
<p><b>WARNING: importing the schema would mean LOSS OF ALL YOUR DATA.</b></p>";
|
||||
|
||||
|
||||
array_push($errors, $msg);
|
||||
}
|
||||
}
|
||||
|
||||
if (count($errors) > 0 && php_sapi_name() != "cli") {
|
||||
http_response_code(503); ?>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Startup failed</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<link rel="stylesheet" type="text/css" href="themes/light.css">
|
||||
</head>
|
||||
<body class="sanity_failed flat ttrss_utility">
|
||||
<div class="content">
|
||||
<h1>Startup failed</h1>
|
||||
|
||||
<p>Please fix errors indicated by the following messages:</p>
|
||||
|
||||
<?php foreach ($errors as $error) { echo self::format_error($error); } ?>
|
||||
|
||||
<p>You might want to check the tt-rss <a target="_blank" href="https://tt-rss.org/wiki/InstallationNotes/">wiki</a> or
|
||||
<a target="_blank" href="https://community.tt-rss.org/">forums</a> for more information. Please search the forums before creating a new topic
|
||||
for your question.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<?php
|
||||
die;
|
||||
} else if (count($errors) > 0) {
|
||||
echo "Please fix errors indicated by the following messages:\n\n";
|
||||
|
||||
foreach ($errors as $error) {
|
||||
echo " * " . strip_tags($error)."\n";
|
||||
}
|
||||
|
||||
echo "\nYou might want to check the tt-rss wiki or forums for more information.\n";
|
||||
echo "Please search the forums before creating a new topic for your question.\n";
|
||||
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private static function format_error(string $msg): string {
|
||||
return "<div class=\"alert alert-danger\">$msg</div>";
|
||||
}
|
||||
|
||||
static function get_override_links(): string {
|
||||
$rv = "";
|
||||
|
||||
$local_css = get_theme_path(self::get(self::LOCAL_OVERRIDE_STYLESHEET));
|
||||
if ($local_css) $rv .= stylesheet_tag($local_css);
|
||||
|
||||
$local_js = get_theme_path(self::get(self::LOCAL_OVERRIDE_JS));
|
||||
if ($local_js) $rv .= javascript_tag($local_js);
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
static function get_user_agent(): string {
|
||||
return sprintf(self::get(self::HTTP_USER_AGENT), self::get_version());
|
||||
}
|
||||
|
||||
static function get_self_dir() : string {
|
||||
return dirname(__DIR__); # we're in classes/Config.php
|
||||
}
|
||||
|
||||
}
|
||||
362
classes/Counters.php
Normal file
362
classes/Counters.php
Normal file
@@ -0,0 +1,362 @@
|
||||
<?php
|
||||
class Counters {
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, int|string>>
|
||||
*/
|
||||
static function get_all(): array {
|
||||
return [
|
||||
...self::get_global(),
|
||||
...self::get_virt(),
|
||||
...self::get_labels(),
|
||||
...self::get_feeds(),
|
||||
...self::get_cats(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $feed_ids
|
||||
* @param array<int>|null $label_ids
|
||||
* @return array<int, array<string, int|string>>
|
||||
*/
|
||||
static function get_conditional(?array $feed_ids = null, ?array $label_ids = null): array {
|
||||
return [
|
||||
...self::get_global(),
|
||||
...self::get_virt(),
|
||||
...self::get_labels($label_ids),
|
||||
...self::get_feeds($feed_ids),
|
||||
...self::get_cats(is_array($feed_ids) ? Feeds::_cats_of($feed_ids, $_SESSION["uid"], true) : null)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
static private function get_cat_children(int $cat_id, int $owner_uid): array {
|
||||
$unread = 0;
|
||||
$marked = 0;
|
||||
|
||||
$cats = ORM::for_table('ttrss_feed_categories')
|
||||
->where('owner_uid', $owner_uid)
|
||||
->where('parent_cat', $cat_id)
|
||||
->find_many();
|
||||
|
||||
foreach ($cats as $cat) {
|
||||
list ($tmp_unread, $tmp_marked) = self::get_cat_children($cat->id, $owner_uid);
|
||||
|
||||
$unread += $tmp_unread + Feeds::_get_cat_unread($cat->id, $owner_uid);
|
||||
$marked += $tmp_marked + Feeds::_get_cat_marked($cat->id, $owner_uid);
|
||||
}
|
||||
|
||||
return [$unread, $marked];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $cat_ids
|
||||
* @return array<int, array<string, int|string>>
|
||||
*/
|
||||
private static function get_cats(?array $cat_ids = null): array {
|
||||
$ret = [];
|
||||
|
||||
/* Labels category */
|
||||
|
||||
$cv = array("id" => Feeds::CATEGORY_LABELS, "kind" => "cat",
|
||||
"counter" => Feeds::_get_cat_unread(Feeds::CATEGORY_LABELS));
|
||||
|
||||
array_push($ret, $cv);
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
if (is_array($cat_ids)) {
|
||||
if (count($cat_ids) == 0)
|
||||
return [];
|
||||
|
||||
$cat_ids_qmarks = arr_qmarks($cat_ids);
|
||||
|
||||
$sth = $pdo->prepare("SELECT fc.id,
|
||||
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
|
||||
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
|
||||
(SELECT COUNT(id) FROM ttrss_feed_categories fcc
|
||||
WHERE fcc.parent_cat = fc.id) AS num_children
|
||||
FROM ttrss_feed_categories fc
|
||||
LEFT JOIN ttrss_feeds f ON (f.cat_id = fc.id)
|
||||
LEFT JOIN ttrss_user_entries ue ON (ue.feed_id = f.id)
|
||||
WHERE fc.owner_uid = ? AND fc.id IN ($cat_ids_qmarks)
|
||||
GROUP BY fc.id
|
||||
UNION
|
||||
SELECT 0,
|
||||
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
|
||||
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
|
||||
0
|
||||
FROM ttrss_feeds f, ttrss_user_entries ue
|
||||
WHERE f.cat_id IS NULL AND
|
||||
ue.feed_id = f.id AND
|
||||
ue.owner_uid = ?");
|
||||
|
||||
$sth->execute([$_SESSION['uid'], ...$cat_ids, $_SESSION['uid']]);
|
||||
|
||||
} else {
|
||||
$sth = $pdo->prepare("SELECT fc.id,
|
||||
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
|
||||
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
|
||||
(SELECT COUNT(id) FROM ttrss_feed_categories fcc
|
||||
WHERE fcc.parent_cat = fc.id) AS num_children
|
||||
FROM ttrss_feed_categories fc
|
||||
LEFT JOIN ttrss_feeds f ON (f.cat_id = fc.id)
|
||||
LEFT JOIN ttrss_user_entries ue ON (ue.feed_id = f.id)
|
||||
WHERE fc.owner_uid = :uid
|
||||
GROUP BY fc.id
|
||||
UNION
|
||||
SELECT 0,
|
||||
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
|
||||
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
|
||||
0
|
||||
FROM ttrss_feeds f, ttrss_user_entries ue
|
||||
WHERE f.cat_id IS NULL AND
|
||||
ue.feed_id = f.id AND
|
||||
ue.owner_uid = :uid");
|
||||
|
||||
$sth->execute(["uid" => $_SESSION['uid']]);
|
||||
}
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
if ($line["num_children"] > 0) {
|
||||
list ($child_counter, $child_marked_counter) = self::get_cat_children($line["id"], $_SESSION["uid"]);
|
||||
} else {
|
||||
$child_counter = 0;
|
||||
$child_marked_counter = 0;
|
||||
}
|
||||
|
||||
$cv = [
|
||||
"id" => (int)$line["id"],
|
||||
"kind" => "cat",
|
||||
"markedcounter" => (int) $line["count_marked"] + $child_marked_counter,
|
||||
"counter" => (int) $line["count"] + $child_counter
|
||||
];
|
||||
|
||||
array_push($ret, $cv);
|
||||
}
|
||||
|
||||
return $ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $feed_ids
|
||||
* @return array<int, array<string, int|string>>
|
||||
*/
|
||||
private static function get_feeds(?array $feed_ids = null): array {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
|
||||
$ret = [];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
if (is_array($feed_ids)) {
|
||||
if (count($feed_ids) == 0)
|
||||
return [];
|
||||
|
||||
$feed_ids_qmarks = arr_qmarks($feed_ids);
|
||||
|
||||
$sth = $pdo->prepare("SELECT f.id,
|
||||
f.title,
|
||||
".SUBSTRING_FOR_DATE."(f.last_updated,1,19) AS last_updated,
|
||||
f.last_error,
|
||||
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
|
||||
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked
|
||||
FROM ttrss_feeds f, ttrss_user_entries ue
|
||||
WHERE f.id = ue.feed_id AND ue.owner_uid = ? AND f.id IN ($feed_ids_qmarks)
|
||||
GROUP BY f.id");
|
||||
|
||||
$sth->execute([$_SESSION['uid'], ...$feed_ids]);
|
||||
} else {
|
||||
$sth = $pdo->prepare("SELECT f.id,
|
||||
f.title,
|
||||
".SUBSTRING_FOR_DATE."(f.last_updated,1,19) AS last_updated,
|
||||
f.last_error,
|
||||
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
|
||||
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked
|
||||
FROM ttrss_feeds f, ttrss_user_entries ue
|
||||
WHERE f.id = ue.feed_id AND ue.owner_uid = :uid
|
||||
GROUP BY f.id");
|
||||
|
||||
$sth->execute(["uid" => $_SESSION['uid']]);
|
||||
}
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
$id = $line["id"];
|
||||
$last_updated = TimeHelper::make_local_datetime($line['last_updated'], false);
|
||||
|
||||
if (Feeds::_has_icon($id)) {
|
||||
$ts = filemtime(Feeds::_get_icon_file($id));
|
||||
} else {
|
||||
$ts = 0;
|
||||
}
|
||||
|
||||
// hide default un-updated timestamp i.e. 1970-01-01 (?) -fox
|
||||
if ((int)date('Y') - (int)date('Y', strtotime($line['last_updated'] ?? '')) > 2)
|
||||
$last_updated = '';
|
||||
|
||||
$cv = [
|
||||
"id" => $id,
|
||||
"updated" => $last_updated,
|
||||
"counter" => (int) $line["count"],
|
||||
"markedcounter" => (int) $line["count_marked"],
|
||||
"ts" => (int) $ts
|
||||
];
|
||||
|
||||
$cv["error"] = $line["last_error"];
|
||||
$cv["title"] = truncate_string($line["title"], 30);
|
||||
|
||||
array_push($ret, $cv);
|
||||
|
||||
}
|
||||
|
||||
$span->end();
|
||||
|
||||
return $ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, int|string>>
|
||||
*/
|
||||
private static function get_global(): array {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
|
||||
$ret = [
|
||||
[
|
||||
"id" => "global-unread",
|
||||
"counter" => (int) Feeds::_get_global_unread()
|
||||
]
|
||||
];
|
||||
|
||||
$subcribed_feeds = ORM::for_table('ttrss_feeds')
|
||||
->where('owner_uid', $_SESSION['uid'])
|
||||
->count();
|
||||
|
||||
array_push($ret, [
|
||||
"id" => "subscribed-feeds",
|
||||
"counter" => $subcribed_feeds
|
||||
]);
|
||||
|
||||
$span->end();
|
||||
|
||||
return $ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, int|string>>
|
||||
*/
|
||||
private static function get_virt(): array {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
|
||||
$ret = [];
|
||||
|
||||
foreach ([Feeds::FEED_ARCHIVED, Feeds::FEED_STARRED, Feeds::FEED_PUBLISHED,
|
||||
Feeds::FEED_FRESH, Feeds::FEED_ALL] as $feed_id) {
|
||||
|
||||
$count = Feeds::_get_counters($feed_id, false, true);
|
||||
|
||||
if (in_array($feed_id, [Feeds::FEED_ARCHIVED, Feeds::FEED_STARRED, Feeds::FEED_PUBLISHED]))
|
||||
$auxctr = Feeds::_get_counters($feed_id, false);
|
||||
else
|
||||
$auxctr = 0;
|
||||
|
||||
$cv = [
|
||||
"id" => $feed_id,
|
||||
"counter" => (int) $count,
|
||||
"auxcounter" => (int) $auxctr
|
||||
];
|
||||
|
||||
if ($feed_id == Feeds::FEED_STARRED)
|
||||
$cv["markedcounter"] = $auxctr;
|
||||
|
||||
array_push($ret, $cv);
|
||||
}
|
||||
|
||||
$feeds = PluginHost::getInstance()->get_feeds(Feeds::CATEGORY_SPECIAL);
|
||||
|
||||
if (is_array($feeds)) {
|
||||
foreach ($feeds as $feed) {
|
||||
/** @var IVirtualFeed $feed['sender'] */
|
||||
|
||||
if (!implements_interface($feed['sender'], 'IVirtualFeed'))
|
||||
continue;
|
||||
|
||||
$cv = [
|
||||
"id" => PluginHost::pfeed_to_feed_id($feed['id']),
|
||||
"counter" => $feed['sender']->get_unread($feed['id'])
|
||||
];
|
||||
|
||||
if (method_exists($feed['sender'], 'get_total'))
|
||||
$cv["auxcounter"] = $feed['sender']->get_total($feed['id']);
|
||||
|
||||
array_push($ret, $cv);
|
||||
}
|
||||
}
|
||||
|
||||
$span->end();
|
||||
return $ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $label_ids
|
||||
* @return array<int, array<string, int|string>>
|
||||
*/
|
||||
static function get_labels(?array $label_ids = null): array {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
|
||||
$ret = [];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
if (is_array($label_ids)) {
|
||||
if (count($label_ids) == 0)
|
||||
return [];
|
||||
|
||||
$label_ids_qmarks = arr_qmarks($label_ids);
|
||||
|
||||
$sth = $pdo->prepare("SELECT id,
|
||||
caption,
|
||||
SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS count_unread,
|
||||
SUM(CASE WHEN u1.marked = true THEN 1 ELSE 0 END) AS count_marked,
|
||||
COUNT(u1.unread) AS total
|
||||
FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON
|
||||
(ttrss_labels2.id = label_id)
|
||||
LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id AND u1.owner_uid = ?
|
||||
WHERE ttrss_labels2.owner_uid = ? AND ttrss_labels2.id IN ($label_ids_qmarks)
|
||||
GROUP BY ttrss_labels2.id, ttrss_labels2.caption");
|
||||
$sth->execute([$_SESSION["uid"], $_SESSION["uid"], ...$label_ids]);
|
||||
} else {
|
||||
$sth = $pdo->prepare("SELECT id,
|
||||
caption,
|
||||
SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS count_unread,
|
||||
SUM(CASE WHEN u1.marked = true THEN 1 ELSE 0 END) AS count_marked,
|
||||
COUNT(u1.unread) AS total
|
||||
FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON
|
||||
(ttrss_labels2.id = label_id)
|
||||
LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id AND u1.owner_uid = :uid
|
||||
WHERE ttrss_labels2.owner_uid = :uid
|
||||
GROUP BY ttrss_labels2.id, ttrss_labels2.caption");
|
||||
$sth->execute([":uid" => $_SESSION['uid']]);
|
||||
}
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
$id = Labels::label_to_feed_id($line["id"]);
|
||||
|
||||
$cv = [
|
||||
"id" => $id,
|
||||
"counter" => (int) $line["count_unread"],
|
||||
"auxcounter" => (int) $line["total"],
|
||||
"markedcounter" => (int) $line["count_marked"],
|
||||
"description" => $line["caption"]
|
||||
];
|
||||
|
||||
array_push($ret, $cv);
|
||||
}
|
||||
|
||||
$span->end();
|
||||
return $ret;
|
||||
}
|
||||
}
|
||||
102
classes/Db.php
Normal file
102
classes/Db.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
class Db
|
||||
{
|
||||
/** @var Db $instance */
|
||||
private static $instance;
|
||||
|
||||
/** @var PDO|null $pdo */
|
||||
private $pdo;
|
||||
|
||||
function __construct() {
|
||||
ORM::configure(self::get_dsn());
|
||||
ORM::configure('username', Config::get(Config::DB_USER));
|
||||
ORM::configure('password', Config::get(Config::DB_PASS));
|
||||
ORM::configure('return_result_sets', true);
|
||||
if (Config::get(Config::DB_TYPE) == "mysql" && Config::get(Config::MYSQL_CHARSET)) {
|
||||
ORM::configure('driver_options', array(PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES ' . Config::get(Config::MYSQL_CHARSET)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $delta adjust generated timestamp by this value in seconds (either positive or negative)
|
||||
* @return string
|
||||
*/
|
||||
static function NOW(int $delta = 0): string {
|
||||
return date("Y-m-d H:i:s", time() + $delta);
|
||||
}
|
||||
|
||||
private function __clone() {
|
||||
//
|
||||
}
|
||||
|
||||
public static function get_dsn(): string {
|
||||
$db_port = Config::get(Config::DB_PORT) ? ';port=' . Config::get(Config::DB_PORT) : '';
|
||||
$db_host = Config::get(Config::DB_HOST) ? ';host=' . Config::get(Config::DB_HOST) : '';
|
||||
if (Config::get(Config::DB_TYPE) == "mysql" && Config::get(Config::MYSQL_CHARSET)) {
|
||||
$db_charset = ';charset=' . Config::get(Config::MYSQL_CHARSET);
|
||||
} else {
|
||||
$db_charset = '';
|
||||
}
|
||||
|
||||
return Config::get(Config::DB_TYPE) . ':dbname=' . Config::get(Config::DB_NAME) . $db_host . $db_port . $db_charset;
|
||||
}
|
||||
|
||||
// this really shouldn't be used unless a separate PDO connection is needed
|
||||
// normal usage is Db::pdo()->prepare(...) etc
|
||||
public function pdo_connect() : PDO {
|
||||
|
||||
try {
|
||||
$pdo = new PDO(self::get_dsn(),
|
||||
Config::get(Config::DB_USER),
|
||||
Config::get(Config::DB_PASS));
|
||||
} catch (Exception $e) {
|
||||
print "<pre>Exception while creating PDO object:" . $e->getMessage() . "</pre>";
|
||||
exit(101);
|
||||
}
|
||||
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
if (Config::get(Config::DB_TYPE) == "pgsql") {
|
||||
|
||||
$pdo->query("set client_encoding = 'UTF-8'");
|
||||
$pdo->query("set datestyle = 'ISO, european'");
|
||||
$pdo->query("set TIME ZONE 0");
|
||||
$pdo->query("set cpu_tuple_cost = 0.5");
|
||||
|
||||
} else if (Config::get(Config::DB_TYPE) == "mysql") {
|
||||
$pdo->query("SET time_zone = '+0:0'");
|
||||
|
||||
if (Config::get(Config::MYSQL_CHARSET)) {
|
||||
$pdo->query("SET NAMES " . Config::get(Config::MYSQL_CHARSET));
|
||||
}
|
||||
}
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
public static function instance() : Db {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public static function pdo() : PDO {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
if (empty(self::$instance->pdo)) {
|
||||
self::$instance->pdo = self::$instance->pdo_connect();
|
||||
}
|
||||
|
||||
return self::$instance->pdo;
|
||||
}
|
||||
|
||||
public static function sql_random_function(): string {
|
||||
if (Config::get(Config::DB_TYPE) == "mysql") {
|
||||
return "RAND()";
|
||||
}
|
||||
return "RANDOM()";
|
||||
}
|
||||
|
||||
}
|
||||
203
classes/Db_Migrations.php
Normal file
203
classes/Db_Migrations.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
class Db_Migrations {
|
||||
|
||||
private string $base_filename = "schema.sql";
|
||||
private string $base_path;
|
||||
private string $migrations_path;
|
||||
private string $migrations_table;
|
||||
private bool $base_is_latest;
|
||||
private PDO $pdo;
|
||||
private int $cached_version = 0;
|
||||
private int $cached_max_version = 0;
|
||||
private int $max_version_override;
|
||||
|
||||
function __construct() {
|
||||
$this->pdo = Db::pdo();
|
||||
}
|
||||
|
||||
function initialize_for_plugin(Plugin $plugin, bool $base_is_latest = true, string $schema_suffix = "sql"): void {
|
||||
$plugin_dir = PluginHost::getInstance()->get_plugin_dir($plugin);
|
||||
$this->initialize("{$plugin_dir}/{$schema_suffix}",
|
||||
strtolower("ttrss_migrations_plugin_" . get_class($plugin)),
|
||||
$base_is_latest);
|
||||
}
|
||||
|
||||
function initialize(string $root_path, string $migrations_table, bool $base_is_latest = true, int $max_version_override = 0): void {
|
||||
$this->base_path = "$root_path/" . Config::get(Config::DB_TYPE);
|
||||
$this->migrations_path = $this->base_path . "/migrations";
|
||||
$this->migrations_table = $migrations_table;
|
||||
$this->base_is_latest = $base_is_latest;
|
||||
$this->max_version_override = $max_version_override;
|
||||
}
|
||||
|
||||
private function set_version(int $version): void {
|
||||
Debug::log("Updating table {$this->migrations_table} with version {$version}...", Debug::LOG_EXTENDED);
|
||||
|
||||
$sth = $this->pdo->query("SELECT * FROM {$this->migrations_table}");
|
||||
|
||||
if ($sth->fetch()) {
|
||||
$sth = $this->pdo->prepare("UPDATE {$this->migrations_table} SET schema_version = ?");
|
||||
} else {
|
||||
$sth = $this->pdo->prepare("INSERT INTO {$this->migrations_table} (schema_version) VALUES (?)");
|
||||
}
|
||||
|
||||
$sth->execute([$version]);
|
||||
|
||||
$this->cached_version = $version;
|
||||
}
|
||||
|
||||
function get_version() : int {
|
||||
if ($this->cached_version)
|
||||
return $this->cached_version;
|
||||
|
||||
try {
|
||||
$sth = $this->pdo->query("SELECT * FROM {$this->migrations_table}");
|
||||
|
||||
if ($res = $sth->fetch()) {
|
||||
return (int) $res['schema_version'];
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
$this->create_migrations_table();
|
||||
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private function create_migrations_table(): void {
|
||||
$this->pdo->query("CREATE TABLE IF NOT EXISTS {$this->migrations_table} (schema_version integer not null)");
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws PDOException
|
||||
* @return bool false if the migration failed, otherwise true (or an exception)
|
||||
*/
|
||||
private function migrate_to(int $version): bool {
|
||||
try {
|
||||
if ($version <= $this->get_version()) {
|
||||
Debug::log("Refusing to apply version $version: current version is higher", Debug::LOG_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($version == 0)
|
||||
Debug::log("Loading base database schema...", Debug::LOG_VERBOSE);
|
||||
else
|
||||
Debug::log("Starting migration to $version...", Debug::LOG_VERBOSE);
|
||||
|
||||
$lines = $this->get_lines($version);
|
||||
|
||||
if (count($lines) > 0) {
|
||||
// mysql doesn't support transactions for DDL statements
|
||||
if (Config::get(Config::DB_TYPE) != "mysql")
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
foreach ($lines as $line) {
|
||||
Debug::log($line, Debug::LOG_EXTENDED);
|
||||
try {
|
||||
$this->pdo->query($line);
|
||||
} catch (PDOException $e) {
|
||||
Debug::log("Failed on line: $line", Debug::LOG_VERBOSE);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
if ($version == 0 && $this->base_is_latest)
|
||||
$this->set_version($this->get_max_version());
|
||||
else
|
||||
$this->set_version($version);
|
||||
|
||||
if (Config::get(Config::DB_TYPE) != "mysql")
|
||||
$this->pdo->commit();
|
||||
|
||||
Debug::log("Migration finished, current version: " . $this->get_version(), Debug::LOG_VERBOSE);
|
||||
|
||||
Logger::log(E_USER_NOTICE, "Applied migration to version $version for {$this->migrations_table}");
|
||||
return true;
|
||||
} else {
|
||||
Debug::log("Migration failed: schema file is empty or missing.", Debug::LOG_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
|
||||
} catch (PDOException $e) {
|
||||
Debug::log("Migration failed: " . $e->getMessage(), Debug::LOG_VERBOSE);
|
||||
try {
|
||||
$this->pdo->rollback();
|
||||
} catch (PDOException $ie) {
|
||||
//
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
function get_max_version() : int {
|
||||
if ($this->max_version_override > 0)
|
||||
return $this->max_version_override;
|
||||
|
||||
if ($this->cached_max_version)
|
||||
return $this->cached_max_version;
|
||||
|
||||
$migrations = glob("{$this->migrations_path}/*.sql");
|
||||
|
||||
if (count($migrations) > 0) {
|
||||
natsort($migrations);
|
||||
|
||||
$this->cached_max_version = (int) basename(array_pop($migrations), ".sql");
|
||||
|
||||
} else {
|
||||
$this->cached_max_version = 0;
|
||||
}
|
||||
|
||||
return $this->cached_max_version;
|
||||
}
|
||||
|
||||
function is_migration_needed() : bool {
|
||||
return $this->get_version() != $this->get_max_version();
|
||||
}
|
||||
|
||||
function migrate() : bool {
|
||||
|
||||
if ($this->get_version() == -1) {
|
||||
try {
|
||||
$this->migrate_to(0);
|
||||
} catch (PDOException $e) {
|
||||
user_error("Failed to load base schema for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for ($i = $this->get_version() + 1; $i <= $this->get_max_version(); $i++) {
|
||||
try {
|
||||
$this->migrate_to($i);
|
||||
} catch (PDOException $e) {
|
||||
user_error("Failed to apply migration {$i} for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING);
|
||||
return false;
|
||||
//throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
return !$this->is_migration_needed();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function get_lines(int $version) : array {
|
||||
if ($version > 0)
|
||||
$filename = "{$this->migrations_path}/{$version}.sql";
|
||||
else
|
||||
$filename = "{$this->base_path}/{$this->base_filename}";
|
||||
|
||||
if (file_exists($filename)) {
|
||||
$lines = array_filter(preg_split("/[\r\n]/", file_get_contents($filename)),
|
||||
fn($line) => strlen(trim($line)) > 0 && strpos($line, "--") !== 0);
|
||||
|
||||
return array_filter(explode(";", implode("", $lines)),
|
||||
fn($line) => strlen(trim($line)) > 0 && !in_array(strtolower($line), ["begin", "commit"]));
|
||||
|
||||
} else {
|
||||
user_error("Requested schema file {$filename} not found.", E_USER_ERROR);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
18
classes/Db_Prefs.php
Normal file
18
classes/Db_Prefs.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
class Db_Prefs {
|
||||
// this class is a stub for the time being (to be removed)
|
||||
|
||||
/**
|
||||
* @return bool|int|null|string
|
||||
*/
|
||||
function read(string $pref_name, ?int $user_id = null, bool $die_on_error = false) {
|
||||
return get_pref($pref_name, $user_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
function write(string $pref_name, $value, ?int $user_id = null, bool $strip_tags = true): bool {
|
||||
return set_pref($pref_name, $value, $user_id, $strip_tags);
|
||||
}
|
||||
}
|
||||
161
classes/Debug.php
Normal file
161
classes/Debug.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
class Debug {
|
||||
const LOG_DISABLED = -1;
|
||||
const LOG_NORMAL = 0;
|
||||
const LOG_VERBOSE = 1;
|
||||
const LOG_EXTENDED = 2;
|
||||
|
||||
const SEPARATOR = "<-{log-separator}->";
|
||||
|
||||
const ALL_LOG_LEVELS = [
|
||||
Debug::LOG_DISABLED,
|
||||
Debug::LOG_NORMAL,
|
||||
Debug::LOG_VERBOSE,
|
||||
Debug::LOG_EXTENDED,
|
||||
];
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
public static int $LOG_DISABLED = self::LOG_DISABLED;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
public static int $LOG_NORMAL = self::LOG_NORMAL;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
public static int $LOG_VERBOSE = self::LOG_VERBOSE;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
public static int $LOG_EXTENDED = self::LOG_EXTENDED;
|
||||
|
||||
private static bool $enabled = false;
|
||||
private static bool $quiet = false;
|
||||
private static ?string $logfile = null;
|
||||
private static bool $enable_html = false;
|
||||
|
||||
private static int $loglevel = self::LOG_NORMAL;
|
||||
|
||||
public static function set_logfile(string $logfile): void {
|
||||
self::$logfile = $logfile;
|
||||
}
|
||||
|
||||
public static function enabled(): bool {
|
||||
return self::$enabled;
|
||||
}
|
||||
|
||||
public static function set_enabled(bool $enable): void {
|
||||
self::$enabled = $enable;
|
||||
}
|
||||
|
||||
public static function set_quiet(bool $quiet): void {
|
||||
self::$quiet = $quiet;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Debug::LOG_* $level
|
||||
*/
|
||||
public static function set_loglevel(int $level): void {
|
||||
self::$loglevel = $level;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int Debug::LOG_*
|
||||
*/
|
||||
public static function get_loglevel(): int {
|
||||
return self::$loglevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $level integer loglevel value
|
||||
* @return Debug::LOG_* if valid, warn and return LOG_DISABLED otherwise
|
||||
*/
|
||||
public static function map_loglevel(int $level) : int {
|
||||
if (in_array($level, self::ALL_LOG_LEVELS)) {
|
||||
/** @phpstan-ignore-next-line */
|
||||
return $level;
|
||||
} else {
|
||||
user_error("Passed invalid debug log level: $level", E_USER_WARNING);
|
||||
return self::LOG_DISABLED;
|
||||
}
|
||||
}
|
||||
|
||||
public static function enable_html(bool $enable) : void {
|
||||
self::$enable_html = $enable;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Debug::LOG_* $level log level
|
||||
*/
|
||||
public static function log(string $message, int $level = Debug::LOG_NORMAL): bool {
|
||||
|
||||
if (!self::$enabled || self::$loglevel < $level) return false;
|
||||
|
||||
$ts = date("H:i:s", time());
|
||||
if (function_exists('posix_getpid')) {
|
||||
$ts = "$ts/" . posix_getpid();
|
||||
}
|
||||
|
||||
$orig_message = $message;
|
||||
|
||||
if ($message === self::SEPARATOR) {
|
||||
$message = self::$enable_html ? "<hr/>" :
|
||||
"=================================================================================================================================";
|
||||
}
|
||||
|
||||
if (self::$logfile) {
|
||||
$fp = fopen(self::$logfile, 'a+');
|
||||
|
||||
if ($fp) {
|
||||
$locked = false;
|
||||
|
||||
if (function_exists("flock")) {
|
||||
$tries = 0;
|
||||
|
||||
// try to lock logfile for writing
|
||||
while ($tries < 5 && !$locked = flock($fp, LOCK_EX | LOCK_NB)) {
|
||||
sleep(1);
|
||||
++$tries;
|
||||
}
|
||||
|
||||
if (!$locked) {
|
||||
fclose($fp);
|
||||
user_error("Unable to lock debugging log file: " . self::$logfile, E_USER_WARNING);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
fputs($fp, "[$ts] $message\n");
|
||||
|
||||
if (function_exists("flock")) {
|
||||
flock($fp, LOCK_UN);
|
||||
}
|
||||
|
||||
fclose($fp);
|
||||
|
||||
if (self::$quiet)
|
||||
return false;
|
||||
|
||||
} else {
|
||||
user_error("Unable to open debugging log file: " . self::$logfile, E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
|
||||
if (self::$enable_html) {
|
||||
if ($orig_message === self::SEPARATOR) {
|
||||
print "$message\n";
|
||||
} else {
|
||||
print "<span class='log-timestamp'>$ts</span> <span class='log-message'>$message</span>\n";
|
||||
}
|
||||
} else {
|
||||
print "[$ts] $message\n";
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
210
classes/Digest.php
Normal file
210
classes/Digest.php
Normal file
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
class Digest
|
||||
{
|
||||
static function send_headlines_digests(): void {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
|
||||
$user_limit = 15; // amount of users to process (e.g. emails to send out)
|
||||
$limit = 1000; // maximum amount of headlines to include
|
||||
|
||||
Debug::log("Sending digests, batch of max $user_limit users, headline limit = $limit");
|
||||
|
||||
if (Config::get(Config::DB_TYPE) == "pgsql") {
|
||||
$interval_qpart = "last_digest_sent < NOW() - INTERVAL '1 days'";
|
||||
} else /* if (Config::get(Config::DB_TYPE) == "mysql") */ {
|
||||
$interval_qpart = "last_digest_sent < DATE_SUB(NOW(), INTERVAL 1 DAY)";
|
||||
}
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$res = $pdo->query("SELECT id, login, email FROM ttrss_users
|
||||
WHERE email != '' AND (last_digest_sent IS NULL OR $interval_qpart)");
|
||||
|
||||
while ($line = $res->fetch()) {
|
||||
|
||||
if (get_pref(Prefs::DIGEST_ENABLE, $line['id'])) {
|
||||
$preferred_ts = strtotime(get_pref(Prefs::DIGEST_PREFERRED_TIME, $line['id']) ?? '');
|
||||
|
||||
// try to send digests within 2 hours of preferred time
|
||||
if ($preferred_ts && time() >= $preferred_ts &&
|
||||
time() - $preferred_ts <= 7200
|
||||
) {
|
||||
|
||||
Debug::log("Sending digest for UID:" . $line['id'] . " - " . $line["email"]);
|
||||
|
||||
$do_catchup = get_pref(Prefs::DIGEST_CATCHUP, $line['id']);
|
||||
|
||||
global $tz_offset;
|
||||
|
||||
// reset tz_offset global to prevent tz cache clash between users
|
||||
$tz_offset = -1;
|
||||
|
||||
$tuple = Digest::prepare_headlines_digest($line["id"], 1, $limit);
|
||||
$digest = $tuple[0];
|
||||
$headlines_count = $tuple[1];
|
||||
$affected_ids = $tuple[2];
|
||||
$digest_text = $tuple[3];
|
||||
|
||||
if ($headlines_count > 0) {
|
||||
|
||||
$mailer = new Mailer();
|
||||
|
||||
//$rc = $mail->quickMail($line["email"], $line["login"], Config::get(Config::DIGEST_SUBJECT), $digest, $digest_text);
|
||||
|
||||
$rc = $mailer->mail(["to_name" => $line["login"],
|
||||
"to_address" => $line["email"],
|
||||
"subject" => Config::get(Config::DIGEST_SUBJECT),
|
||||
"message" => $digest_text,
|
||||
"message_html" => $digest]);
|
||||
|
||||
//if (!$rc && $debug) Debug::log("ERROR: " . $mailer->lastError());
|
||||
|
||||
Debug::log("RC=$rc");
|
||||
|
||||
if ($rc && $do_catchup) {
|
||||
Debug::log("Marking affected articles as read...");
|
||||
Article::_catchup_by_id($affected_ids, Article::CATCHUP_MODE_MARK_AS_READ, $line["id"]);
|
||||
}
|
||||
} else {
|
||||
Debug::log("No headlines");
|
||||
}
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_users SET last_digest_sent = NOW()
|
||||
WHERE id = ?");
|
||||
$sth->execute([$line["id"]]);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$span->end();
|
||||
Debug::log("All done.");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: int, 2: array<int>, 3: string}
|
||||
*/
|
||||
static function prepare_headlines_digest(int $user_id, int $days = 1, int $limit = 1000) {
|
||||
|
||||
$tpl = new Templator();
|
||||
$tpl_t = new Templator();
|
||||
|
||||
$tpl->readTemplateFromFile("digest_template_html.txt");
|
||||
$tpl_t->readTemplateFromFile("digest_template.txt");
|
||||
|
||||
$user_tz_string = Prefs::get(Prefs::USER_TIMEZONE, $user_id);
|
||||
$min_score = Prefs::get(Prefs::DIGEST_MIN_SCORE, $user_id);
|
||||
|
||||
if ($user_tz_string == 'Automatic')
|
||||
$user_tz_string = 'GMT';
|
||||
|
||||
$local_ts = TimeHelper::convert_timestamp(time(), 'UTC', $user_tz_string);
|
||||
|
||||
$tpl->setVariable('CUR_DATE', date('Y/m/d', $local_ts));
|
||||
$tpl->setVariable('CUR_TIME', date('G:i', $local_ts));
|
||||
$tpl->setVariable('TTRSS_HOST', Config::get_self_url());
|
||||
|
||||
$tpl_t->setVariable('CUR_DATE', date('Y/m/d', $local_ts));
|
||||
$tpl_t->setVariable('CUR_TIME', date('G:i', $local_ts));
|
||||
$tpl_t->setVariable('TTRSS_HOST', Config::get_self_url());
|
||||
|
||||
$affected_ids = array();
|
||||
|
||||
if (Config::get(Config::DB_TYPE) == "pgsql") {
|
||||
$interval_qpart = "ttrss_entries.date_updated > NOW() - INTERVAL '$days days'";
|
||||
} else /* if (Config::get(Config::DB_TYPE) == "mysql") */ {
|
||||
$interval_qpart = "ttrss_entries.date_updated > DATE_SUB(NOW(), INTERVAL $days DAY)";
|
||||
}
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT ttrss_entries.title,
|
||||
ttrss_feeds.title AS feed_title,
|
||||
COALESCE(ttrss_feed_categories.title, '" . __('Uncategorized') . "') AS cat_title,
|
||||
date_updated,
|
||||
ttrss_user_entries.ref_id,
|
||||
link,
|
||||
score,
|
||||
content,
|
||||
".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated
|
||||
FROM
|
||||
ttrss_user_entries,ttrss_entries,ttrss_feeds
|
||||
LEFT JOIN
|
||||
ttrss_feed_categories ON (cat_id = ttrss_feed_categories.id)
|
||||
WHERE
|
||||
ref_id = ttrss_entries.id AND feed_id = ttrss_feeds.id
|
||||
AND include_in_digest = true
|
||||
AND $interval_qpart
|
||||
AND ttrss_user_entries.owner_uid = :user_id
|
||||
AND unread = true
|
||||
AND score >= :min_score
|
||||
ORDER BY ttrss_feed_categories.title, ttrss_feeds.title, score DESC, date_updated DESC
|
||||
LIMIT " . (int)$limit);
|
||||
$sth->execute([':user_id' => $user_id, ':min_score' => $min_score]);
|
||||
|
||||
$headlines_count = 0;
|
||||
$headlines = array();
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
array_push($headlines, $line);
|
||||
$headlines_count++;
|
||||
}
|
||||
|
||||
for ($i = 0; $i < sizeof($headlines); $i++) {
|
||||
|
||||
$line = $headlines[$i];
|
||||
|
||||
array_push($affected_ids, $line["ref_id"]);
|
||||
|
||||
$updated = TimeHelper::make_local_datetime($line['last_updated'], false,
|
||||
$user_id);
|
||||
|
||||
if (get_pref(Prefs::ENABLE_FEED_CATS, $user_id)) {
|
||||
$line['feed_title'] = $line['cat_title'] . " / " . $line['feed_title'];
|
||||
}
|
||||
|
||||
$article_labels = Article::_get_labels($line["ref_id"], $user_id);
|
||||
$article_labels_formatted = "";
|
||||
|
||||
if (is_array($article_labels) && count($article_labels) > 0) {
|
||||
$article_labels_formatted = implode(", ", array_map(fn($a) => $a[1], $article_labels));
|
||||
}
|
||||
|
||||
$tpl->setVariable('FEED_TITLE', $line["feed_title"]);
|
||||
$tpl->setVariable('ARTICLE_TITLE', $line["title"]);
|
||||
$tpl->setVariable('ARTICLE_LINK', $line["link"]);
|
||||
$tpl->setVariable('ARTICLE_UPDATED', $updated);
|
||||
$tpl->setVariable('ARTICLE_EXCERPT',
|
||||
truncate_string(strip_tags($line["content"]), 300));
|
||||
// $tpl->setVariable('ARTICLE_CONTENT',
|
||||
// strip_tags($article_content));
|
||||
$tpl->setVariable('ARTICLE_LABELS', $article_labels_formatted, true);
|
||||
|
||||
$tpl->addBlock('article');
|
||||
|
||||
$tpl_t->setVariable('FEED_TITLE', $line["feed_title"]);
|
||||
$tpl_t->setVariable('ARTICLE_TITLE', $line["title"]);
|
||||
$tpl_t->setVariable('ARTICLE_LINK', $line["link"]);
|
||||
$tpl_t->setVariable('ARTICLE_UPDATED', $updated);
|
||||
$tpl_t->setVariable('ARTICLE_LABELS', $article_labels_formatted, true);
|
||||
$tpl_t->setVariable('ARTICLE_EXCERPT',
|
||||
truncate_string(strip_tags($line["content"]), 300, "..."), true);
|
||||
|
||||
$tpl_t->addBlock('article');
|
||||
|
||||
if (!isset($headlines[$i + 1]) || $headlines[$i]['feed_title'] != $headlines[$i + 1]['feed_title']) {
|
||||
$tpl->addBlock('feed');
|
||||
$tpl_t->addBlock('feed');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$tpl->addBlock('digest');
|
||||
$tpl->generateOutputToString($tmp);
|
||||
|
||||
$tpl_t->addBlock('digest');
|
||||
$tpl_t->generateOutputToString($tmp_t);
|
||||
|
||||
return array($tmp, $headlines_count, $affected_ids, $tmp_t);
|
||||
}
|
||||
}
|
||||
492
classes/DiskCache.php
Normal file
492
classes/DiskCache.php
Normal file
@@ -0,0 +1,492 @@
|
||||
<?php
|
||||
class DiskCache implements Cache_Adapter {
|
||||
/** @var Cache_Adapter $adapter */
|
||||
private $adapter;
|
||||
|
||||
/** @var array<string, DiskCache> $instances */
|
||||
private static $instances = [];
|
||||
|
||||
/**
|
||||
* https://stackoverflow.com/a/53662733
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private array $mimeMap = [
|
||||
'video/3gpp2' => '3g2',
|
||||
'video/3gp' => '3gp',
|
||||
'video/3gpp' => '3gp',
|
||||
'application/x-compressed' => '7zip',
|
||||
'audio/x-acc' => 'aac',
|
||||
'audio/ac3' => 'ac3',
|
||||
'application/postscript' => 'ai',
|
||||
'audio/x-aiff' => 'aif',
|
||||
'audio/aiff' => 'aif',
|
||||
'audio/x-au' => 'au',
|
||||
'video/x-msvideo' => 'avi',
|
||||
'video/msvideo' => 'avi',
|
||||
'video/avi' => 'avi',
|
||||
'application/x-troff-msvideo' => 'avi',
|
||||
'application/macbinary' => 'bin',
|
||||
'application/mac-binary' => 'bin',
|
||||
'application/x-binary' => 'bin',
|
||||
'application/x-macbinary' => 'bin',
|
||||
'image/bmp' => 'bmp',
|
||||
'image/x-bmp' => 'bmp',
|
||||
'image/x-bitmap' => 'bmp',
|
||||
'image/x-xbitmap' => 'bmp',
|
||||
'image/x-win-bitmap' => 'bmp',
|
||||
'image/x-windows-bmp' => 'bmp',
|
||||
'image/ms-bmp' => 'bmp',
|
||||
'image/x-ms-bmp' => 'bmp',
|
||||
'application/bmp' => 'bmp',
|
||||
'application/x-bmp' => 'bmp',
|
||||
'application/x-win-bitmap' => 'bmp',
|
||||
'application/cdr' => 'cdr',
|
||||
'application/coreldraw' => 'cdr',
|
||||
'application/x-cdr' => 'cdr',
|
||||
'application/x-coreldraw' => 'cdr',
|
||||
'image/cdr' => 'cdr',
|
||||
'image/x-cdr' => 'cdr',
|
||||
'zz-application/zz-winassoc-cdr' => 'cdr',
|
||||
'application/mac-compactpro' => 'cpt',
|
||||
'application/pkix-crl' => 'crl',
|
||||
'application/pkcs-crl' => 'crl',
|
||||
'application/x-x509-ca-cert' => 'crt',
|
||||
'application/pkix-cert' => 'crt',
|
||||
'text/css' => 'css',
|
||||
'text/x-comma-separated-values' => 'csv',
|
||||
'text/comma-separated-values' => 'csv',
|
||||
'application/vnd.msexcel' => 'csv',
|
||||
'application/x-director' => 'dcr',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
||||
'application/x-dvi' => 'dvi',
|
||||
'message/rfc822' => 'eml',
|
||||
'application/x-msdownload' => 'exe',
|
||||
'video/x-f4v' => 'f4v',
|
||||
'audio/x-flac' => 'flac',
|
||||
'video/x-flv' => 'flv',
|
||||
'image/gif' => 'gif',
|
||||
'application/gpg-keys' => 'gpg',
|
||||
'application/x-gtar' => 'gtar',
|
||||
'application/x-gzip' => 'gzip',
|
||||
'application/mac-binhex40' => 'hqx',
|
||||
'application/mac-binhex' => 'hqx',
|
||||
'application/x-binhex40' => 'hqx',
|
||||
'application/x-mac-binhex40' => 'hqx',
|
||||
'text/html' => 'html',
|
||||
'image/x-icon' => 'ico',
|
||||
'image/x-ico' => 'ico',
|
||||
'image/vnd.microsoft.icon' => 'ico',
|
||||
'text/calendar' => 'ics',
|
||||
'application/java-archive' => 'jar',
|
||||
'application/x-java-application' => 'jar',
|
||||
'application/x-jar' => 'jar',
|
||||
'image/jp2' => 'jp2',
|
||||
'video/mj2' => 'jp2',
|
||||
'image/jpx' => 'jp2',
|
||||
'image/jpm' => 'jp2',
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/pjpeg' => 'jpg',
|
||||
'application/x-javascript' => 'js',
|
||||
'application/json' => 'json',
|
||||
'text/json' => 'json',
|
||||
'application/vnd.google-earth.kml+xml' => 'kml',
|
||||
'application/vnd.google-earth.kmz' => 'kmz',
|
||||
'text/x-log' => 'log',
|
||||
'audio/x-m4a' => 'm4a',
|
||||
'audio/mp4' => 'm4a',
|
||||
'application/vnd.mpegurl' => 'm4u',
|
||||
'audio/midi' => 'mid',
|
||||
'application/vnd.mif' => 'mif',
|
||||
'video/quicktime' => 'mov',
|
||||
'video/x-sgi-movie' => 'movie',
|
||||
'audio/mpeg' => 'mp3',
|
||||
'audio/mpg' => 'mp3',
|
||||
'audio/mpeg3' => 'mp3',
|
||||
'audio/mp3' => 'mp3',
|
||||
'video/mp4' => 'mp4',
|
||||
'video/mpeg' => 'mpeg',
|
||||
'application/oda' => 'oda',
|
||||
'audio/ogg' => 'ogg',
|
||||
'video/ogg' => 'ogg',
|
||||
'application/ogg' => 'ogg',
|
||||
'font/otf' => 'otf',
|
||||
'application/x-pkcs10' => 'p10',
|
||||
'application/pkcs10' => 'p10',
|
||||
'application/x-pkcs12' => 'p12',
|
||||
'application/x-pkcs7-signature' => 'p7a',
|
||||
'application/pkcs7-mime' => 'p7c',
|
||||
'application/x-pkcs7-mime' => 'p7c',
|
||||
'application/x-pkcs7-certreqresp' => 'p7r',
|
||||
'application/pkcs7-signature' => 'p7s',
|
||||
'application/pdf' => 'pdf',
|
||||
'application/octet-stream' => 'pdf',
|
||||
'application/x-x509-user-cert' => 'pem',
|
||||
'application/x-pem-file' => 'pem',
|
||||
'application/pgp' => 'pgp',
|
||||
'application/x-httpd-php' => 'php',
|
||||
'application/php' => 'php',
|
||||
'application/x-php' => 'php',
|
||||
'text/php' => 'php',
|
||||
'text/x-php' => 'php',
|
||||
'application/x-httpd-php-source' => 'php',
|
||||
'image/png' => 'png',
|
||||
'image/x-png' => 'png',
|
||||
'application/powerpoint' => 'ppt',
|
||||
'application/vnd.ms-powerpoint' => 'ppt',
|
||||
'application/vnd.ms-office' => 'ppt',
|
||||
'application/msword' => 'ppt',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
|
||||
'application/x-photoshop' => 'psd',
|
||||
'image/vnd.adobe.photoshop' => 'psd',
|
||||
'audio/x-realaudio' => 'ra',
|
||||
'audio/x-pn-realaudio' => 'ram',
|
||||
'application/x-rar' => 'rar',
|
||||
'application/rar' => 'rar',
|
||||
'application/x-rar-compressed' => 'rar',
|
||||
'audio/x-pn-realaudio-plugin' => 'rpm',
|
||||
'application/x-pkcs7' => 'rsa',
|
||||
'text/rtf' => 'rtf',
|
||||
'text/richtext' => 'rtx',
|
||||
'video/vnd.rn-realvideo' => 'rv',
|
||||
'application/x-stuffit' => 'sit',
|
||||
'application/smil' => 'smil',
|
||||
'text/srt' => 'srt',
|
||||
'image/svg+xml' => 'svg',
|
||||
'application/x-shockwave-flash' => 'swf',
|
||||
'application/x-tar' => 'tar',
|
||||
'application/x-gzip-compressed' => 'tgz',
|
||||
'image/tiff' => 'tiff',
|
||||
'font/ttf' => 'ttf',
|
||||
'text/plain' => 'txt',
|
||||
'text/x-vcard' => 'vcf',
|
||||
'application/videolan' => 'vlc',
|
||||
'text/vtt' => 'vtt',
|
||||
'audio/x-wav' => 'wav',
|
||||
'audio/wave' => 'wav',
|
||||
'audio/wav' => 'wav',
|
||||
'application/wbxml' => 'wbxml',
|
||||
'video/webm' => 'webm',
|
||||
'image/webp' => 'webp',
|
||||
'audio/x-ms-wma' => 'wma',
|
||||
'application/wmlc' => 'wmlc',
|
||||
'video/x-ms-wmv' => 'wmv',
|
||||
'video/x-ms-asf' => 'wmv',
|
||||
'font/woff' => 'woff',
|
||||
'font/woff2' => 'woff2',
|
||||
'application/xhtml+xml' => 'xhtml',
|
||||
'application/excel' => 'xl',
|
||||
'application/msexcel' => 'xls',
|
||||
'application/x-msexcel' => 'xls',
|
||||
'application/x-ms-excel' => 'xls',
|
||||
'application/x-excel' => 'xls',
|
||||
'application/x-dos_ms_excel' => 'xls',
|
||||
'application/xls' => 'xls',
|
||||
'application/x-xls' => 'xls',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
|
||||
'application/vnd.ms-excel' => 'xlsx',
|
||||
'application/xml' => 'xml',
|
||||
'text/xml' => 'xml',
|
||||
'text/xsl' => 'xsl',
|
||||
'application/xspf+xml' => 'xspf',
|
||||
'application/x-compress' => 'z',
|
||||
'application/x-zip' => 'zip',
|
||||
'application/zip' => 'zip',
|
||||
'application/x-zip-compressed' => 'zip',
|
||||
'application/s-compressed' => 'zip',
|
||||
'multipart/x-zip' => 'zip',
|
||||
'text/x-scriptzsh' => 'zsh'
|
||||
];
|
||||
|
||||
public static function instance(string $dir) : DiskCache {
|
||||
if ((self::$instances[$dir] ?? null) == null)
|
||||
self::$instances[$dir] = new self($dir);
|
||||
|
||||
return self::$instances[$dir];
|
||||
}
|
||||
|
||||
public function __construct(string $dir) {
|
||||
foreach (PluginHost::getInstance()->get_plugins() as $p) {
|
||||
if (implements_interface($p, "Cache_Adapter")) {
|
||||
|
||||
/** @var Cache_Adapter $p */
|
||||
$this->adapter = clone $p; // we need separate object instances for separate directories
|
||||
$this->adapter->set_dir($dir);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$this->adapter = new Cache_Local();
|
||||
$this->adapter->set_dir($dir);
|
||||
}
|
||||
|
||||
public function remove(string $filename): bool {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
$span->setAttribute('file.name', $filename);
|
||||
|
||||
$rc = $this->adapter->remove($filename);
|
||||
$span->end();
|
||||
|
||||
return $rc;
|
||||
}
|
||||
|
||||
public function set_dir(string $dir) : void {
|
||||
$this->adapter->set_dir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|false -1 if the file doesn't exist, false if an error occurred, timestamp otherwise
|
||||
*/
|
||||
public function get_mtime(string $filename) {
|
||||
return $this->adapter->get_mtime(basename($filename));
|
||||
}
|
||||
|
||||
public function make_dir(): bool {
|
||||
return $this->adapter->make_dir();
|
||||
}
|
||||
|
||||
/** @param string|null $filename null means check that cache directory itself is writable */
|
||||
public function is_writable(?string $filename = null): bool {
|
||||
return $this->adapter->is_writable($filename ? basename($filename) : null);
|
||||
}
|
||||
|
||||
public function exists(string $filename): bool {
|
||||
$span = OpenTelemetry\API\Trace\Span::getCurrent();
|
||||
$span->addEvent("DiskCache::exists: $filename");
|
||||
|
||||
$rc = $this->adapter->exists(basename($filename));
|
||||
|
||||
return $rc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|false -1 if the file doesn't exist, false if an error occurred, size in bytes otherwise
|
||||
*/
|
||||
public function get_size(string $filename) {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
$span->setAttribute('file.name', $filename);
|
||||
|
||||
$rc = $this->adapter->get_size(basename($filename));
|
||||
$span->end();
|
||||
|
||||
return $rc;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
*
|
||||
* @return int|false Bytes written or false if an error occurred.
|
||||
*/
|
||||
public function put(string $filename, $data) {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
$rc = $this->adapter->put(basename($filename), $data);
|
||||
$span->end();
|
||||
|
||||
return $rc;
|
||||
}
|
||||
|
||||
/** @deprecated we can't assume cached files are local, and other storages
|
||||
* might not support this operation (object metadata may be immutable) */
|
||||
public function touch(string $filename): bool {
|
||||
user_error("DiskCache: called unsupported method touch() for $filename", E_USER_DEPRECATED);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function get(string $filename): ?string {
|
||||
return $this->adapter->get(basename($filename));
|
||||
}
|
||||
|
||||
public function expire_all(): void {
|
||||
$this->adapter->expire_all();
|
||||
}
|
||||
|
||||
public function get_dir(): string {
|
||||
return $this->adapter->get_dir();
|
||||
}
|
||||
|
||||
/** Downloads $url to cache as $local_filename if its missing (unless $force-ed)
|
||||
* @param string $url
|
||||
* @param string $local_filename
|
||||
* @param array<string,string|int|false> $options (additional params to UrlHelper::fetch())
|
||||
* @param bool $force
|
||||
* @return bool
|
||||
*/
|
||||
public function download(string $url, string $local_filename, array $options = [], bool $force = false) : bool {
|
||||
if ($this->exists($local_filename) && !$force)
|
||||
return true;
|
||||
|
||||
$data = UrlHelper::fetch(array_merge(["url" => $url,
|
||||
"max_size" => Config::get(Config::MAX_CACHE_FILE_SIZE)], $options));
|
||||
|
||||
if ($data)
|
||||
return $this->put($local_filename, $data) > 0;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function send(string $filename) {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
$span->setAttribute('file.name', $filename);
|
||||
|
||||
$filename = basename($filename);
|
||||
|
||||
if (!$this->exists($filename)) {
|
||||
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
|
||||
echo "File not found.";
|
||||
|
||||
$span->setAttribute('error', '404 not found');
|
||||
$span->end();
|
||||
return false;
|
||||
}
|
||||
|
||||
$file_mtime = $this->get_mtime($filename);
|
||||
$gmt_modified = gmdate("D, d M Y H:i:s", (int)$file_mtime) . " GMT";
|
||||
|
||||
if (($_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? '') == $gmt_modified || ($_SERVER['HTTP_IF_NONE_MATCH'] ?? '') == $file_mtime) {
|
||||
header('HTTP/1.1 304 Not Modified');
|
||||
|
||||
$span->setAttribute('error', '304 not modified');
|
||||
$span->end();
|
||||
return false;
|
||||
}
|
||||
|
||||
$mimetype = $this->get_mime_type($filename);
|
||||
|
||||
if ($mimetype == "application/octet-stream")
|
||||
$mimetype = "video/mp4";
|
||||
|
||||
# block SVG because of possible embedded javascript (.....)
|
||||
$mimetype_blacklist = [ "image/svg+xml" ];
|
||||
|
||||
/* only serve video and images */
|
||||
if (!preg_match("/(image|audio|video)\//", (string)$mimetype) || in_array($mimetype, $mimetype_blacklist)) {
|
||||
http_response_code(400);
|
||||
header("Content-type: text/plain");
|
||||
|
||||
print "Stored file has disallowed content type ($mimetype)";
|
||||
|
||||
$span->setAttribute('error', '400 disallowed content type');
|
||||
$span->end();
|
||||
return false;
|
||||
}
|
||||
|
||||
$fake_extension = $this->get_fake_extension($filename);
|
||||
|
||||
if ($fake_extension)
|
||||
$fake_extension = ".$fake_extension";
|
||||
|
||||
header("Content-Disposition: inline; filename=\"{$filename}{$fake_extension}\"");
|
||||
header("Content-type: $mimetype");
|
||||
|
||||
$stamp_expires = gmdate("D, d M Y H:i:s",
|
||||
(int)$this->get_mtime($filename) + 86400 * Config::get(Config::CACHE_MAX_DAYS)) . " GMT";
|
||||
|
||||
header("Expires: $stamp_expires", true);
|
||||
header("Last-Modified: $gmt_modified", true);
|
||||
header("Cache-Control: no-cache");
|
||||
header("ETag: $file_mtime");
|
||||
|
||||
header_remove("Pragma");
|
||||
|
||||
$span->setAttribute('mimetype', $mimetype);
|
||||
|
||||
$rc = $this->adapter->send($filename);
|
||||
|
||||
$span->end();
|
||||
|
||||
return $rc;
|
||||
}
|
||||
|
||||
public function get_full_path(string $filename): string {
|
||||
return $this->adapter->get_full_path(basename($filename));
|
||||
}
|
||||
|
||||
public function get_mime_type(string $filename) {
|
||||
return $this->adapter->get_mime_type(basename($filename));
|
||||
}
|
||||
|
||||
public function get_fake_extension(string $filename): string {
|
||||
$mimetype = $this->adapter->get_mime_type(basename($filename));
|
||||
|
||||
if ($mimetype)
|
||||
return $this->mimeMap[$mimetype] ?? "";
|
||||
else
|
||||
return "";
|
||||
}
|
||||
|
||||
public function get_url(string $filename): string {
|
||||
return Config::get_self_url() . "/public.php?op=cached&file=" . basename($this->adapter->get_dir()) . "/" . basename($filename);
|
||||
}
|
||||
|
||||
// check for locally cached (media) URLs and rewrite to local versions
|
||||
// this is called separately after sanitize() and plugin render article hooks to allow
|
||||
// plugins work on original source URLs used before caching
|
||||
// NOTE: URLs should be already absolutized because this is called after sanitize()
|
||||
static public function rewrite_urls(string $str): string {
|
||||
$span = OpenTelemetry\API\Trace\Span::getCurrent();
|
||||
$span->addEvent("DiskCache::rewrite_urls");
|
||||
|
||||
$res = trim($str);
|
||||
|
||||
if (!$res) {
|
||||
$span->end();
|
||||
return '';
|
||||
}
|
||||
|
||||
$doc = new DOMDocument();
|
||||
if (@$doc->loadHTML('<?xml encoding="UTF-8">' . $res)) {
|
||||
$xpath = new DOMXPath($doc);
|
||||
$cache = DiskCache::instance("images");
|
||||
|
||||
$entries = $xpath->query('(//img[@src]|//source[@src|@srcset]|//video[@poster|@src])');
|
||||
|
||||
$need_saving = false;
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$span->addEvent("entry: " . $entry->tagName);
|
||||
|
||||
foreach (array('src', 'poster') as $attr) {
|
||||
if ($entry->hasAttribute($attr)) {
|
||||
$url = $entry->getAttribute($attr);
|
||||
$cached_filename = sha1($url);
|
||||
|
||||
if ($cache->exists($cached_filename)) {
|
||||
$url = $cache->get_url($cached_filename);
|
||||
|
||||
$entry->setAttribute($attr, $url);
|
||||
$entry->removeAttribute("srcset");
|
||||
|
||||
$need_saving = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($entry->hasAttribute("srcset")) {
|
||||
$matches = RSSUtils::decode_srcset($entry->getAttribute('srcset'));
|
||||
|
||||
for ($i = 0; $i < count($matches); $i++) {
|
||||
$cached_filename = sha1($matches[$i]["url"]);
|
||||
|
||||
if ($cache->exists($cached_filename)) {
|
||||
$matches[$i]["url"] = $cache->get_url($cached_filename);
|
||||
|
||||
$need_saving = true;
|
||||
}
|
||||
}
|
||||
|
||||
$entry->setAttribute("srcset", RSSUtils::encode_srcset($matches));
|
||||
}
|
||||
}
|
||||
|
||||
if ($need_saving) {
|
||||
if (isset($doc->firstChild))
|
||||
$doc->removeChild($doc->firstChild); //remove doctype
|
||||
|
||||
$res = $doc->saveHTML();
|
||||
}
|
||||
}
|
||||
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
40
classes/Errors.php
Normal file
40
classes/Errors.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
class Errors {
|
||||
const E_SUCCESS = "E_SUCCESS";
|
||||
const E_UNAUTHORIZED = "E_UNAUTHORIZED";
|
||||
const E_UNKNOWN_METHOD = "E_UNKNOWN_METHOD";
|
||||
const E_UNKNOWN_PLUGIN = "E_UNKNOWN_PLUGIN";
|
||||
const E_SCHEMA_MISMATCH = "E_SCHEMA_MISMATCH";
|
||||
const E_URL_SCHEME_MISMATCH = "E_URL_SCHEME_MISMATCH";
|
||||
|
||||
/**
|
||||
* @param Errors::E_* $code
|
||||
* @param array<string, string> $params
|
||||
*/
|
||||
static function to_json(string $code, array $params = []): string {
|
||||
return json_encode(["error" => ["code" => $code, "params" => $params]]);
|
||||
}
|
||||
|
||||
static function libxml_last_error() : string {
|
||||
$error = libxml_get_last_error();
|
||||
$error_formatted = "";
|
||||
|
||||
if ($error) {
|
||||
foreach (libxml_get_errors() as $error) {
|
||||
if ($error->level == LIBXML_ERR_FATAL) {
|
||||
// currently only the first error is reported
|
||||
$error_formatted = self::format_libxml_error($error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return UConverter::transcode($error_formatted, 'UTF-8', 'UTF-8');
|
||||
}
|
||||
|
||||
static function format_libxml_error(LibXMLError $error) : string {
|
||||
return sprintf("LibXML error %s at line %d (column %d): %s",
|
||||
$error->code, $error->line, $error->column,
|
||||
$error->message);
|
||||
}
|
||||
}
|
||||
21
classes/FeedEnclosure.php
Normal file
21
classes/FeedEnclosure.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
class FeedEnclosure {
|
||||
/** @var string */
|
||||
public $link;
|
||||
|
||||
/** @var string */
|
||||
public $type;
|
||||
|
||||
/** @var string */
|
||||
public $length;
|
||||
|
||||
/** @var string */
|
||||
public $title;
|
||||
|
||||
/** @var string */
|
||||
public $height;
|
||||
|
||||
/** @var string */
|
||||
public $width;
|
||||
}
|
||||
|
||||
24
classes/FeedItem.php
Normal file
24
classes/FeedItem.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
abstract class FeedItem {
|
||||
abstract function get_id(): string;
|
||||
|
||||
/** @return int|false a timestamp on success, false otherwise */
|
||||
abstract function get_date();
|
||||
|
||||
abstract function get_link(): string;
|
||||
abstract function get_title(): string;
|
||||
abstract function get_description(): string;
|
||||
abstract function get_content(): string;
|
||||
abstract function get_comments_url(): string;
|
||||
abstract function get_comments_count(): int;
|
||||
|
||||
/** @return array<int, string> */
|
||||
abstract function get_categories(): array;
|
||||
|
||||
/** @return array<int, FeedEnclosure> */
|
||||
abstract function get_enclosures(): array;
|
||||
|
||||
abstract function get_author(): string;
|
||||
abstract function get_language(): string;
|
||||
}
|
||||
|
||||
224
classes/FeedItem_Atom.php
Normal file
224
classes/FeedItem_Atom.php
Normal file
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
class FeedItem_Atom extends FeedItem_Common {
|
||||
const NS_XML = "http://www.w3.org/XML/1998/namespace";
|
||||
|
||||
function get_id(): string {
|
||||
$id = $this->elem->getElementsByTagName("id")->item(0);
|
||||
|
||||
if ($id) {
|
||||
return $id->nodeValue;
|
||||
} else {
|
||||
return clean($this->get_link());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|false a timestamp on success, false otherwise
|
||||
*/
|
||||
function get_date() {
|
||||
$updated = $this->elem->getElementsByTagName("updated")->item(0);
|
||||
|
||||
if ($updated) {
|
||||
return strtotime($updated->nodeValue ?? '');
|
||||
}
|
||||
|
||||
$published = $this->elem->getElementsByTagName("published")->item(0);
|
||||
|
||||
if ($published) {
|
||||
return strtotime($published->nodeValue ?? '');
|
||||
}
|
||||
|
||||
$date = $this->xpath->query("dc:date", $this->elem)->item(0);
|
||||
|
||||
if ($date) {
|
||||
return strtotime($date->nodeValue ?? '');
|
||||
}
|
||||
|
||||
// consistent with strtotime failing to parse
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
function get_link(): string {
|
||||
$links = $this->elem->getElementsByTagName("link");
|
||||
|
||||
foreach ($links as $link) {
|
||||
/** @phpstan-ignore-next-line */
|
||||
if ($link->hasAttribute("href") &&
|
||||
(!$link->hasAttribute("rel")
|
||||
|| $link->getAttribute("rel") == "alternate"
|
||||
|| $link->getAttribute("rel") == "standout")) {
|
||||
$base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $link);
|
||||
|
||||
if ($base)
|
||||
return UrlHelper::rewrite_relative($base, clean(trim($link->getAttribute("href"))));
|
||||
else
|
||||
return clean(trim($link->getAttribute("href")));
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function get_title(): string {
|
||||
$title = $this->elem->getElementsByTagName("title")->item(0);
|
||||
return $title ? clean(trim($title->nodeValue)) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $base optional (returns $content if $base is null)
|
||||
* @param string $content an HTML string
|
||||
*
|
||||
* @return string the rewritten XML or original $content
|
||||
*/
|
||||
private function rewrite_content_to_base(?string $base = null, ?string $content = '') {
|
||||
|
||||
if (!empty($base) && !empty($content)) {
|
||||
|
||||
$tmpdoc = new DOMDocument();
|
||||
if (@$tmpdoc->loadHTML('<?xml encoding="UTF-8">' . $content)) {
|
||||
$tmpxpath = new DOMXPath($tmpdoc);
|
||||
|
||||
$elems = $tmpxpath->query("(//*[@href]|//*[@src])");
|
||||
|
||||
foreach ($elems as $elem) {
|
||||
if ($elem->hasAttribute("href")) {
|
||||
$elem->setAttribute("href",
|
||||
UrlHelper::rewrite_relative($base, $elem->getAttribute("href")));
|
||||
} else if ($elem->hasAttribute("src")) {
|
||||
$elem->setAttribute("src",
|
||||
UrlHelper::rewrite_relative($base, $elem->getAttribute("src")));
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to $content if saveXML somehow fails (i.e. returns false)
|
||||
$modified_content = $tmpdoc->saveXML();
|
||||
return $modified_content !== false ? $modified_content : $content;
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
function get_content(): string {
|
||||
/** @var DOMElement|null */
|
||||
$content = $this->elem->getElementsByTagName("content")->item(0);
|
||||
|
||||
if ($content) {
|
||||
$base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $content);
|
||||
|
||||
if ($content->hasAttribute('type')) {
|
||||
if ($content->getAttribute('type') == 'xhtml') {
|
||||
for ($i = 0; $i < $content->childNodes->length; $i++) {
|
||||
$child = $content->childNodes->item($i);
|
||||
|
||||
if ($child->hasChildNodes()) {
|
||||
return $this->rewrite_content_to_base($base, $this->doc->saveHTML($child));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->rewrite_content_to_base($base, $this->subtree_or_text($content));
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// TODO: duplicate code should be merged with get_content()
|
||||
function get_description(): string {
|
||||
/** @var DOMElement|null */
|
||||
$content = $this->elem->getElementsByTagName("summary")->item(0);
|
||||
|
||||
if ($content) {
|
||||
$base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $content);
|
||||
|
||||
if ($content->hasAttribute('type')) {
|
||||
if ($content->getAttribute('type') == 'xhtml') {
|
||||
for ($i = 0; $i < $content->childNodes->length; $i++) {
|
||||
$child = $content->childNodes->item($i);
|
||||
|
||||
if ($child->hasChildNodes()) {
|
||||
return $this->rewrite_content_to_base($base, $this->doc->saveHTML($child));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->rewrite_content_to_base($base, $this->subtree_or_text($content));
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
function get_categories(): array {
|
||||
$categories = $this->elem->getElementsByTagName("category");
|
||||
$cats = [];
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
if ($cat->hasAttribute("term"))
|
||||
array_push($cats, $cat->getAttribute("term"));
|
||||
}
|
||||
|
||||
$categories = $this->xpath->query("dc:subject", $this->elem);
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
array_push($cats, $cat->nodeValue);
|
||||
}
|
||||
|
||||
return $this->normalize_categories($cats);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, FeedEnclosure>
|
||||
*/
|
||||
function get_enclosures(): array {
|
||||
$links = $this->elem->getElementsByTagName("link");
|
||||
|
||||
$encs = [];
|
||||
|
||||
foreach ($links as $link) {
|
||||
/** @phpstan-ignore-next-line */
|
||||
if ($link->hasAttribute("href") && $link->hasAttribute("rel")) {
|
||||
$base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $link);
|
||||
|
||||
if ($link->getAttribute("rel") == "enclosure") {
|
||||
$enc = new FeedEnclosure();
|
||||
|
||||
$enc->type = clean($link->getAttribute("type"));
|
||||
$enc->length = clean($link->getAttribute("length"));
|
||||
$enc->link = clean($link->getAttribute("href"));
|
||||
|
||||
if (!empty($base)) {
|
||||
$enc->link = UrlHelper::rewrite_relative($base, $enc->link);
|
||||
}
|
||||
|
||||
array_push($encs, $enc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
array_push($encs, ...parent::get_enclosures());
|
||||
|
||||
return $encs;
|
||||
}
|
||||
|
||||
function get_language(): string {
|
||||
$lang = $this->elem->getAttributeNS(self::NS_XML, "lang");
|
||||
|
||||
if (!empty($lang)) {
|
||||
return clean($lang);
|
||||
} else {
|
||||
// Fall back to the language declared on the feed, if any.
|
||||
foreach ($this->doc->childNodes as $child) {
|
||||
if (method_exists($child, "getAttributeNS")) {
|
||||
return clean($child->getAttributeNS(self::NS_XML, "lang"));
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
221
classes/FeedItem_Common.php
Normal file
221
classes/FeedItem_Common.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
abstract class FeedItem_Common extends FeedItem {
|
||||
/** @var DOMElement */
|
||||
protected $elem;
|
||||
|
||||
/** @var DOMDocument */
|
||||
protected $doc;
|
||||
|
||||
/** @var DOMXPath */
|
||||
protected $xpath;
|
||||
|
||||
function __construct(DOMElement $elem, DOMDocument $doc, DOMXPath $xpath) {
|
||||
$this->elem = $elem;
|
||||
$this->xpath = $xpath;
|
||||
$this->doc = $doc;
|
||||
|
||||
try {
|
||||
$source = $elem->getElementsByTagName("source")->item(0);
|
||||
|
||||
// we don't need <source> element
|
||||
if ($source)
|
||||
$elem->removeChild($source);
|
||||
} catch (DOMException $e) {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
function get_element(): DOMElement {
|
||||
return $this->elem;
|
||||
}
|
||||
|
||||
function get_author(): string {
|
||||
/** @var DOMElement|null */
|
||||
$author = $this->elem->getElementsByTagName("author")->item(0);
|
||||
|
||||
if ($author) {
|
||||
$name = $author->getElementsByTagName("name")->item(0);
|
||||
|
||||
if ($name) return clean($name->nodeValue);
|
||||
|
||||
$email = $author->getElementsByTagName("email")->item(0);
|
||||
|
||||
if ($email) return clean($email->nodeValue);
|
||||
|
||||
if ($author->nodeValue)
|
||||
return clean($author->nodeValue);
|
||||
}
|
||||
|
||||
$author_elems = $this->xpath->query("dc:creator", $this->elem);
|
||||
$authors = [];
|
||||
|
||||
foreach ($author_elems as $author) {
|
||||
array_push($authors, clean($author->nodeValue));
|
||||
}
|
||||
|
||||
return implode(", ", $authors);
|
||||
}
|
||||
|
||||
function get_comments_url(): string {
|
||||
//RSS only. Use a query here to avoid namespace clashes (e.g. with slash).
|
||||
//might give a wrong result if a default namespace was declared (possible with XPath 2.0)
|
||||
$com_url = $this->xpath->query("comments", $this->elem)->item(0);
|
||||
|
||||
if ($com_url)
|
||||
return clean($com_url->nodeValue);
|
||||
|
||||
//Atom Threading Extension (RFC 4685) stuff. Could be used in RSS feeds, so it's in common.
|
||||
//'text/html' for type is too restrictive?
|
||||
$com_url = $this->xpath->query("atom:link[@rel='replies' and contains(@type,'text/html')]/@href", $this->elem)->item(0);
|
||||
|
||||
if ($com_url)
|
||||
return clean($com_url->nodeValue);
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function get_comments_count(): int {
|
||||
//also query for ATE stuff here
|
||||
$query = "slash:comments|thread:total|atom:link[@rel='replies']/@thread:count";
|
||||
$comments = $this->xpath->query($query, $this->elem)->item(0);
|
||||
|
||||
if ($comments && is_numeric($comments->nodeValue)) {
|
||||
return (int) clean($comments->nodeValue);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* this is common for both Atom and RSS types and deals with various 'media:' elements
|
||||
*
|
||||
* @return array<int, FeedEnclosure>
|
||||
*/
|
||||
function get_enclosures(): array {
|
||||
$encs = [];
|
||||
|
||||
$enclosures = $this->xpath->query("media:content", $this->elem);
|
||||
|
||||
foreach ($enclosures as $enclosure) {
|
||||
$enc = new FeedEnclosure();
|
||||
|
||||
$enc->type = clean($enclosure->getAttribute("type"));
|
||||
$enc->link = clean($enclosure->getAttribute("url"));
|
||||
$enc->length = clean($enclosure->getAttribute("length"));
|
||||
$enc->height = clean($enclosure->getAttribute("height"));
|
||||
$enc->width = clean($enclosure->getAttribute("width"));
|
||||
|
||||
$medium = clean($enclosure->getAttribute("medium"));
|
||||
if (!$enc->type && $medium) {
|
||||
$enc->type = strtolower("$medium/generic");
|
||||
}
|
||||
|
||||
$desc = $this->xpath->query("media:description", $enclosure)->item(0);
|
||||
if ($desc) $enc->title = clean($desc->nodeValue);
|
||||
|
||||
array_push($encs, $enc);
|
||||
}
|
||||
|
||||
$enclosures = $this->xpath->query("media:group", $this->elem);
|
||||
|
||||
foreach ($enclosures as $enclosure) {
|
||||
$enc = new FeedEnclosure();
|
||||
|
||||
/** @var DOMElement|null */
|
||||
$content = $this->xpath->query("media:content", $enclosure)->item(0);
|
||||
|
||||
if ($content) {
|
||||
$enc->type = clean($content->getAttribute("type"));
|
||||
$enc->link = clean($content->getAttribute("url"));
|
||||
$enc->length = clean($content->getAttribute("length"));
|
||||
$enc->height = clean($content->getAttribute("height"));
|
||||
$enc->width = clean($content->getAttribute("width"));
|
||||
|
||||
$medium = clean($content->getAttribute("medium"));
|
||||
if (!$enc->type && $medium) {
|
||||
$enc->type = strtolower("$medium/generic");
|
||||
}
|
||||
|
||||
$desc = $this->xpath->query("media:description", $content)->item(0);
|
||||
if ($desc) {
|
||||
$enc->title = clean($desc->nodeValue);
|
||||
} else {
|
||||
$desc = $this->xpath->query("media:description", $enclosure)->item(0);
|
||||
if ($desc) $enc->title = clean($desc->nodeValue);
|
||||
}
|
||||
|
||||
array_push($encs, $enc);
|
||||
}
|
||||
}
|
||||
|
||||
$enclosures = $this->xpath->query("media:thumbnail", $this->elem);
|
||||
|
||||
foreach ($enclosures as $enclosure) {
|
||||
$enc = new FeedEnclosure();
|
||||
|
||||
$enc->type = "image/generic";
|
||||
$enc->link = clean($enclosure->getAttribute("url"));
|
||||
$enc->height = clean($enclosure->getAttribute("height"));
|
||||
$enc->width = clean($enclosure->getAttribute("width"));
|
||||
|
||||
array_push($encs, $enc);
|
||||
}
|
||||
|
||||
return $encs;
|
||||
}
|
||||
|
||||
function count_children(DOMElement $node): int {
|
||||
return $node->getElementsByTagName("*")->length;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|string false on failure, otherwise string contents
|
||||
*/
|
||||
function subtree_or_text(DOMElement $node) {
|
||||
if ($this->count_children($node) == 0) {
|
||||
return $node->nodeValue;
|
||||
} else {
|
||||
return $node->c14n();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $cats
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
static function normalize_categories(array $cats): array {
|
||||
|
||||
$tmp = [];
|
||||
|
||||
foreach ($cats as $rawcat) {
|
||||
array_push($tmp, ...explode(",", $rawcat));
|
||||
}
|
||||
|
||||
$tmp = array_map(function($srccat) {
|
||||
$cat = clean(trim(mb_strtolower($srccat)));
|
||||
|
||||
// we don't support numeric tags
|
||||
if (is_numeric($cat))
|
||||
$cat = 't:' . $cat;
|
||||
|
||||
$cat = preg_replace('/[,\'\"]/', "", $cat);
|
||||
|
||||
if (Config::get(Config::DB_TYPE) == "mysql") {
|
||||
$cat = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $cat);
|
||||
}
|
||||
|
||||
if (mb_strlen($cat) > 250)
|
||||
$cat = mb_substr($cat, 0, 250);
|
||||
|
||||
return $cat;
|
||||
}, $tmp);
|
||||
|
||||
// remove empty values
|
||||
$tmp = array_filter($tmp, 'strlen');
|
||||
|
||||
asort($tmp);
|
||||
|
||||
return array_unique($tmp);
|
||||
}
|
||||
}
|
||||
169
classes/FeedItem_RSS.php
Normal file
169
classes/FeedItem_RSS.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
class FeedItem_RSS extends FeedItem_Common {
|
||||
function get_id(): string {
|
||||
$id = $this->elem->getElementsByTagName("guid")->item(0);
|
||||
|
||||
if ($id) {
|
||||
return clean($id->nodeValue);
|
||||
} else {
|
||||
return clean($this->get_link());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int|false a timestamp on success, false otherwise
|
||||
*/
|
||||
function get_date() {
|
||||
$pubDate = $this->elem->getElementsByTagName("pubDate")->item(0);
|
||||
|
||||
if ($pubDate) {
|
||||
return strtotime($pubDate->nodeValue ?? '');
|
||||
}
|
||||
|
||||
$date = $this->xpath->query("dc:date", $this->elem)->item(0);
|
||||
|
||||
if ($date) {
|
||||
return strtotime($date->nodeValue ?? '');
|
||||
}
|
||||
|
||||
// consistent with strtotime failing to parse
|
||||
return false;
|
||||
}
|
||||
|
||||
function get_link(): string {
|
||||
$links = $this->xpath->query("atom:link", $this->elem);
|
||||
|
||||
foreach ($links as $link) {
|
||||
if ($link && $link->hasAttribute("href") &&
|
||||
(!$link->hasAttribute("rel")
|
||||
|| $link->getAttribute("rel") == "alternate"
|
||||
|| $link->getAttribute("rel") == "standout")) {
|
||||
|
||||
return clean(trim($link->getAttribute("href")));
|
||||
}
|
||||
}
|
||||
|
||||
/** @var DOMElement|null */
|
||||
$link = $this->elem->getElementsByTagName("guid")->item(0);
|
||||
|
||||
if ($link && $link->hasAttributes() && $link->getAttribute("isPermaLink") == "true") {
|
||||
return clean(trim($link->nodeValue));
|
||||
}
|
||||
|
||||
$link = $this->elem->getElementsByTagName("link")->item(0);
|
||||
|
||||
if ($link) {
|
||||
return clean(trim($link->nodeValue));
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function get_title(): string {
|
||||
$title = $this->xpath->query("title", $this->elem)->item(0);
|
||||
|
||||
if ($title) {
|
||||
return clean(trim($title->nodeValue));
|
||||
}
|
||||
|
||||
// if the document has a default namespace then querying for
|
||||
// title would fail because of reasons so let's try the old way
|
||||
$title = $this->elem->getElementsByTagName("title")->item(0);
|
||||
|
||||
if ($title) {
|
||||
return clean(trim($title->nodeValue));
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function get_content(): string {
|
||||
/** @var DOMElement|null */
|
||||
$contentA = $this->xpath->query("content:encoded", $this->elem)->item(0);
|
||||
|
||||
/** @var DOMElement|null */
|
||||
$contentB = $this->elem->getElementsByTagName("description")->item(0);
|
||||
|
||||
if ($contentA && $contentB) {
|
||||
$resultA = $this->subtree_or_text($contentA);
|
||||
$resultB = $this->subtree_or_text($contentB);
|
||||
|
||||
return mb_strlen($resultA) > mb_strlen($resultB) ? $resultA : $resultB;
|
||||
}
|
||||
|
||||
if ($contentA) {
|
||||
return $this->subtree_or_text($contentA);
|
||||
}
|
||||
|
||||
if ($contentB) {
|
||||
return $this->subtree_or_text($contentB);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function get_description(): string {
|
||||
$summary = $this->elem->getElementsByTagName("description")->item(0);
|
||||
|
||||
if ($summary) {
|
||||
return $summary->nodeValue;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
function get_categories(): array {
|
||||
$categories = $this->elem->getElementsByTagName("category");
|
||||
$cats = [];
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
array_push($cats, $cat->nodeValue);
|
||||
}
|
||||
|
||||
$categories = $this->xpath->query("dc:subject", $this->elem);
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
array_push($cats, $cat->nodeValue);
|
||||
}
|
||||
|
||||
return $this->normalize_categories($cats);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, FeedEnclosure>
|
||||
*/
|
||||
function get_enclosures(): array {
|
||||
$enclosures = $this->elem->getElementsByTagName("enclosure");
|
||||
|
||||
$encs = array();
|
||||
|
||||
foreach ($enclosures as $enclosure) {
|
||||
$enc = new FeedEnclosure();
|
||||
|
||||
$enc->type = clean($enclosure->getAttribute("type"));
|
||||
$enc->link = clean($enclosure->getAttribute("url"));
|
||||
$enc->length = clean($enclosure->getAttribute("length"));
|
||||
$enc->height = clean($enclosure->getAttribute("height"));
|
||||
$enc->width = clean($enclosure->getAttribute("width"));
|
||||
|
||||
array_push($encs, $enc);
|
||||
}
|
||||
|
||||
array_push($encs, ...parent::get_enclosures());
|
||||
|
||||
return $encs;
|
||||
}
|
||||
|
||||
function get_language(): string {
|
||||
$languages = $this->doc->getElementsByTagName('language');
|
||||
|
||||
if (count($languages) == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return clean($languages[0]->textContent);
|
||||
}
|
||||
}
|
||||
244
classes/FeedParser.php
Normal file
244
classes/FeedParser.php
Normal file
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
class FeedParser {
|
||||
|
||||
/** @var DOMDocument */
|
||||
private $doc;
|
||||
|
||||
/** @var string|null */
|
||||
private $error = null;
|
||||
|
||||
/** @var array<string> */
|
||||
private $libxml_errors = [];
|
||||
|
||||
/** @var array<FeedItem> */
|
||||
private $items = [];
|
||||
|
||||
/** @var string|null */
|
||||
private $link;
|
||||
|
||||
/** @var string|null */
|
||||
private $title;
|
||||
|
||||
/** @var FeedParser::FEED_*|null */
|
||||
private $type;
|
||||
|
||||
/** @var DOMXPath|null */
|
||||
private $xpath;
|
||||
|
||||
const FEED_RDF = 0;
|
||||
const FEED_RSS = 1;
|
||||
const FEED_ATOM = 2;
|
||||
|
||||
function __construct(string $data) {
|
||||
libxml_use_internal_errors(true);
|
||||
libxml_clear_errors();
|
||||
$this->doc = new DOMDocument();
|
||||
$this->doc->loadXML($data);
|
||||
|
||||
mb_substitute_character("none");
|
||||
|
||||
$error = libxml_get_last_error();
|
||||
|
||||
if ($error) {
|
||||
foreach (libxml_get_errors() as $error) {
|
||||
if ($error->level == LIBXML_ERR_FATAL) {
|
||||
// currently only the first error is reported
|
||||
$this->error ??= Errors::format_libxml_error($error);
|
||||
$this->libxml_errors[] = Errors::format_libxml_error($error);
|
||||
}
|
||||
}
|
||||
}
|
||||
libxml_clear_errors();
|
||||
}
|
||||
|
||||
function init() : void {
|
||||
$xpath = new DOMXPath($this->doc);
|
||||
$xpath->registerNamespace('atom', 'http://www.w3.org/2005/Atom');
|
||||
$xpath->registerNamespace('atom03', 'http://purl.org/atom/ns#');
|
||||
$xpath->registerNamespace('media', 'http://search.yahoo.com/mrss/');
|
||||
$xpath->registerNamespace('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#');
|
||||
$xpath->registerNamespace('slash', 'http://purl.org/rss/1.0/modules/slash/');
|
||||
$xpath->registerNamespace('dc', 'http://purl.org/dc/elements/1.1/');
|
||||
$xpath->registerNamespace('content', 'http://purl.org/rss/1.0/modules/content/');
|
||||
$xpath->registerNamespace('thread', 'http://purl.org/syndication/thread/1.0');
|
||||
|
||||
$this->xpath = $xpath;
|
||||
|
||||
$root_list = $xpath->query("(//atom03:feed|//atom:feed|//channel|//rdf:rdf|//rdf:RDF)");
|
||||
|
||||
if (!empty($root_list) && $root_list->length > 0) {
|
||||
|
||||
/** @var DOMElement|null $root */
|
||||
$root = $root_list->item(0);
|
||||
|
||||
if ($root) {
|
||||
switch (mb_strtolower($root->tagName)) {
|
||||
case "rdf:rdf":
|
||||
$this->type = $this::FEED_RDF;
|
||||
break;
|
||||
case "channel":
|
||||
$this->type = $this::FEED_RSS;
|
||||
break;
|
||||
case "feed":
|
||||
case "atom:feed":
|
||||
$this->type = $this::FEED_ATOM;
|
||||
break;
|
||||
default:
|
||||
$this->error ??= "Unknown/unsupported feed type";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch ($this->type) {
|
||||
case $this::FEED_ATOM:
|
||||
|
||||
$title = $xpath->query("//atom:feed/atom:title")->item(0);
|
||||
|
||||
if (!$title)
|
||||
$title = $xpath->query("//atom03:feed/atom03:title")->item(0);
|
||||
|
||||
|
||||
if ($title) {
|
||||
$this->title = $title->nodeValue;
|
||||
}
|
||||
|
||||
$link = $xpath->query("//atom:feed/atom:link[not(@rel)]")->item(0);
|
||||
|
||||
if (!$link)
|
||||
$link = $xpath->query("//atom:feed/atom:link[@rel='alternate']")->item(0);
|
||||
|
||||
if (!$link)
|
||||
$link = $xpath->query("//atom03:feed/atom03:link[not(@rel)]")->item(0);
|
||||
|
||||
if (!$link)
|
||||
$link = $xpath->query("//atom03:feed/atom03:link[@rel='alternate']")->item(0);
|
||||
|
||||
/** @var DOMElement|null $link */
|
||||
if ($link && $link->hasAttributes()) {
|
||||
$this->link = $link->getAttribute("href");
|
||||
}
|
||||
|
||||
$articles = $xpath->query("//atom:entry");
|
||||
|
||||
if (empty($articles) || $articles->length == 0)
|
||||
$articles = $xpath->query("//atom03:entry");
|
||||
|
||||
foreach ($articles as $article) {
|
||||
array_push($this->items, new FeedItem_Atom($article, $this->doc, $this->xpath));
|
||||
}
|
||||
|
||||
break;
|
||||
case $this::FEED_RSS:
|
||||
$title = $xpath->query("//channel/title")->item(0);
|
||||
|
||||
if ($title) {
|
||||
$this->title = $title->nodeValue;
|
||||
}
|
||||
|
||||
/** @var DOMElement|null $link */
|
||||
$link = $xpath->query("//channel/link")->item(0);
|
||||
|
||||
if ($link) {
|
||||
if ($link->getAttribute("href"))
|
||||
$this->link = $link->getAttribute("href");
|
||||
else if ($link->nodeValue)
|
||||
$this->link = $link->nodeValue;
|
||||
}
|
||||
|
||||
$articles = $xpath->query("//channel/item");
|
||||
|
||||
foreach ($articles as $article) {
|
||||
array_push($this->items, new FeedItem_RSS($article, $this->doc, $this->xpath));
|
||||
}
|
||||
|
||||
break;
|
||||
case $this::FEED_RDF:
|
||||
$xpath->registerNamespace('rssfake', 'http://purl.org/rss/1.0/');
|
||||
|
||||
$title = $xpath->query("//rssfake:channel/rssfake:title")->item(0);
|
||||
|
||||
if ($title) {
|
||||
$this->title = $title->nodeValue;
|
||||
}
|
||||
|
||||
$link = $xpath->query("//rssfake:channel/rssfake:link")->item(0);
|
||||
|
||||
if ($link) {
|
||||
$this->link = $link->nodeValue;
|
||||
}
|
||||
|
||||
$articles = $xpath->query("//rssfake:item");
|
||||
|
||||
foreach ($articles as $article) {
|
||||
array_push($this->items, new FeedItem_RSS($article, $this->doc, $this->xpath));
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
if ($this->title) $this->title = trim($this->title);
|
||||
if ($this->link) $this->link = trim($this->link);
|
||||
|
||||
} else {
|
||||
$this->error ??= "Unknown/unsupported feed type";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated use Errors::format_libxml_error() instead */
|
||||
function format_error(LibXMLError $error) : string {
|
||||
return Errors::format_libxml_error($error);
|
||||
}
|
||||
|
||||
// libxml may have invalid unicode data in error messages
|
||||
function error() : string {
|
||||
return UConverter::transcode($this->error ?? '', 'UTF-8', 'UTF-8');
|
||||
}
|
||||
|
||||
/** @return array<string> - WARNING: may return invalid unicode data */
|
||||
function errors() : array {
|
||||
return $this->libxml_errors;
|
||||
}
|
||||
|
||||
function get_link() : string {
|
||||
return clean($this->link ?? '');
|
||||
}
|
||||
|
||||
function get_title() : string {
|
||||
return clean($this->title ?? '');
|
||||
}
|
||||
|
||||
/** @return array<FeedItem> */
|
||||
function get_items() : array {
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
/** @return array<string> */
|
||||
function get_links(string $rel) : array {
|
||||
$rv = array();
|
||||
|
||||
switch ($this->type) {
|
||||
case $this::FEED_ATOM:
|
||||
$links = $this->xpath->query("//atom:feed/atom:link");
|
||||
|
||||
foreach ($links as $link) {
|
||||
if (!$rel || $link->hasAttribute('rel') && $link->getAttribute('rel') == $rel) {
|
||||
array_push($rv, clean(trim($link->getAttribute('href'))));
|
||||
}
|
||||
}
|
||||
break;
|
||||
case $this::FEED_RSS:
|
||||
$links = $this->xpath->query("//atom:link");
|
||||
|
||||
foreach ($links as $link) {
|
||||
if (!$rel || $link->hasAttribute('rel') && $link->getAttribute('rel') == $rel) {
|
||||
array_push($rv, clean(trim($link->getAttribute('href'))));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
}
|
||||
2448
classes/Feeds.php
Normal file
2448
classes/Feeds.php
Normal file
File diff suppressed because it is too large
Load Diff
35
classes/Handler.php
Normal file
35
classes/Handler.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
class Handler implements IHandler {
|
||||
protected PDO $pdo;
|
||||
|
||||
/** @var array<int|string, mixed> */
|
||||
protected array $args;
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $args
|
||||
*/
|
||||
function __construct(array $args) {
|
||||
$this->pdo = Db::pdo();
|
||||
$this->args = $args;
|
||||
}
|
||||
|
||||
function csrf_ignore(string $method): bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
function before(string $method): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
function after(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $p
|
||||
*/
|
||||
public static function _param_to_bool($p): bool {
|
||||
$p = clean($p);
|
||||
return $p && ($p !== "f" && $p !== "false");
|
||||
}
|
||||
}
|
||||
11
classes/Handler_Administrative.php
Normal file
11
classes/Handler_Administrative.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
class Handler_Administrative extends Handler_Protected {
|
||||
function before(string $method): bool {
|
||||
if (parent::before($method)) {
|
||||
if (($_SESSION["access_level"] ?? 0) >= UserHelper::ACCESS_LEVEL_ADMIN) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
7
classes/Handler_Protected.php
Normal file
7
classes/Handler_Protected.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
class Handler_Protected extends Handler {
|
||||
|
||||
function before(string $method): bool {
|
||||
return parent::before($method) && !empty($_SESSION['uid']);
|
||||
}
|
||||
}
|
||||
844
classes/Handler_Public.php
Normal file
844
classes/Handler_Public.php
Normal file
@@ -0,0 +1,844 @@
|
||||
<?php
|
||||
class Handler_Public extends Handler {
|
||||
|
||||
/**
|
||||
* @param string $feed may be a feed ID or tag
|
||||
*/
|
||||
private function generate_syndicated_feed(int $owner_uid, string $feed, bool $is_cat,
|
||||
int $limit, int $offset, string $search, string $view_mode = "",
|
||||
string $format = 'atom', string $order = "", string $orig_guid = "", string $start_ts = ""): void {
|
||||
|
||||
$note_style = "background-color : #fff7d5;
|
||||
border-width : 1px; ".
|
||||
"padding : 5px; border-style : dashed; border-color : #e7d796;".
|
||||
"margin-bottom : 1em; color : #9a8c59;";
|
||||
|
||||
if (!$limit) $limit = 60;
|
||||
|
||||
list($override_order, $skip_first_id_check) = Feeds::_order_to_override_query($order);
|
||||
|
||||
if (!$override_order) {
|
||||
$override_order = "date_entered DESC, updated DESC";
|
||||
|
||||
if ($feed == Feeds::FEED_PUBLISHED && !$is_cat) {
|
||||
$override_order = "last_published DESC";
|
||||
} else if ($feed == Feeds::FEED_STARRED && !$is_cat) {
|
||||
$override_order = "last_marked DESC";
|
||||
}
|
||||
}
|
||||
|
||||
$params = array(
|
||||
"owner_uid" => $owner_uid,
|
||||
"feed" => $feed,
|
||||
"limit" => $limit,
|
||||
"view_mode" => $view_mode,
|
||||
"cat_view" => $is_cat,
|
||||
"search" => $search,
|
||||
"override_order" => $override_order,
|
||||
"include_children" => true,
|
||||
"ignore_vfeed_group" => true,
|
||||
"offset" => $offset,
|
||||
"start_ts" => $start_ts
|
||||
);
|
||||
|
||||
if (!$is_cat && is_numeric($feed) && $feed < PLUGIN_FEED_BASE_INDEX && $feed > LABEL_BASE_INDEX) {
|
||||
|
||||
$user_plugins = get_pref(Prefs::_ENABLED_PLUGINS, $owner_uid);
|
||||
|
||||
$tmppluginhost = new PluginHost();
|
||||
$tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_ALL);
|
||||
$tmppluginhost->load((string)$user_plugins, PluginHost::KIND_USER, $owner_uid);
|
||||
//$tmppluginhost->load_data();
|
||||
|
||||
$handler = $tmppluginhost->get_feed_handler(
|
||||
PluginHost::feed_to_pfeed_id((int)$feed));
|
||||
|
||||
if ($handler) {
|
||||
// 'get_headlines' is implemented by the plugin.
|
||||
// @phpstan-ignore-next-line
|
||||
$qfh_ret = $handler->get_headlines(PluginHost::feed_to_pfeed_id((int)$feed), $params);
|
||||
} else {
|
||||
user_error("Failed to find handler for plugin feed ID: $feed", E_USER_ERROR);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
} else {
|
||||
$qfh_ret = Feeds::_get_headlines($params);
|
||||
}
|
||||
|
||||
$result = $qfh_ret[0];
|
||||
$feed_title = htmlspecialchars($qfh_ret[1]);
|
||||
$feed_site_url = $qfh_ret[2];
|
||||
/* $last_error = $qfh_ret[3]; */
|
||||
|
||||
$feed_self_url = Config::get_self_url() .
|
||||
"/public.php?op=rss&id=$feed&key=" .
|
||||
Feeds::_get_access_key($feed, false, $owner_uid);
|
||||
|
||||
if (!$feed_site_url) $feed_site_url = Config::get_self_url();
|
||||
|
||||
if ($format == 'atom') {
|
||||
$tpl = new Templator();
|
||||
|
||||
$tpl->readTemplateFromFile("generated_feed.txt");
|
||||
|
||||
$tpl->setVariable('FEED_TITLE', $feed_title, true);
|
||||
$tpl->setVariable('VERSION', Config::get_version(), true);
|
||||
$tpl->setVariable('FEED_URL', htmlspecialchars($feed_self_url), true);
|
||||
|
||||
$tpl->setVariable('SELF_URL', htmlspecialchars(Config::get_self_url()), true);
|
||||
while ($line = $result->fetch()) {
|
||||
|
||||
$line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content"]), 100, '...'));
|
||||
$line["tags"] = Article::_get_tags($line["id"], $owner_uid);
|
||||
|
||||
$max_excerpt_length = 250;
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES,
|
||||
function ($result) use (&$line) {
|
||||
$line = $result;
|
||||
},
|
||||
$line, $max_excerpt_length);
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_EXPORT_FEED,
|
||||
function ($result) use (&$line) {
|
||||
$line = $result;
|
||||
},
|
||||
$line, $feed, $is_cat, $owner_uid);
|
||||
|
||||
$tpl->setVariable('ARTICLE_ID',
|
||||
htmlspecialchars($orig_guid ? $line['link'] :
|
||||
$this->_make_article_tag_uri($line['id'], $line['date_entered'])), true);
|
||||
$tpl->setVariable('ARTICLE_LINK', htmlspecialchars($line['link']), true);
|
||||
$tpl->setVariable('ARTICLE_TITLE', htmlspecialchars($line['title']), true);
|
||||
$tpl->setVariable('ARTICLE_EXCERPT', $line["content_preview"], true);
|
||||
|
||||
$content = Sanitizer::sanitize($line["content"], false, $owner_uid,
|
||||
$feed_site_url, null, $line["id"]);
|
||||
|
||||
$content = DiskCache::rewrite_urls($content);
|
||||
|
||||
if ($line['note']) {
|
||||
$content = "<div style=\"$note_style\">Article note: " . $line['note'] . "</div>" .
|
||||
$content;
|
||||
$tpl->setVariable('ARTICLE_NOTE', htmlspecialchars($line['note']), true);
|
||||
}
|
||||
|
||||
$tpl->setVariable('ARTICLE_CONTENT', $content, true);
|
||||
|
||||
$tpl->setVariable('ARTICLE_UPDATED_ATOM',
|
||||
date('c', strtotime($line["updated"] ?? '')), true);
|
||||
$tpl->setVariable('ARTICLE_UPDATED_RFC822',
|
||||
date(DATE_RFC822, strtotime($line["updated"] ?? '')), true);
|
||||
|
||||
$tpl->setVariable('ARTICLE_AUTHOR', htmlspecialchars($line['author']), true);
|
||||
|
||||
$tpl->setVariable('ARTICLE_SOURCE_LINK', htmlspecialchars($line['site_url'] ? $line["site_url"] : Config::get_self_url()), true);
|
||||
$tpl->setVariable('ARTICLE_SOURCE_TITLE', htmlspecialchars($line['feed_title'] ?? $feed_title), true);
|
||||
|
||||
foreach ($line["tags"] as $tag) {
|
||||
$tpl->setVariable('ARTICLE_CATEGORY', htmlspecialchars($tag), true);
|
||||
$tpl->addBlock('category');
|
||||
}
|
||||
|
||||
$enclosures = Article::_get_enclosures($line["id"]);
|
||||
|
||||
if (count($enclosures) > 0) {
|
||||
foreach ($enclosures as $e) {
|
||||
$type = htmlspecialchars($e['content_type']);
|
||||
$url = htmlspecialchars($e['content_url']);
|
||||
$length = $e['duration'] ? $e['duration'] : 1;
|
||||
|
||||
$tpl->setVariable('ARTICLE_ENCLOSURE_URL', $url, true);
|
||||
$tpl->setVariable('ARTICLE_ENCLOSURE_TYPE', $type, true);
|
||||
$tpl->setVariable('ARTICLE_ENCLOSURE_LENGTH', $length, true);
|
||||
|
||||
$tpl->addBlock('enclosure');
|
||||
}
|
||||
} else {
|
||||
$tpl->setVariable('ARTICLE_ENCLOSURE_URL', "", true);
|
||||
$tpl->setVariable('ARTICLE_ENCLOSURE_TYPE', "", true);
|
||||
$tpl->setVariable('ARTICLE_ENCLOSURE_LENGTH', "", true);
|
||||
}
|
||||
|
||||
list ($og_image, $og_stream) = Article::_get_image($enclosures, $line['content'], $feed_site_url, $line);
|
||||
|
||||
$tpl->setVariable('ARTICLE_OG_IMAGE', $og_image, true);
|
||||
|
||||
$tpl->addBlock('entry');
|
||||
}
|
||||
|
||||
$tmp = "";
|
||||
|
||||
$tpl->addBlock('feed');
|
||||
$tpl->generateOutputToString($tmp);
|
||||
|
||||
if (empty($_REQUEST["noxml"])) {
|
||||
header("Content-Type: text/xml; charset=utf-8");
|
||||
} else {
|
||||
header("Content-Type: text/plain; charset=utf-8");
|
||||
}
|
||||
|
||||
print $tmp;
|
||||
} else if ($format == 'json') {
|
||||
|
||||
$feed = array();
|
||||
|
||||
$feed['title'] = $feed_title;
|
||||
$feed['feed_url'] = $feed_self_url;
|
||||
$feed['self_url'] = Config::get_self_url();
|
||||
$feed['articles'] = [];
|
||||
|
||||
while ($line = $result->fetch()) {
|
||||
|
||||
$line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content"]), 100, '...'));
|
||||
$line["tags"] = Article::_get_tags($line["id"], $owner_uid);
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES,
|
||||
function ($result) use (&$line) {
|
||||
$line = $result;
|
||||
},
|
||||
$line);
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_EXPORT_FEED,
|
||||
function ($result) use (&$line) {
|
||||
$line = $result;
|
||||
},
|
||||
$line, $feed, $is_cat, $owner_uid);
|
||||
|
||||
$article = array();
|
||||
|
||||
$article['id'] = $line['link'];
|
||||
$article['link'] = $line['link'];
|
||||
$article['title'] = $line['title'];
|
||||
$article['excerpt'] = $line["content_preview"];
|
||||
$article['content'] = Sanitizer::sanitize($line["content"], false, $owner_uid, $feed_site_url, null, $line["id"]);
|
||||
$article['updated'] = date('c', strtotime($line["updated"] ?? ''));
|
||||
|
||||
if (!empty($line['note'])) $article['note'] = $line['note'];
|
||||
if (!empty($line['author'])) $article['author'] = $line['author'];
|
||||
|
||||
$article['source'] = [
|
||||
'link' => $line['site_url'] ? $line["site_url"] : Config::get_self_url(),
|
||||
'title' => $line['feed_title'] ?? $feed_title
|
||||
];
|
||||
|
||||
if (count($line["tags"]) > 0) {
|
||||
$article['tags'] = array();
|
||||
|
||||
foreach ($line["tags"] as $tag) {
|
||||
array_push($article['tags'], $tag);
|
||||
}
|
||||
}
|
||||
|
||||
$enclosures = Article::_get_enclosures($line["id"]);
|
||||
|
||||
if (count($enclosures) > 0) {
|
||||
$article['enclosures'] = array();
|
||||
|
||||
foreach ($enclosures as $e) {
|
||||
$type = $e['content_type'];
|
||||
$url = $e['content_url'];
|
||||
$length = $e['duration'];
|
||||
|
||||
array_push($article['enclosures'], array("url" => $url, "type" => $type, "length" => $length));
|
||||
}
|
||||
}
|
||||
|
||||
array_push($feed['articles'], $article);
|
||||
}
|
||||
|
||||
header("Content-Type: text/json; charset=utf-8");
|
||||
print json_encode($feed);
|
||||
|
||||
} else {
|
||||
header("Content-Type: text/plain; charset=utf-8");
|
||||
print "Unknown format: $format.";
|
||||
}
|
||||
}
|
||||
|
||||
function getUnread(): void {
|
||||
$login = clean($_REQUEST["login"]);
|
||||
$fresh = clean($_REQUEST["fresh"] ?? "0") == "1";
|
||||
|
||||
$uid = UserHelper::find_user_by_login($login);
|
||||
|
||||
if ($uid) {
|
||||
print Feeds::_get_global_unread($uid);
|
||||
|
||||
if ($fresh) {
|
||||
print ";";
|
||||
print Feeds::_get_counters(Feeds::FEED_FRESH, false, true, $uid);
|
||||
}
|
||||
} else {
|
||||
print "-1;User not found";
|
||||
}
|
||||
}
|
||||
|
||||
function getProfiles(): void {
|
||||
$login = clean($_REQUEST["login"]);
|
||||
$rv = [];
|
||||
|
||||
if ($login) {
|
||||
$profiles = ORM::for_table('ttrss_settings_profiles')
|
||||
->table_alias('p')
|
||||
->select_many('title' , 'p.id')
|
||||
->join('ttrss_users', ['owner_uid', '=', 'u.id'], 'u')
|
||||
->where_raw('LOWER(login) = LOWER(?)', [$login])
|
||||
->order_by_asc('title')
|
||||
->find_many();
|
||||
|
||||
$rv = [ [ "value" => 0, "label" => __("Default profile") ] ];
|
||||
|
||||
foreach ($profiles as $profile) {
|
||||
array_push($rv, [ "label" => $profile->title, "value" => $profile->id ]);
|
||||
}
|
||||
}
|
||||
|
||||
print json_encode($rv);
|
||||
}
|
||||
|
||||
function logout(): void {
|
||||
if (validate_csrf($_POST["csrf_token"])) {
|
||||
|
||||
$login = $_SESSION["name"];
|
||||
$user_id = $_SESSION["uid"];
|
||||
|
||||
UserHelper::logout();
|
||||
|
||||
$redirect_url = "";
|
||||
|
||||
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_POST_LOGOUT,
|
||||
function ($result) use (&$redirect_url) {
|
||||
if (!empty($result[0]))
|
||||
$redirect_url = UrlHelper::validate($result[0]);
|
||||
},
|
||||
$login, $user_id);
|
||||
|
||||
if (!$redirect_url)
|
||||
$redirect_url = Config::get_self_url() . "/index.php";
|
||||
|
||||
header("Location: " . $redirect_url);
|
||||
} else {
|
||||
header("Content-Type: text/json");
|
||||
print Errors::to_json(Errors::E_UNAUTHORIZED);
|
||||
}
|
||||
}
|
||||
|
||||
function rss(): void {
|
||||
$feed = clean($_REQUEST["id"]);
|
||||
$key = clean($_REQUEST["key"]);
|
||||
$is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false);
|
||||
$limit = (int)clean($_REQUEST["limit"] ?? 0);
|
||||
$offset = (int)clean($_REQUEST["offset"] ?? 0);
|
||||
|
||||
$search = clean($_REQUEST["q"] ?? "");
|
||||
$view_mode = clean($_REQUEST["view-mode"] ?? "");
|
||||
$order = clean($_REQUEST["order"] ?? "");
|
||||
$start_ts = clean($_REQUEST["ts"] ?? "");
|
||||
|
||||
$format = clean($_REQUEST['format'] ?? "atom");
|
||||
$orig_guid = clean($_REQUEST["orig_guid"] ?? "");
|
||||
|
||||
if (Config::get(Config::SINGLE_USER_MODE)) {
|
||||
UserHelper::authenticate("admin", null);
|
||||
}
|
||||
|
||||
if ($key) {
|
||||
$access_key = ORM::for_table('ttrss_access_keys')
|
||||
->select('owner_uid')
|
||||
->where(['access_key' => $key, 'feed_id' => $feed])
|
||||
->find_one();
|
||||
|
||||
if ($access_key) {
|
||||
$this->generate_syndicated_feed($access_key->owner_uid, $feed, $is_cat, $limit,
|
||||
$offset, $search, $view_mode, $format, $order, $orig_guid, $start_ts);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
header('HTTP/1.1 403 Forbidden');
|
||||
}
|
||||
|
||||
function updateTask(): void {
|
||||
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_UPDATE_TASK);
|
||||
}
|
||||
|
||||
function housekeepingTask(): void {
|
||||
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_HOUSE_KEEPING);
|
||||
}
|
||||
|
||||
function globalUpdateFeeds(): void {
|
||||
RPC::updaterandomfeed_real();
|
||||
|
||||
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_UPDATE_TASK);
|
||||
}
|
||||
|
||||
function login(): void {
|
||||
if (!Config::get(Config::SINGLE_USER_MODE)) {
|
||||
|
||||
$login = clean($_POST["login"]);
|
||||
$password = clean($_POST["password"]);
|
||||
$remember_me = clean($_POST["remember_me"] ?? false);
|
||||
$safe_mode = checkbox_to_sql_bool($_POST["safe_mode"] ?? false);
|
||||
|
||||
if (session_status() != PHP_SESSION_ACTIVE) {
|
||||
if ($remember_me) {
|
||||
session_set_cookie_params(Config::get(Config::SESSION_COOKIE_LIFETIME));
|
||||
} else {
|
||||
session_set_cookie_params(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (UserHelper::authenticate($login, $password)) {
|
||||
$_POST["password"] = "";
|
||||
|
||||
$_SESSION["ref_schema_version"] = Config::get_schema_version();
|
||||
$_SESSION["bw_limit"] = !!clean($_POST["bw_limit"] ?? false);
|
||||
$_SESSION["safe_mode"] = $safe_mode;
|
||||
|
||||
if (!empty($_POST["profile"])) {
|
||||
$profile = (int) clean($_POST["profile"]);
|
||||
|
||||
$profile_obj = ORM::for_table('ttrss_settings_profiles')
|
||||
->where(['id' => $profile, 'owner_uid' => $_SESSION['uid']])
|
||||
->find_one();
|
||||
|
||||
$_SESSION["profile"] = $profile_obj ? $profile : null;
|
||||
}
|
||||
} else {
|
||||
|
||||
// start an empty session to deliver login error message
|
||||
if (session_status() != PHP_SESSION_ACTIVE)
|
||||
session_start();
|
||||
|
||||
$_SESSION["login_error_msg"] ??= __("Incorrect username or password");
|
||||
}
|
||||
|
||||
$return = clean($_REQUEST['return'] ?? '');
|
||||
|
||||
if ($return && mb_strpos($return, Config::get_self_url()) === 0) {
|
||||
header("Location: $return");
|
||||
} else {
|
||||
header("Location: " . Config::get_self_url());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function index(): void {
|
||||
header("Content-Type: text/plain");
|
||||
print Errors::to_json(Errors::E_UNKNOWN_METHOD);
|
||||
}
|
||||
|
||||
function forgotpass(): void {
|
||||
startup_gettext();
|
||||
session_start();
|
||||
|
||||
$hash = clean($_REQUEST["hash"] ?? '');
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Tiny Tiny RSS</title>
|
||||
<link rel="shortcut icon" type="image/png" href="images/favicon.png">
|
||||
<link rel="icon" type="image/png" sizes="72x72" href="images/favicon-72px.png">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||
<?php
|
||||
echo stylesheet_tag("themes/light.css");
|
||||
echo javascript_tag("lib/dojo/dojo.js");
|
||||
echo javascript_tag("lib/dojo/tt-rss-layer.js");
|
||||
?>
|
||||
<?= Config::get_override_links() ?>
|
||||
</head>
|
||||
<body class='flat ttrss_utility'>
|
||||
<div class='container'>
|
||||
|
||||
<script type="text/javascript">
|
||||
require(['dojo/parser', "dojo/ready", 'dijit/form/Button','dijit/form/CheckBox', 'dijit/form/Form',
|
||||
'dijit/form/Select','dijit/form/TextBox','dijit/form/ValidationTextBox'],function(parser, ready){
|
||||
ready(function() {
|
||||
parser.parse();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
|
||||
print "<h1>".__("Password recovery")."</h1>";
|
||||
print "<div class='content'>";
|
||||
|
||||
$method = clean($_POST['method'] ?? '');
|
||||
|
||||
if ($hash) {
|
||||
$login = clean($_REQUEST["login"]);
|
||||
|
||||
if ($login) {
|
||||
$user = ORM::for_table('ttrss_users')
|
||||
->select_many('id', 'resetpass_token')
|
||||
->where_raw('LOWER(login) = LOWER(?)', [$login])
|
||||
->find_one();
|
||||
|
||||
if ($user) {
|
||||
list($timestamp, $resetpass_token) = explode(":", $user->resetpass_token);
|
||||
|
||||
if ($timestamp && $resetpass_token &&
|
||||
$timestamp >= time() - 15*60*60 &&
|
||||
$resetpass_token === $hash) {
|
||||
$user->resetpass_token = null;
|
||||
$user->save();
|
||||
|
||||
UserHelper::reset_password($user->id, true);
|
||||
|
||||
print "<p>"."Completed."."</p>";
|
||||
|
||||
} else {
|
||||
print_error("Some of the information provided is missing or incorrect.");
|
||||
}
|
||||
} else {
|
||||
print_error("Some of the information provided is missing or incorrect.");
|
||||
}
|
||||
} else {
|
||||
print_error("Some of the information provided is missing or incorrect.");
|
||||
}
|
||||
|
||||
print "<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>";
|
||||
|
||||
} else if (!$method) {
|
||||
print_notice(__("You will need to provide valid account name and email. Password reset link will be sent to your email address."));
|
||||
|
||||
print "<form method='POST' action='public.php'>
|
||||
<input type='hidden' name='method' value='do'>
|
||||
<input type='hidden' name='op' value='forgotpass'>
|
||||
|
||||
<fieldset>
|
||||
<label>".__("Login:")."</label>
|
||||
<input dojoType='dijit.form.TextBox' type='text' name='login' value='' required>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<label>".__("Email:")."</label>
|
||||
<input dojoType='dijit.form.TextBox' type='email' name='email' value='' required>
|
||||
</fieldset>";
|
||||
|
||||
$_SESSION["pwdreset:testvalue1"] = rand(1,10);
|
||||
$_SESSION["pwdreset:testvalue2"] = rand(1,10);
|
||||
|
||||
print "<fieldset>
|
||||
<label>".T_sprintf("How much is %d + %d:", $_SESSION["pwdreset:testvalue1"], $_SESSION["pwdreset:testvalue2"])."</label>
|
||||
<input dojoType='dijit.form.TextBox' type='text' name='test' value='' required>
|
||||
</fieldset>
|
||||
|
||||
<hr/>
|
||||
<fieldset>
|
||||
<button dojoType='dijit.form.Button' type='submit' class='alt-danger'>".__("Reset password")."</button>
|
||||
<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>
|
||||
</fieldset>
|
||||
|
||||
</form>";
|
||||
} else if ($method == 'do') {
|
||||
$login = clean($_POST["login"]);
|
||||
$email = clean($_POST["email"]);
|
||||
$test = clean($_POST["test"]);
|
||||
|
||||
if ($test != ($_SESSION["pwdreset:testvalue1"] + $_SESSION["pwdreset:testvalue2"]) || !$email || !$login) {
|
||||
print_error(__('Some of the required form parameters are missing or incorrect.'));
|
||||
|
||||
print "<form method='GET' action='public.php'>
|
||||
<input type='hidden' name='op' value='forgotpass'>
|
||||
<button dojoType='dijit.form.Button' type='submit' class='alt-primary'>".__("Go back")."</button>
|
||||
</form>";
|
||||
} else {
|
||||
// prevent submitting this form multiple times
|
||||
$_SESSION["pwdreset:testvalue1"] = rand(1, 1000);
|
||||
$_SESSION["pwdreset:testvalue2"] = rand(1, 1000);
|
||||
|
||||
$user = ORM::for_table('ttrss_users')
|
||||
->select('id')
|
||||
->where_raw('LOWER(login) = LOWER(?)', [$login])
|
||||
->where('email', $email)
|
||||
->find_one();
|
||||
|
||||
if ($user) {
|
||||
print_notice("Password reset instructions are being sent to your email address.");
|
||||
|
||||
$resetpass_token = sha1(get_random_bytes(128));
|
||||
$resetpass_link = Config::get_self_url() . "/public.php?op=forgotpass&hash=" . $resetpass_token .
|
||||
"&login=" . urlencode($login);
|
||||
|
||||
$tpl = new Templator();
|
||||
|
||||
$tpl->readTemplateFromFile("resetpass_link_template.txt");
|
||||
|
||||
$tpl->setVariable('LOGIN', $login);
|
||||
$tpl->setVariable('RESETPASS_LINK', $resetpass_link);
|
||||
$tpl->setVariable('TTRSS_HOST', Config::get_self_url());
|
||||
|
||||
$tpl->addBlock('message');
|
||||
|
||||
$message = "";
|
||||
|
||||
$tpl->generateOutputToString($message);
|
||||
|
||||
$mailer = new Mailer();
|
||||
|
||||
$rc = $mailer->mail(["to_name" => $login,
|
||||
"to_address" => $email,
|
||||
"subject" => __("[tt-rss] Password reset request"),
|
||||
"message" => $message]);
|
||||
|
||||
if (!$rc) print_error($mailer->error());
|
||||
|
||||
$user->resetpass_token = time() . ":" . $resetpass_token;
|
||||
$user->save();
|
||||
|
||||
print "<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>";
|
||||
} else {
|
||||
print_error(__("Sorry, login and email combination not found."));
|
||||
|
||||
print "<form method='GET' action='public.php'>
|
||||
<input type='hidden' name='op' value='forgotpass'>
|
||||
<button dojoType='dijit.form.Button' type='submit'>".__("Go back")."</button>
|
||||
</form>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print "</div>";
|
||||
print "</div>";
|
||||
print "</body>";
|
||||
print "</html>";
|
||||
}
|
||||
|
||||
function dbupdate(): void {
|
||||
startup_gettext();
|
||||
|
||||
if (!Config::get(Config::SINGLE_USER_MODE) && ($_SESSION["access_level"] ?? 0) < 10) {
|
||||
$_SESSION["login_error_msg"] = __("Your access level is insufficient to run this script.");
|
||||
$this->_render_login_form();
|
||||
exit;
|
||||
}
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Tiny Tiny RSS: Database Updater</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||
<link rel="icon" type="image/png" sizes="72x72" href="images/favicon-72px.png">
|
||||
<link rel="shortcut icon" type="image/png" href="images/favicon.png">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<?php
|
||||
foreach (["lib/dojo/dojo.js",
|
||||
"lib/dojo/tt-rss-layer.js",
|
||||
"js/common.js",
|
||||
"js/utility.js"] as $jsfile) {
|
||||
|
||||
echo javascript_tag($jsfile);
|
||||
|
||||
} ?>
|
||||
|
||||
<?= Config::get_override_links() ?>
|
||||
|
||||
<style type="text/css">
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background : #303030;
|
||||
}
|
||||
}
|
||||
|
||||
body.css_loading * {
|
||||
display : none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="text/javascript">
|
||||
require({cache:{}});
|
||||
</script>
|
||||
</head>
|
||||
<body class="flat ttrss_utility css_loading">
|
||||
|
||||
<script type="text/javascript">
|
||||
const UtilityApp = {
|
||||
init: function() {
|
||||
require(['dojo/parser', "dojo/ready", 'dijit/form/Button','dijit/form/CheckBox', 'dijit/form/Form',
|
||||
'dijit/form/Select','dijit/form/TextBox','dijit/form/ValidationTextBox'],function(parser, ready){
|
||||
ready(function() {
|
||||
parser.parse();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDbUpdate() {
|
||||
return confirm(__("Proceed with update?"));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<h1><?= __("Database Updater") ?></h1>
|
||||
|
||||
<div class="content">
|
||||
|
||||
<?php
|
||||
@$op = clean($_REQUEST["subop"] ?? "");
|
||||
|
||||
$migrations = Config::get_migrations();
|
||||
|
||||
if ($op == "performupdate") {
|
||||
if ($migrations->is_migration_needed()) {
|
||||
?>
|
||||
|
||||
<h2><?= T_sprintf("Performing updates to version %d", Config::SCHEMA_VERSION) ?></h2>
|
||||
|
||||
<code><pre class="small pre-wrap"><?php
|
||||
Debug::set_enabled(true);
|
||||
Debug::set_loglevel(Debug::LOG_VERBOSE);
|
||||
$result = $migrations->migrate();
|
||||
Debug::set_loglevel(Debug::LOG_NORMAL);
|
||||
Debug::set_enabled(false);
|
||||
?></pre></code>
|
||||
|
||||
<?php if (!$result) { ?>
|
||||
<?= format_error("One of migrations failed. Either retry the process or perform updates manually.") ?>
|
||||
|
||||
<form method="post">
|
||||
<?= \Controls\hidden_tag('subop', 'performupdate') ?>
|
||||
<?= \Controls\submit_tag(__("Update"), ["onclick" => "return confirmDbUpdate()"]) ?>
|
||||
</form>
|
||||
<?php } else { ?>
|
||||
<?= format_notice("Update successful.") ?>
|
||||
|
||||
<a href="index.php"><?= __("Return to Tiny Tiny RSS") ?></a>
|
||||
<?php }
|
||||
|
||||
} else { ?>
|
||||
|
||||
<?= format_notice("Database is already up to date.") ?>
|
||||
|
||||
<a href="index.php"><?= __("Return to Tiny Tiny RSS") ?></a>
|
||||
|
||||
<?php
|
||||
}
|
||||
} else {
|
||||
if ($migrations->is_migration_needed()) {
|
||||
|
||||
?>
|
||||
<h2><?= T_sprintf("Database schema needs update to the latest version (%d to %d).",
|
||||
Config::get_schema_version(), Config::SCHEMA_VERSION) ?></h2>
|
||||
|
||||
<?= format_warning("Please backup your database before proceeding.") ?>
|
||||
|
||||
<form method="post">
|
||||
<?= \Controls\hidden_tag('subop', 'performupdate') ?>
|
||||
<?= \Controls\submit_tag(__("Update"), ["onclick" => "return confirmDbUpdate()"]) ?>
|
||||
</form>
|
||||
|
||||
<?php
|
||||
} else { ?>
|
||||
|
||||
<?= format_notice("Database is already up to date.") ?>
|
||||
|
||||
<a href="index.php"><?= __("Return to Tiny Tiny RSS") ?></a>
|
||||
|
||||
<?php
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
}
|
||||
|
||||
function cached(): void {
|
||||
list ($cache_dir, $filename) = explode("/", $_GET["file"], 2);
|
||||
|
||||
// we do not allow files with extensions at the moment
|
||||
$filename = str_replace(".", "", $filename);
|
||||
|
||||
$cache = DiskCache::instance($cache_dir);
|
||||
|
||||
if ($cache->exists($filename)) {
|
||||
$cache->send($filename);
|
||||
} else {
|
||||
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
|
||||
echo "File not found.";
|
||||
}
|
||||
}
|
||||
|
||||
function feed_icon() : void {
|
||||
$id = (int)$_REQUEST['id'];
|
||||
$cache = DiskCache::instance('feed-icons');
|
||||
|
||||
if ($cache->exists((string)$id)) {
|
||||
$cache->send((string)$id);
|
||||
} else {
|
||||
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
|
||||
echo "File not found.";
|
||||
}
|
||||
}
|
||||
|
||||
private function _make_article_tag_uri(int $id, string $timestamp): string {
|
||||
|
||||
$timestamp = date("Y-m-d", strtotime($timestamp));
|
||||
|
||||
return "tag:" . parse_url(Config::get_self_url(), PHP_URL_HOST) . ",$timestamp:/$id";
|
||||
}
|
||||
|
||||
// this should be used very carefully because this endpoint is exposed to unauthenticated users
|
||||
// plugin data is not loaded because there's no user context and owner_uid/session may or may not be available
|
||||
// in general, don't do anything user-related in here and do not modify $_SESSION
|
||||
public function pluginhandler(): void {
|
||||
$host = new PluginHost();
|
||||
|
||||
$plugin_name = basename(clean($_REQUEST["plugin"]));
|
||||
$method = clean($_REQUEST["pmethod"]);
|
||||
|
||||
$host->load($plugin_name, PluginHost::KIND_ALL, 0);
|
||||
//$host->load_data();
|
||||
|
||||
$plugin = $host->get_plugin($plugin_name);
|
||||
|
||||
if ($plugin) {
|
||||
if (method_exists($plugin, $method)) {
|
||||
if ($plugin->is_public_method($method)) {
|
||||
$plugin->$method();
|
||||
} else {
|
||||
user_error("PluginHandler[PUBLIC]: Requested private method '$method' of plugin '$plugin_name'.", E_USER_WARNING);
|
||||
header("Content-Type: text/json");
|
||||
print Errors::to_json(Errors::E_UNAUTHORIZED);
|
||||
}
|
||||
} else {
|
||||
user_error("PluginHandler[PUBLIC]: Requested unknown method '$method' of plugin '$plugin_name'.", E_USER_WARNING);
|
||||
header("Content-Type: text/json");
|
||||
print Errors::to_json(Errors::E_UNKNOWN_METHOD);
|
||||
}
|
||||
} else {
|
||||
user_error("PluginHandler[PUBLIC]: Requested method '$method' of unknown plugin '$plugin_name'.", E_USER_WARNING);
|
||||
header("Content-Type: text/json");
|
||||
print Errors::to_json(Errors::E_UNKNOWN_PLUGIN, ['plugin' => $plugin_name]);
|
||||
}
|
||||
}
|
||||
|
||||
static function _render_login_form(string $return_to = ""): void {
|
||||
header('Cache-Control: public');
|
||||
|
||||
if ($return_to)
|
||||
$_REQUEST['return'] = $return_to;
|
||||
|
||||
require_once "login_form.php";
|
||||
exit;
|
||||
}
|
||||
|
||||
// implicit Config::sanity_check() does the actual checking */
|
||||
public function healthcheck() : void {
|
||||
header("Content-Type: text/plain");
|
||||
print "OK";
|
||||
}
|
||||
|
||||
}
|
||||
?>
|
||||
18
classes/IAuthModule.php
Normal file
18
classes/IAuthModule.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
interface IAuthModule {
|
||||
/**
|
||||
* @param string $login
|
||||
* @param string $password
|
||||
* @param string $service
|
||||
* @return int|false user_id
|
||||
*/
|
||||
function authenticate($login, $password, $service = '');
|
||||
|
||||
/** this is a pluginhost compatibility wrapper that invokes $this->authenticate(...$args) (Auth_Base)
|
||||
* @param string $login
|
||||
* @param string $password
|
||||
* @param string $service
|
||||
* @return int|false user_id
|
||||
*/
|
||||
function hook_auth_user($login, $password, $service = '');
|
||||
}
|
||||
4
classes/IAuthModule2.php
Normal file
4
classes/IAuthModule2.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
interface IAuthModule2 extends IAuthModule {
|
||||
function change_password(int $owner_uid, string $old_password, string $new_password) : string;
|
||||
}
|
||||
4
classes/ICatchall.php
Normal file
4
classes/ICatchall.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
interface ICatchall {
|
||||
function catchall(string $method): void;
|
||||
}
|
||||
6
classes/IHandler.php
Normal file
6
classes/IHandler.php
Normal file
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
interface IHandler {
|
||||
function csrf_ignore(string $method): bool;
|
||||
function before(string $method): bool;
|
||||
function after(): bool;
|
||||
}
|
||||
11
classes/IVirtualFeed.php
Normal file
11
classes/IVirtualFeed.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
interface IVirtualFeed {
|
||||
function get_unread(int $feed_id) : int;
|
||||
function get_total(int $feed_id) : int;
|
||||
/**
|
||||
* @param int $feed_id
|
||||
* @param array<string,int|string|bool> $options
|
||||
* @return array<int,int|string>
|
||||
*/
|
||||
function get_headlines(int $feed_id, array $options) : array;
|
||||
}
|
||||
233
classes/Labels.php
Normal file
233
classes/Labels.php
Normal file
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
class Labels
|
||||
{
|
||||
static function label_to_feed_id(int $label): int {
|
||||
return LABEL_BASE_INDEX - 1 - abs($label);
|
||||
}
|
||||
|
||||
static function feed_to_label_id(int $feed): int {
|
||||
return LABEL_BASE_INDEX - 1 + abs($feed);
|
||||
}
|
||||
|
||||
static function find_id(string $label, int $owner_uid): int {
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT id FROM ttrss_labels2 WHERE LOWER(caption) = LOWER(?)
|
||||
AND owner_uid = ? LIMIT 1");
|
||||
$sth->execute([$label, $owner_uid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
return $row['id'];
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static function find_caption(int $label, int $owner_uid): string {
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT caption FROM ttrss_labels2 WHERE id = ?
|
||||
AND owner_uid = ? LIMIT 1");
|
||||
$sth->execute([$label, $owner_uid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
return $row['caption'];
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
static function get_as_hash(int $owner_uid): array {
|
||||
$rv = [];
|
||||
$labels = Labels::get_all($owner_uid);
|
||||
|
||||
foreach ($labels as $i => $label) {
|
||||
$rv[(int)$label["id"]] = $labels[$i];
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, string>> An array of label detail arrays
|
||||
*/
|
||||
static function get_all(int $owner_uid) {
|
||||
$rv = array();
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT id, fg_color, bg_color, caption FROM ttrss_labels2
|
||||
WHERE owner_uid = ? ORDER BY caption");
|
||||
$sth->execute([$owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch(PDO::FETCH_ASSOC)) {
|
||||
array_push($rv, $line);
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{'no-labels': 1}|array<int, array<int, array{0: int, 1: string, 2: string, 3: string}>> $labels
|
||||
* [label_id, caption, fg_color, bg_color]
|
||||
*
|
||||
* @see Article::_get_labels()
|
||||
*/
|
||||
static function update_cache(int $owner_uid, int $id, array $labels, bool $force = false): void {
|
||||
$pdo = Db::pdo();
|
||||
|
||||
if ($force)
|
||||
self::clear_cache($id);
|
||||
|
||||
if (!$labels)
|
||||
$labels = Article::_get_labels($id);
|
||||
|
||||
$labels = json_encode($labels);
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
label_cache = ? WHERE ref_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$labels, $id, $owner_uid]);
|
||||
|
||||
}
|
||||
|
||||
static function clear_cache(int $id): void {
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
label_cache = '' WHERE ref_id = ?");
|
||||
$sth->execute([$id]);
|
||||
|
||||
}
|
||||
|
||||
static function remove_article(int $id, string $label, int $owner_uid): void {
|
||||
|
||||
$label_id = self::find_id($label, $owner_uid);
|
||||
|
||||
if (!$label_id) return;
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("DELETE FROM ttrss_user_labels2
|
||||
WHERE
|
||||
label_id = ? AND
|
||||
article_id = ?");
|
||||
|
||||
$sth->execute([$label_id, $id]);
|
||||
|
||||
self::clear_cache($id);
|
||||
}
|
||||
|
||||
static function add_article(int $id, string $label, int $owner_uid): void {
|
||||
|
||||
$label_id = self::find_id($label, $owner_uid);
|
||||
|
||||
if (!$label_id) return;
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT
|
||||
article_id FROM ttrss_labels2, ttrss_user_labels2
|
||||
WHERE
|
||||
label_id = id AND
|
||||
label_id = ? AND
|
||||
article_id = ? AND owner_uid = ?
|
||||
LIMIT 1");
|
||||
|
||||
$sth->execute([$label_id, $id, $owner_uid]);
|
||||
|
||||
if (!$sth->fetch()) {
|
||||
$sth = $pdo->prepare("INSERT INTO ttrss_user_labels2
|
||||
(label_id, article_id) VALUES (?, ?)");
|
||||
|
||||
$sth->execute([$label_id, $id]);
|
||||
}
|
||||
|
||||
self::clear_cache($id);
|
||||
|
||||
}
|
||||
|
||||
static function remove(int $id, int $owner_uid): void {
|
||||
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
$tr_in_progress = false;
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
} catch (Exception $e) {
|
||||
$tr_in_progress = true;
|
||||
}
|
||||
|
||||
$sth = $pdo->prepare("SELECT caption FROM ttrss_labels2
|
||||
WHERE id = ?");
|
||||
$sth->execute([$id]);
|
||||
|
||||
$row = $sth->fetch();
|
||||
$caption = $row['caption'];
|
||||
|
||||
$sth = $pdo->prepare("DELETE FROM ttrss_labels2 WHERE id = ?
|
||||
AND owner_uid = ?");
|
||||
$sth->execute([$id, $owner_uid]);
|
||||
|
||||
if ($sth->rowCount() != 0 && $caption) {
|
||||
|
||||
/* Remove access key for the label */
|
||||
|
||||
$ext_id = LABEL_BASE_INDEX - 1 - $id;
|
||||
|
||||
$sth = $pdo->prepare("DELETE FROM ttrss_access_keys WHERE
|
||||
feed_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$ext_id, $owner_uid]);
|
||||
|
||||
/* Remove cached data */
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET label_cache = ''
|
||||
WHERE owner_uid = ?");
|
||||
$sth->execute([$owner_uid]);
|
||||
|
||||
}
|
||||
|
||||
if (!$tr_in_progress) $pdo->commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|int false if the check for an existing label failed, otherwise the number of rows inserted (1 on success)
|
||||
*/
|
||||
static function create(string $caption, ?string $fg_color = '', ?string $bg_color = '', ?int $owner_uid = null) {
|
||||
|
||||
if (!$owner_uid) $owner_uid = $_SESSION['uid'];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$tr_in_progress = false;
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
} catch (Exception $e) {
|
||||
$tr_in_progress = true;
|
||||
}
|
||||
|
||||
$sth = $pdo->prepare("SELECT id FROM ttrss_labels2
|
||||
WHERE LOWER(caption) = LOWER(?) AND owner_uid = ?");
|
||||
$sth->execute([$caption, $owner_uid]);
|
||||
|
||||
if (!$sth->fetch()) {
|
||||
$sth = $pdo->prepare("INSERT INTO ttrss_labels2
|
||||
(caption,owner_uid,fg_color,bg_color) VALUES (?, ?, ?, ?)");
|
||||
|
||||
$sth->execute([$caption, $owner_uid, $fg_color, $bg_color]);
|
||||
|
||||
$result = $sth->rowCount();
|
||||
} else {
|
||||
$result = false;
|
||||
}
|
||||
|
||||
if (!$tr_in_progress) $pdo->commit();
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
89
classes/Logger.php
Normal file
89
classes/Logger.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
class Logger {
|
||||
/** @var Logger|null */
|
||||
private static $instance;
|
||||
|
||||
/** @var Logger_Adapter|null */
|
||||
private $adapter;
|
||||
|
||||
const LOG_DEST_SQL = "sql";
|
||||
const LOG_DEST_STDOUT = "stdout";
|
||||
const LOG_DEST_SYSLOG = "syslog";
|
||||
|
||||
const ERROR_NAMES = [
|
||||
1 => 'E_ERROR',
|
||||
2 => 'E_WARNING',
|
||||
4 => 'E_PARSE',
|
||||
8 => 'E_NOTICE',
|
||||
16 => 'E_CORE_ERROR',
|
||||
32 => 'E_CORE_WARNING',
|
||||
64 => 'E_COMPILE_ERROR',
|
||||
128 => 'E_COMPILE_WARNING',
|
||||
256 => 'E_USER_ERROR',
|
||||
512 => 'E_USER_WARNING',
|
||||
1024 => 'E_USER_NOTICE',
|
||||
2048 => 'E_STRICT',
|
||||
4096 => 'E_RECOVERABLE_ERROR',
|
||||
8192 => 'E_DEPRECATED',
|
||||
16384 => 'E_USER_DEPRECATED',
|
||||
32767 => 'E_ALL'];
|
||||
|
||||
static function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool {
|
||||
return self::get_instance()->_log_error($errno, $errstr, $file, $line, $context);
|
||||
}
|
||||
|
||||
private function _log_error(int $errno, string $errstr, string $file, int $line, string $context): bool {
|
||||
//if ($errno == E_NOTICE) return false;
|
||||
|
||||
if ($this->adapter)
|
||||
return $this->adapter->log_error($errno, $errstr, $file, $line, $context);
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
static function log(int $errno, string $errstr, string $context = ""): bool {
|
||||
return self::get_instance()->_log($errno, $errstr, $context);
|
||||
}
|
||||
|
||||
private function _log(int $errno, string $errstr, string $context = ""): bool {
|
||||
if ($this->adapter)
|
||||
return $this->adapter->log_error($errno, $errstr, '', 0, $context);
|
||||
else
|
||||
return user_error($errstr, $errno);
|
||||
}
|
||||
|
||||
private function __clone() {
|
||||
//
|
||||
}
|
||||
|
||||
function __construct() {
|
||||
switch (Config::get(Config::LOG_DESTINATION)) {
|
||||
case self::LOG_DEST_SQL:
|
||||
$this->adapter = new Logger_SQL();
|
||||
break;
|
||||
case self::LOG_DEST_SYSLOG:
|
||||
$this->adapter = new Logger_Syslog();
|
||||
break;
|
||||
case self::LOG_DEST_STDOUT:
|
||||
$this->adapter = new Logger_Stdout();
|
||||
break;
|
||||
default:
|
||||
$this->adapter = null;
|
||||
}
|
||||
|
||||
if ($this->adapter && !implements_interface($this->adapter, "Logger_Adapter"))
|
||||
user_error("Adapter for LOG_DESTINATION: " . Config::LOG_DESTINATION . " does not implement required interface.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
private static function get_instance() : Logger {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
static function get() : Logger {
|
||||
user_error("Please don't use Logger::get(), call Logger::log(...) instead.", E_USER_DEPRECATED);
|
||||
return self::get_instance();
|
||||
}
|
||||
}
|
||||
4
classes/Logger_Adapter.php
Normal file
4
classes/Logger_Adapter.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
interface Logger_Adapter {
|
||||
function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool;
|
||||
}
|
||||
61
classes/Logger_SQL.php
Normal file
61
classes/Logger_SQL.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
class Logger_SQL implements Logger_Adapter {
|
||||
|
||||
function __construct() {
|
||||
$conn = get_class($this);
|
||||
|
||||
ORM::configure(Db::get_dsn(), null, $conn);
|
||||
ORM::configure('username', Config::get(Config::DB_USER), $conn);
|
||||
ORM::configure('password', Config::get(Config::DB_PASS), $conn);
|
||||
ORM::configure('return_result_sets', true, $conn);
|
||||
}
|
||||
|
||||
function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool {
|
||||
|
||||
if (Config::get_schema_version() > 117) {
|
||||
|
||||
// limit context length, DOMDocument dumps entire XML in here sometimes, which may be huge
|
||||
$context = mb_substr($context, 0, 8192);
|
||||
|
||||
$server_params = [
|
||||
"Real IP" => "HTTP_X_REAL_IP",
|
||||
"Forwarded For" => "HTTP_X_FORWARDED_FOR",
|
||||
"Forwarded Protocol" => "HTTP_X_FORWARDED_PROTO",
|
||||
"Remote IP" => "REMOTE_ADDR",
|
||||
"Request URI" => "REQUEST_URI",
|
||||
"User agent" => "HTTP_USER_AGENT",
|
||||
];
|
||||
|
||||
foreach ($server_params as $n => $p) {
|
||||
if (isset($_SERVER[$p]))
|
||||
$context .= "\n$n: " . $_SERVER[$p];
|
||||
}
|
||||
|
||||
// passed error message may contain invalid unicode characters, failing to insert an error here
|
||||
// would break the execution entirely by generating an actual fatal error instead of a E_WARNING etc
|
||||
$errstr = UConverter::transcode($errstr, 'UTF-8', 'UTF-8');
|
||||
$context = UConverter::transcode($context, 'UTF-8', 'UTF-8');
|
||||
|
||||
// can't use $_SESSION["uid"] ?? null because what if its, for example, false? or zero?
|
||||
// this would cause a PDOException on insert below
|
||||
$owner_uid = !empty($_SESSION["uid"]) ? $_SESSION["uid"] : null;
|
||||
|
||||
$entry = ORM::for_table('ttrss_error_log', get_class($this))->create();
|
||||
|
||||
$entry->set([
|
||||
'errno' => $errno,
|
||||
'errstr' => $errstr,
|
||||
'filename' => $file,
|
||||
'lineno' => (int)$line,
|
||||
'context' => $context,
|
||||
'owner_uid' => $owner_uid,
|
||||
'created_at' => Db::NOW(),
|
||||
]);
|
||||
|
||||
return $entry->save();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
31
classes/Logger_Stdout.php
Normal file
31
classes/Logger_Stdout.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
class Logger_Stdout implements Logger_Adapter {
|
||||
|
||||
function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool {
|
||||
|
||||
switch ($errno) {
|
||||
case E_ERROR:
|
||||
case E_PARSE:
|
||||
case E_CORE_ERROR:
|
||||
case E_COMPILE_ERROR:
|
||||
case E_USER_ERROR:
|
||||
$priority = LOG_ERR;
|
||||
break;
|
||||
case E_WARNING:
|
||||
case E_CORE_WARNING:
|
||||
case E_COMPILE_WARNING:
|
||||
case E_USER_WARNING:
|
||||
$priority = LOG_WARNING;
|
||||
break;
|
||||
default:
|
||||
$priority = LOG_INFO;
|
||||
}
|
||||
|
||||
$errname = Logger::ERROR_NAMES[$errno] . " ($errno)";
|
||||
|
||||
print "[EEE] $priority $errname ($file:$line) $errstr\n";
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
31
classes/Logger_Syslog.php
Normal file
31
classes/Logger_Syslog.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
class Logger_Syslog implements Logger_Adapter {
|
||||
|
||||
function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool {
|
||||
|
||||
switch ($errno) {
|
||||
case E_ERROR:
|
||||
case E_PARSE:
|
||||
case E_CORE_ERROR:
|
||||
case E_COMPILE_ERROR:
|
||||
case E_USER_ERROR:
|
||||
$priority = LOG_ERR;
|
||||
break;
|
||||
case E_WARNING:
|
||||
case E_CORE_WARNING:
|
||||
case E_COMPILE_WARNING:
|
||||
case E_USER_WARNING:
|
||||
$priority = LOG_WARNING;
|
||||
break;
|
||||
default:
|
||||
$priority = LOG_INFO;
|
||||
}
|
||||
|
||||
$errname = Logger::ERROR_NAMES[$errno] . " ($errno)";
|
||||
|
||||
syslog($priority, "[tt-rss] $errname ($file:$line) $errstr");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
65
classes/Mailer.php
Normal file
65
classes/Mailer.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
class Mailer {
|
||||
private string $last_error = "";
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @return bool|int bool if the default mail function handled the request, otherwise an int as described in Mailer#mail()
|
||||
*/
|
||||
function mail(array $params) {
|
||||
|
||||
$to_name = $params["to_name"] ?? "";
|
||||
$to_address = $params["to_address"];
|
||||
$subject = $params["subject"];
|
||||
$message = $params["message"];
|
||||
// $message_html = $params["message_html"] ?? "";
|
||||
$from_name = $params["from_name"] ?? Config::get(Config::SMTP_FROM_NAME);
|
||||
$from_address = $params["from_address"] ?? Config::get(Config::SMTP_FROM_ADDRESS);
|
||||
$additional_headers = $params["headers"] ?? [];
|
||||
|
||||
$from_combined = $from_name ? "$from_name <$from_address>" : $from_address;
|
||||
$to_combined = $to_name ? "$to_name <$to_address>" : $to_address;
|
||||
|
||||
if (Config::get(Config::LOG_SENT_MAIL))
|
||||
Logger::log(E_USER_NOTICE, "Sending mail from $from_combined to $to_combined [$subject]: $message");
|
||||
|
||||
// HOOK_SEND_MAIL plugin instructions:
|
||||
// 1. return 1 or true if mail is handled
|
||||
// 2. return -1 if there's been a fatal error and no further action is allowed
|
||||
// 3. any other return value will allow cycling to the next handler and, eventually, to default mail() function
|
||||
// 4. set error message if needed via passed Mailer instance function set_error()
|
||||
|
||||
$hooks_tried = 0;
|
||||
|
||||
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_SEND_MAIL) as $p) {
|
||||
$rc = $p->hook_send_mail($this, $params);
|
||||
|
||||
if ($rc == 1)
|
||||
return $rc;
|
||||
|
||||
if ($rc == -1)
|
||||
return 0;
|
||||
|
||||
++$hooks_tried;
|
||||
}
|
||||
|
||||
$headers = [ "From: $from_combined", "Content-Type: text/plain; charset=UTF-8" ];
|
||||
|
||||
$rc = mail($to_combined, $subject, $message, implode("\r\n", [...$headers, ...$additional_headers]));
|
||||
|
||||
if (!$rc) {
|
||||
$this->set_error(error_get_last()['message'] ?? T_sprintf("Unknown error while sending mail. Hooks tried: %d.", $hooks_tried));
|
||||
}
|
||||
|
||||
return $rc;
|
||||
}
|
||||
|
||||
function set_error(string $message): void {
|
||||
$this->last_error = $message;
|
||||
user_error("Error sending mail: $message", E_USER_WARNING);
|
||||
}
|
||||
|
||||
function error(): string {
|
||||
return $this->last_error;
|
||||
}
|
||||
}
|
||||
699
classes/OPML.php
Normal file
699
classes/OPML.php
Normal file
@@ -0,0 +1,699 @@
|
||||
<?php
|
||||
class OPML extends Handler_Protected {
|
||||
|
||||
function csrf_ignore(string $method): bool {
|
||||
$csrf_ignored = array("export", "import");
|
||||
|
||||
return array_search($method, $csrf_ignored) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|int|void false if writing the file failed, true if printing succeeded, int if bytes were written to a file, or void if $owner_uid is missing
|
||||
*/
|
||||
function export() {
|
||||
$output_name = sprintf("tt-rss_%s_%s.opml", $_SESSION["name"], date("Y-m-d"));
|
||||
$include_settings = $_REQUEST["include_settings"] == "1";
|
||||
$owner_uid = $_SESSION["uid"];
|
||||
|
||||
$rc = $this->opml_export($output_name, $owner_uid, false, $include_settings);
|
||||
|
||||
return $rc;
|
||||
}
|
||||
|
||||
function import(): void {
|
||||
$owner_uid = $_SESSION["uid"];
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
|
||||
print "<html>
|
||||
<head>
|
||||
".stylesheet_tag("themes/light.css")."
|
||||
<title>".__("OPML Utility")."</title>
|
||||
<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\"/>
|
||||
</head>
|
||||
<body class='claro ttrss_utility'>
|
||||
<h1>".__('OPML Utility')."</h1><div class='content'>";
|
||||
|
||||
Feeds::_add_cat("Imported feeds", $owner_uid);
|
||||
|
||||
$this->opml_notice(__("Importing OPML..."));
|
||||
|
||||
$this->opml_import($owner_uid);
|
||||
|
||||
print "<br><form method=\"GET\" action=\"prefs.php\">
|
||||
<input type=\"submit\" value=\"".__("Return to preferences")."\">
|
||||
</form>";
|
||||
|
||||
print "</div></body></html>";
|
||||
}
|
||||
|
||||
// Export
|
||||
|
||||
private function opml_export_category(int $owner_uid, int $cat_id, bool $hide_private_feeds = false, bool $include_settings = true): string {
|
||||
|
||||
if ($hide_private_feeds)
|
||||
$hide_qpart = "(private IS false AND auth_login = '' AND auth_pass = '')";
|
||||
else
|
||||
$hide_qpart = "true";
|
||||
|
||||
$out = "";
|
||||
|
||||
$ttrss_specific_qpart = "";
|
||||
|
||||
if ($cat_id) {
|
||||
$sth = $this->pdo->prepare("SELECT title,order_id
|
||||
FROM ttrss_feed_categories WHERE id = ?
|
||||
AND owner_uid = ?");
|
||||
$sth->execute([$cat_id, $owner_uid]);
|
||||
$row = $sth->fetch();
|
||||
$cat_title = htmlspecialchars($row['title']);
|
||||
|
||||
if ($include_settings) {
|
||||
$order_id = (int)$row["order_id"];
|
||||
$ttrss_specific_qpart = "ttrssSortOrder=\"$order_id\"";
|
||||
}
|
||||
} else {
|
||||
$cat_title = "";
|
||||
}
|
||||
|
||||
if ($cat_title) $out .= "<outline text=\"$cat_title\" $ttrss_specific_qpart>\n";
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT id,title
|
||||
FROM ttrss_feed_categories WHERE
|
||||
(parent_cat = :cat OR (:cat = 0 AND parent_cat IS NULL)) AND
|
||||
owner_uid = :uid ORDER BY order_id, title");
|
||||
|
||||
$sth->execute([':cat' => $cat_id, ':uid' => $owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$out .= $this->opml_export_category($owner_uid, $line["id"], $hide_private_feeds, $include_settings);
|
||||
}
|
||||
|
||||
$fsth = $this->pdo->prepare("select title, feed_url, site_url, update_interval, order_id, purge_interval
|
||||
FROM ttrss_feeds WHERE
|
||||
(cat_id = :cat OR (:cat = 0 AND cat_id IS NULL)) AND owner_uid = :uid AND $hide_qpart
|
||||
ORDER BY order_id, title");
|
||||
|
||||
$fsth->execute([':cat' => $cat_id, ':uid' => $owner_uid]);
|
||||
|
||||
while ($fline = $fsth->fetch()) {
|
||||
$title = htmlspecialchars($fline["title"]);
|
||||
$url = htmlspecialchars($fline["feed_url"]);
|
||||
$site_url = htmlspecialchars($fline["site_url"]);
|
||||
|
||||
if ($include_settings) {
|
||||
$update_interval = (int)$fline["update_interval"];
|
||||
$order_id = (int)$fline["order_id"];
|
||||
$purge_interval = (int)$fline["purge_interval"];
|
||||
|
||||
$ttrss_specific_qpart = "ttrssSortOrder=\"$order_id\" ttrssPurgeInterval=\"$purge_interval\" ttrssUpdateInterval=\"$update_interval\"";
|
||||
} else {
|
||||
$ttrss_specific_qpart = "";
|
||||
}
|
||||
|
||||
if ($site_url) {
|
||||
$html_url_qpart = "htmlUrl=\"$site_url\"";
|
||||
} else {
|
||||
$html_url_qpart = "";
|
||||
}
|
||||
|
||||
$out .= "<outline type=\"rss\" text=\"$title\" xmlUrl=\"$url\" $ttrss_specific_qpart $html_url_qpart/>\n";
|
||||
}
|
||||
|
||||
if ($cat_title) $out .= "</outline>\n";
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|int|void false if writing the file failed, true if printing succeeded, int if bytes were written to a file, or void if $owner_uid is missing
|
||||
*/
|
||||
function opml_export(string $filename, int $owner_uid, bool $hide_private_feeds = false, bool $include_settings = true, bool $file_output = false) {
|
||||
if (!$owner_uid) return;
|
||||
|
||||
if (!$file_output)
|
||||
if (!isset($_REQUEST["debug"])) {
|
||||
header("Content-type: application/xml+opml");
|
||||
header("Content-Disposition: attachment; filename=$filename");
|
||||
} else {
|
||||
header("Content-type: text/xml");
|
||||
}
|
||||
|
||||
$out = "<?xml version=\"1.0\" encoding=\"utf-8\"?".">";
|
||||
|
||||
$out .= "<opml version=\"1.0\">";
|
||||
$out .= "<head>
|
||||
<dateCreated>" . date("r", time()) . "</dateCreated>
|
||||
<title>Tiny Tiny RSS Feed Export</title>
|
||||
</head>";
|
||||
$out .= "<body>";
|
||||
|
||||
$out .= $this->opml_export_category($owner_uid, 0, $hide_private_feeds, $include_settings);
|
||||
|
||||
# export tt-rss settings
|
||||
|
||||
if ($include_settings) {
|
||||
$out .= "<outline text=\"tt-rss-prefs\" schema-version=\"".Config::SCHEMA_VERSION."\">";
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs2 WHERE
|
||||
profile IS NULL AND owner_uid = ? ORDER BY pref_name");
|
||||
$sth->execute([$owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$name = $line["pref_name"];
|
||||
$value = htmlspecialchars($line["value"]);
|
||||
|
||||
$out .= "<outline pref-name=\"$name\" value=\"$value\"/>";
|
||||
}
|
||||
|
||||
$out .= "</outline>";
|
||||
|
||||
$out .= "<outline text=\"tt-rss-labels\" schema-version=\"".Config::SCHEMA_VERSION."\">";
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT * FROM ttrss_labels2 WHERE
|
||||
owner_uid = ?");
|
||||
$sth->execute([$owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$name = htmlspecialchars($line['caption']);
|
||||
$fg_color = htmlspecialchars($line['fg_color']);
|
||||
$bg_color = htmlspecialchars($line['bg_color']);
|
||||
|
||||
$out .= "<outline label-name=\"$name\" label-fg-color=\"$fg_color\" label-bg-color=\"$bg_color\"/>";
|
||||
|
||||
}
|
||||
|
||||
$out .= "</outline>";
|
||||
|
||||
$out .= "<outline text=\"tt-rss-filters\" schema-version=\"".Config::SCHEMA_VERSION."\">";
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2
|
||||
WHERE owner_uid = ? ORDER BY id");
|
||||
$sth->execute([$owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch(PDO::FETCH_ASSOC)) {
|
||||
$line["rules"] = array();
|
||||
$line["actions"] = array();
|
||||
|
||||
$tmph = $this->pdo->prepare("SELECT * FROM ttrss_filters2_rules
|
||||
WHERE filter_id = ?");
|
||||
$tmph->execute([$line['id']]);
|
||||
|
||||
while ($tmp_line = $tmph->fetch(PDO::FETCH_ASSOC)) {
|
||||
unset($tmp_line["id"]);
|
||||
unset($tmp_line["filter_id"]);
|
||||
|
||||
$cat_filter = $tmp_line["cat_filter"];
|
||||
|
||||
if (!$tmp_line["match_on"]) {
|
||||
if ($cat_filter && $tmp_line["cat_id"] || $tmp_line["feed_id"]) {
|
||||
$tmp_line["feed"] = Feeds::_get_title(
|
||||
$cat_filter ? $tmp_line["cat_id"] : $tmp_line["feed_id"],
|
||||
$cat_filter);
|
||||
} else {
|
||||
$tmp_line["feed"] = "";
|
||||
}
|
||||
} else {
|
||||
$match = [];
|
||||
foreach (json_decode($tmp_line["match_on"], true) as $feed_id) {
|
||||
|
||||
if (strpos($feed_id, "CAT:") === 0) {
|
||||
$feed_id = (int)substr($feed_id, 4);
|
||||
if ($feed_id) {
|
||||
array_push($match, [Feeds::_get_cat_title($feed_id), true, false]);
|
||||
} else {
|
||||
array_push($match, [0, true, true]);
|
||||
}
|
||||
} else {
|
||||
if ($feed_id) {
|
||||
array_push($match, [Feeds::_get_title((int)$feed_id), false, false]);
|
||||
} else {
|
||||
array_push($match, [0, false, true]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$tmp_line["match"] = $match;
|
||||
unset($tmp_line["match_on"]);
|
||||
}
|
||||
|
||||
unset($tmp_line["feed_id"]);
|
||||
unset($tmp_line["cat_id"]);
|
||||
|
||||
array_push($line["rules"], $tmp_line);
|
||||
}
|
||||
|
||||
$tmph = $this->pdo->prepare("SELECT * FROM ttrss_filters2_actions
|
||||
WHERE filter_id = ?");
|
||||
$tmph->execute([$line['id']]);
|
||||
|
||||
while ($tmp_line = $tmph->fetch(PDO::FETCH_ASSOC)) {
|
||||
unset($tmp_line["id"]);
|
||||
unset($tmp_line["filter_id"]);
|
||||
|
||||
array_push($line["actions"], $tmp_line);
|
||||
}
|
||||
|
||||
unset($line["id"]);
|
||||
unset($line["owner_uid"]);
|
||||
$filter = json_encode($line);
|
||||
|
||||
$out .= "<outline filter-type=\"2\"><![CDATA[$filter]]></outline>";
|
||||
|
||||
}
|
||||
|
||||
|
||||
$out .= "</outline>";
|
||||
}
|
||||
|
||||
$out .= "</body></opml>";
|
||||
|
||||
// Format output.
|
||||
$doc = new DOMDocument();
|
||||
$doc->formatOutput = true;
|
||||
$doc->preserveWhiteSpace = false;
|
||||
$doc->loadXML($out);
|
||||
|
||||
$xpath = new DOMXPath($doc);
|
||||
$outlines = $xpath->query("//outline[@title]");
|
||||
|
||||
// cleanup empty categories
|
||||
foreach ($outlines as $node) {
|
||||
if ($node->getElementsByTagName('outline')->length == 0)
|
||||
$node->parentNode->removeChild($node);
|
||||
}
|
||||
|
||||
$res = $doc->saveXML();
|
||||
|
||||
/* // saveXML uses a two-space indent. Change to tabs.
|
||||
$res = preg_replace_callback('/^(?: )+/mu',
|
||||
create_function(
|
||||
'$matches',
|
||||
'return str_repeat("\t", intval(strlen($matches[0])/2));'),
|
||||
$res); */
|
||||
|
||||
if ($file_output)
|
||||
return file_put_contents($filename, $res) > 0;
|
||||
|
||||
print $res;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Import
|
||||
|
||||
private function opml_import_feed(DOMNode $node, int $cat_id, int $owner_uid, int $nest): void {
|
||||
$attrs = $node->attributes;
|
||||
|
||||
$feed_title = mb_substr($attrs->getNamedItem('text')->nodeValue, 0, 250);
|
||||
if (!$feed_title) $feed_title = mb_substr($attrs->getNamedItem('title')->nodeValue, 0, 250);
|
||||
|
||||
$feed_url = $attrs->getNamedItem('xmlUrl')->nodeValue;
|
||||
if (!$feed_url) $feed_url = $attrs->getNamedItem('xmlURL')->nodeValue;
|
||||
|
||||
$site_url = mb_substr($attrs->getNamedItem('htmlUrl')->nodeValue, 0, 250);
|
||||
|
||||
if ($feed_url) {
|
||||
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE
|
||||
feed_url = ? AND owner_uid = ?");
|
||||
$sth->execute([$feed_url, $owner_uid]);
|
||||
|
||||
if (!$feed_title) $feed_title = '[Unknown]';
|
||||
|
||||
if (!$sth->fetch()) {
|
||||
#$this->opml_notice("[FEED] [$feed_title/$feed_url] dst_CAT=$cat_id");
|
||||
$this->opml_notice(T_sprintf("Adding feed: %s", $feed_title == '[Unknown]' ? $feed_url : $feed_title), $nest);
|
||||
|
||||
if (!$cat_id) $cat_id = null;
|
||||
|
||||
$update_interval = (int) $attrs->getNamedItem('ttrssUpdateInterval')->nodeValue;
|
||||
if (!$update_interval) $update_interval = 0;
|
||||
|
||||
$order_id = (int) $attrs->getNamedItem('ttrssSortOrder')->nodeValue;
|
||||
if (!$order_id) $order_id = 0;
|
||||
|
||||
$purge_interval = (int) $attrs->getNamedItem('ttrssPurgeInterval')->nodeValue;
|
||||
if (!$purge_interval) $purge_interval = 0;
|
||||
|
||||
$sth = $this->pdo->prepare("INSERT INTO ttrss_feeds
|
||||
(title, feed_url, owner_uid, cat_id, site_url, order_id, update_interval, purge_interval) VALUES
|
||||
(?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
|
||||
$sth->execute([$feed_title, $feed_url, $owner_uid, $cat_id, $site_url, $order_id, $update_interval, $purge_interval]);
|
||||
|
||||
} else {
|
||||
$this->opml_notice(T_sprintf("Duplicate feed: %s", $feed_title == '[Unknown]' ? $feed_url : $feed_title), $nest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function opml_import_label(DOMNode $node, int $owner_uid, int $nest): void {
|
||||
$attrs = $node->attributes;
|
||||
$label_name = $attrs->getNamedItem('label-name')->nodeValue;
|
||||
|
||||
if ($label_name) {
|
||||
$fg_color = $attrs->getNamedItem('label-fg-color')->nodeValue;
|
||||
$bg_color = $attrs->getNamedItem('label-bg-color')->nodeValue;
|
||||
|
||||
if (!Labels::find_id($label_name, $owner_uid)) {
|
||||
$this->opml_notice(T_sprintf("Adding label %s", htmlspecialchars($label_name)), $nest);
|
||||
Labels::create($label_name, $fg_color, $bg_color, $owner_uid);
|
||||
} else {
|
||||
$this->opml_notice(T_sprintf("Duplicate label: %s", htmlspecialchars($label_name)), $nest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function opml_import_preference(DOMNode $node, int $owner_uid, int $nest): void {
|
||||
$attrs = $node->attributes;
|
||||
$pref_name = $attrs->getNamedItem('pref-name')->nodeValue;
|
||||
|
||||
if ($pref_name) {
|
||||
$pref_value = $attrs->getNamedItem('value')->nodeValue;
|
||||
|
||||
$this->opml_notice(T_sprintf("Setting preference key %s to %s",
|
||||
$pref_name, $pref_value), $nest);
|
||||
|
||||
set_pref($pref_name, $pref_value, $owner_uid);
|
||||
}
|
||||
}
|
||||
|
||||
private function opml_import_filter(DOMNode $node, int $owner_uid, int $nest): void {
|
||||
$attrs = $node->attributes;
|
||||
|
||||
$filter_type = $attrs->getNamedItem('filter-type')->nodeValue;
|
||||
|
||||
if ($filter_type == '2') {
|
||||
$filter = json_decode($node->nodeValue, true);
|
||||
|
||||
if ($filter) {
|
||||
$match_any_rule = bool_to_sql_bool($filter["match_any_rule"]);
|
||||
$enabled = bool_to_sql_bool($filter["enabled"]);
|
||||
$inverse = bool_to_sql_bool($filter["inverse"]);
|
||||
$title = $filter["title"];
|
||||
|
||||
//print "F: $title, $inverse, $enabled, $match_any_rule";
|
||||
|
||||
$sth = $this->pdo->prepare("INSERT INTO ttrss_filters2 (match_any_rule,enabled,inverse,title,owner_uid)
|
||||
VALUES (?, ?, ?, ?, ?)");
|
||||
|
||||
$sth->execute([$match_any_rule, $enabled, $inverse, $title, $owner_uid]);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT MAX(id) AS id FROM ttrss_filters2 WHERE
|
||||
owner_uid = ?");
|
||||
$sth->execute([$owner_uid]);
|
||||
|
||||
$row = $sth->fetch();
|
||||
$filter_id = $row['id'];
|
||||
|
||||
if ($filter_id) {
|
||||
$this->opml_notice(T_sprintf("Adding filter %s...", $title), $nest);
|
||||
//$this->opml_notice(json_encode($filter));
|
||||
|
||||
foreach ($filter["rules"] as $rule) {
|
||||
$feed_id = null;
|
||||
$cat_id = null;
|
||||
|
||||
if ($rule["match"] ?? false) {
|
||||
|
||||
$match_on = [];
|
||||
|
||||
foreach ($rule["match"] as $match) {
|
||||
list ($name, $is_cat, $is_id) = $match;
|
||||
|
||||
if ($is_id) {
|
||||
array_push($match_on, ($is_cat ? "CAT:" : "") . $name);
|
||||
} else {
|
||||
|
||||
$match_id = Feeds::_find_by_title($name, $is_cat, $owner_uid);
|
||||
|
||||
if ($match_id) {
|
||||
if ($is_cat) {
|
||||
array_push($match_on, "CAT:$match_id");
|
||||
} else {
|
||||
array_push($match_on, $match_id);
|
||||
}
|
||||
}
|
||||
|
||||
/*if (!$is_cat) {
|
||||
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
|
||||
WHERE title = ? AND owner_uid = ?");
|
||||
|
||||
$tsth->execute([$name, $_SESSION['uid']]);
|
||||
|
||||
if ($row = $tsth->fetch()) {
|
||||
$match_id = $row['id'];
|
||||
|
||||
array_push($match_on, $match_id);
|
||||
}
|
||||
} else {
|
||||
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories
|
||||
WHERE title = ? AND owner_uid = ?");
|
||||
$tsth->execute([$name, $_SESSION['uid']]);
|
||||
|
||||
if ($row = $tsth->fetch()) {
|
||||
$match_id = $row['id'];
|
||||
|
||||
array_push($match_on, "CAT:$match_id");
|
||||
}
|
||||
} */
|
||||
}
|
||||
}
|
||||
|
||||
$reg_exp = $rule["reg_exp"];
|
||||
$filter_type = (int)$rule["filter_type"];
|
||||
$inverse = bool_to_sql_bool($rule["inverse"]);
|
||||
$match_on = json_encode($match_on);
|
||||
|
||||
$usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_rules
|
||||
(feed_id,cat_id,match_on,filter_id,filter_type,reg_exp,cat_filter,inverse)
|
||||
VALUES
|
||||
(NULL, NULL, ?, ?, ?, ?, false, ?)");
|
||||
$usth->execute([$match_on, $filter_id, $filter_type, $reg_exp, $inverse]);
|
||||
|
||||
} else {
|
||||
|
||||
$match_id = Feeds::_find_by_title($rule['feed'] ?? "", $rule['cat_filter'], $owner_uid);
|
||||
|
||||
if ($match_id) {
|
||||
if ($rule['cat_filter']) {
|
||||
$cat_id = $match_id;
|
||||
} else {
|
||||
$feed_id = $match_id;
|
||||
}
|
||||
}
|
||||
|
||||
/*if (!$rule["cat_filter"]) {
|
||||
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
|
||||
WHERE title = ? AND owner_uid = ?");
|
||||
|
||||
$tsth->execute([$rule['feed'], $_SESSION['uid']]);
|
||||
|
||||
if ($row = $tsth->fetch()) {
|
||||
$feed_id = $row['id'];
|
||||
}
|
||||
} else {
|
||||
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories
|
||||
WHERE title = ? AND owner_uid = ?");
|
||||
|
||||
$tsth->execute([$rule['feed'], $_SESSION['uid']]);
|
||||
|
||||
if ($row = $tsth->fetch()) {
|
||||
$feed_id = $row['id'];
|
||||
}
|
||||
} */
|
||||
|
||||
$cat_filter = bool_to_sql_bool($rule["cat_filter"]);
|
||||
$reg_exp = $rule["reg_exp"];
|
||||
$filter_type = (int)$rule["filter_type"];
|
||||
$inverse = bool_to_sql_bool($rule["inverse"]);
|
||||
|
||||
$usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_rules
|
||||
(feed_id,cat_id,filter_id,filter_type,reg_exp,cat_filter,inverse)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?, ?)");
|
||||
$usth->execute([$feed_id, $cat_id, $filter_id, $filter_type, $reg_exp, $cat_filter, $inverse]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($filter["actions"] as $action) {
|
||||
|
||||
$action_id = (int)$action["action_id"];
|
||||
$action_param = $action["action_param"];
|
||||
|
||||
$usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_actions
|
||||
(filter_id,action_id,action_param)
|
||||
VALUES
|
||||
(?, ?, ?)");
|
||||
$usth->execute([$filter_id, $action_id, $action_param]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function opml_import_category(DOMDocument $doc, ?DOMNode $root_node, int $owner_uid, int $parent_id, int $nest): void {
|
||||
$default_cat_id = (int) $this->get_feed_category('Imported feeds', $owner_uid, 0);
|
||||
|
||||
if ($root_node) {
|
||||
$cat_title = mb_substr($root_node->attributes->getNamedItem('text')->nodeValue, 0, 250);
|
||||
|
||||
if (!$cat_title)
|
||||
$cat_title = mb_substr($root_node->attributes->getNamedItem('title')->nodeValue, 0, 250);
|
||||
|
||||
if (!in_array($cat_title, array("tt-rss-filters", "tt-rss-labels", "tt-rss-prefs"))) {
|
||||
$cat_id = $this->get_feed_category($cat_title, $owner_uid, $parent_id);
|
||||
|
||||
if ($cat_id === 0) {
|
||||
$order_id = (int) $root_node->attributes->getNamedItem('ttrssSortOrder')->nodeValue;
|
||||
|
||||
Feeds::_add_cat($cat_title, $owner_uid, $parent_id ? $parent_id : null, (int)$order_id);
|
||||
$cat_id = $this->get_feed_category($cat_title, $owner_uid, $parent_id);
|
||||
}
|
||||
|
||||
} else {
|
||||
$cat_id = 0;
|
||||
}
|
||||
|
||||
$outlines = $root_node->childNodes;
|
||||
|
||||
} else {
|
||||
$xpath = new DOMXPath($doc);
|
||||
$outlines = $xpath->query("//opml/body/outline");
|
||||
|
||||
$cat_id = 0;
|
||||
$cat_title = false;
|
||||
}
|
||||
|
||||
//$this->opml_notice("[CAT] $cat_title id: $cat_id P_id: $parent_id");
|
||||
$this->opml_notice(T_sprintf("Processing category: %s", $cat_title ? $cat_title : __("Uncategorized")), $nest);
|
||||
|
||||
foreach ($outlines as $node) {
|
||||
if ($node->hasAttributes() && strtolower($node->tagName) == "outline") {
|
||||
$attrs = $node->attributes;
|
||||
$node_cat_title = $attrs->getNamedItem('text') ? $attrs->getNamedItem('text')->nodeValue : false;
|
||||
|
||||
if (!$node_cat_title)
|
||||
$node_cat_title = $attrs->getNamedItem('title') ? $attrs->getNamedItem('title')->nodeValue : false;
|
||||
|
||||
$node_feed_url = $attrs->getNamedItem('xmlUrl') ? $attrs->getNamedItem('xmlUrl')->nodeValue : false;
|
||||
|
||||
if ($node_cat_title && !$node_feed_url) {
|
||||
$this->opml_import_category($doc, $node, $owner_uid, $cat_id, $nest+1);
|
||||
} else {
|
||||
|
||||
if (!$cat_id) {
|
||||
$dst_cat_id = $default_cat_id;
|
||||
} else {
|
||||
$dst_cat_id = $cat_id;
|
||||
}
|
||||
|
||||
switch ($cat_title) {
|
||||
case "tt-rss-prefs":
|
||||
$this->opml_import_preference($node, $owner_uid, $nest+1);
|
||||
break;
|
||||
case "tt-rss-labels":
|
||||
$this->opml_import_label($node, $owner_uid, $nest+1);
|
||||
break;
|
||||
case "tt-rss-filters":
|
||||
$this->opml_import_filter($node, $owner_uid, $nest+1);
|
||||
break;
|
||||
default:
|
||||
$this->opml_import_feed($node, $dst_cat_id, $owner_uid, $nest+1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** $filename is optional; assumes HTTP upload with $_FILES otherwise */
|
||||
/**
|
||||
* @return bool|void false on failure, true if successful, void if $owner_uid is missing
|
||||
*/
|
||||
function opml_import(int $owner_uid, string $filename = "") {
|
||||
if (!$owner_uid) return;
|
||||
|
||||
if (!$filename) {
|
||||
if ($_FILES['opml_file']['error'] != 0) {
|
||||
print_error(T_sprintf("Upload failed with error code %d",
|
||||
$_FILES['opml_file']['error']));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_uploaded_file($_FILES['opml_file']['tmp_name'])) {
|
||||
$tmp_file = (string)tempnam(Config::get(Config::CACHE_DIR) . '/upload', 'opml');
|
||||
|
||||
$result = move_uploaded_file($_FILES['opml_file']['tmp_name'],
|
||||
$tmp_file);
|
||||
|
||||
if (!$result) {
|
||||
print_error(__("Unable to move uploaded file."));
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
print_error(__('Error: please upload OPML file.'));
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
$tmp_file = $filename;
|
||||
}
|
||||
|
||||
if (!is_readable($tmp_file)) {
|
||||
$this->opml_notice(T_sprintf("Error: file is not readable: %s", $filename));
|
||||
return false;
|
||||
}
|
||||
|
||||
$doc = new DOMDocument();
|
||||
|
||||
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
|
||||
libxml_disable_entity_loader(false);
|
||||
}
|
||||
|
||||
$loaded = $doc->load($tmp_file);
|
||||
|
||||
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
|
||||
libxml_disable_entity_loader(true);
|
||||
}
|
||||
|
||||
// only remove temporary i.e. HTTP uploaded files
|
||||
if (!$filename)
|
||||
unlink($tmp_file);
|
||||
|
||||
if ($loaded) {
|
||||
// we're using ORM while importing so we can't transaction-lock things anymore
|
||||
//$this->pdo->beginTransaction();
|
||||
$this->opml_import_category($doc, null, $owner_uid, 0, 0);
|
||||
//$this->pdo->commit();
|
||||
} else {
|
||||
$this->opml_notice(__('Error while parsing document.'));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function opml_notice(string $msg, int $prefix_length = 0): void {
|
||||
if (php_sapi_name() == "cli") {
|
||||
Debug::log(str_repeat(" ", $prefix_length) . $msg);
|
||||
} else {
|
||||
// TODO: use better separator i.e. CSS-defined span of certain width or something
|
||||
print str_repeat(" ", $prefix_length) . $msg . "<br/>";
|
||||
}
|
||||
}
|
||||
|
||||
function get_feed_category(string $feed_cat, int $owner_uid, int $parent_cat_id) : int {
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories
|
||||
WHERE title = :title
|
||||
AND (parent_cat = :parent OR (:parent = 0 AND parent_cat IS NULL))
|
||||
AND owner_uid = :uid");
|
||||
|
||||
$sth->execute([':title' => $feed_cat, ':parent' => $parent_cat_id, ':uid' => $owner_uid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
return $row['id'];
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
716
classes/Plugin.php
Normal file
716
classes/Plugin.php
Normal file
@@ -0,0 +1,716 @@
|
||||
<?php
|
||||
abstract class Plugin {
|
||||
const API_VERSION_COMPAT = 1;
|
||||
|
||||
/** @var PDO $pdo */
|
||||
protected $pdo;
|
||||
|
||||
/**
|
||||
* @param PluginHost $host
|
||||
*
|
||||
* @return void
|
||||
* */
|
||||
abstract function init($host);
|
||||
|
||||
/** @return array<null|float|string|bool> */
|
||||
abstract function about();
|
||||
// return array(1.0, "plugin", "No description", "No author", false);
|
||||
|
||||
function __construct() {
|
||||
$this->pdo = Db::pdo();
|
||||
}
|
||||
|
||||
/** @return array<string,bool> */
|
||||
function flags() {
|
||||
/* associative array, possible keys:
|
||||
needs_curl = boolean
|
||||
*/
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $method
|
||||
*
|
||||
* @return bool */
|
||||
function is_public_method($method) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $method
|
||||
*
|
||||
* @return bool */
|
||||
function csrf_ignore($method) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return string */
|
||||
function get_js() {
|
||||
return "";
|
||||
}
|
||||
|
||||
/** @return string */
|
||||
function get_login_js() {
|
||||
return "";
|
||||
}
|
||||
|
||||
/** @return string */
|
||||
function get_css() {
|
||||
return "";
|
||||
}
|
||||
|
||||
/** @return string */
|
||||
function get_prefs_js() {
|
||||
return "";
|
||||
}
|
||||
|
||||
/** @return int */
|
||||
function api_version() {
|
||||
return Plugin::API_VERSION_COMPAT;
|
||||
}
|
||||
|
||||
/* gettext-related helpers */
|
||||
|
||||
/**
|
||||
* @param string $msgid
|
||||
*
|
||||
* @return string */
|
||||
function __($msgid) {
|
||||
/** @var Plugin $this -- this is a strictly template-related hack */
|
||||
return _dgettext(PluginHost::object_to_domain($this), $msgid);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $singular
|
||||
* @param string $plural
|
||||
* @param int $number
|
||||
*
|
||||
* @return string */
|
||||
function _ngettext($singular, $plural, $number) {
|
||||
/** @var Plugin $this -- this is a strictly template-related hack */
|
||||
return _dngettext(PluginHost::object_to_domain($this), $singular, $plural, $number);
|
||||
}
|
||||
|
||||
/** @return string */
|
||||
function T_sprintf() {
|
||||
$args = func_get_args();
|
||||
$msgid = array_shift($args);
|
||||
|
||||
return vsprintf($this->__($msgid), $args);
|
||||
}
|
||||
|
||||
/* plugin hook methods */
|
||||
|
||||
/* GLOBAL hooks are invoked in global context, only available to system plugins (loaded via .env for all users) */
|
||||
|
||||
/** Adds buttons for article (on the right) - e.g. mail, share, add note. Generated markup must be valid XML.
|
||||
* @param array<string,mixed> $line
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_ARTICLE_BUTTON
|
||||
* @see Plugin::hook_article_left_button()
|
||||
*/
|
||||
function hook_article_button($line) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Allows plugins to alter article data as gathered from feed XML, i.e. embed images, get full text content, etc.
|
||||
* @param array<string,mixed> $article
|
||||
* @return array<string,mixed>
|
||||
* @see PluginHost::HOOK_ARTICLE_FILTER
|
||||
*/
|
||||
function hook_article_filter($article) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Allow adding new UI elements (e.g. accordion panes) to (top) tab contents in Preferences
|
||||
* @param string $tab
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_PREFS_TAB
|
||||
*/
|
||||
function hook_prefs_tab($tab) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** Allow adding new content to various sections of preferences UI (i.e. OPML import/export pane)
|
||||
* @param string $section
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_PREFS_TAB_SECTION
|
||||
*/
|
||||
function hook_prefs_tab_section($section) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** Allows adding new (top) tabs in preferences UI
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_PREFS_TABS
|
||||
*/
|
||||
function hook_prefs_tabs() {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** Invoked when feed XML is processed by FeedParser class
|
||||
* @param FeedParser $parser
|
||||
* @param int $feed_id
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_FEED_PARSED
|
||||
*/
|
||||
function hook_feed_parsed($parser, $feed_id) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** GLOBAL: Invoked when a feed update task finishes
|
||||
* @param array<string,string> $cli_options
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_UPDATE_TASK
|
||||
*/
|
||||
function hook_update_task($cli_options) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** This is a pluginhost compatibility wrapper that invokes $this->authenticate(...$args) (Auth_Base)
|
||||
* @param string $login
|
||||
* @param string $password
|
||||
* @param string $service
|
||||
* @return int|false user_id
|
||||
* @see PluginHost::HOOK_AUTH_USER
|
||||
*/
|
||||
function hook_auth_user($login, $password, $service = '') {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** IAuthModule only
|
||||
* @param string $login
|
||||
* @param string $password
|
||||
* @param string $service
|
||||
* @return int|false user_id
|
||||
*/
|
||||
function authenticate($login, $password, $service = '') {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Allows plugins to modify global hotkey map (hotkey sequence -> action)
|
||||
* @param array<string, string> $hotkeys
|
||||
* @return array<string, string>
|
||||
* @see PluginHost::HOOK_HOTKEY_MAP
|
||||
* @see Plugin::hook_hotkey_info()
|
||||
*/
|
||||
function hook_hotkey_map($hotkeys) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Invoked when article is rendered by backend (before it gets passed to frontent JS code) - three panel mode
|
||||
* @param array<string, mixed> $article
|
||||
* @return array<string, mixed>
|
||||
* @see PluginHost::HOOK_RENDER_ARTICLE
|
||||
*/
|
||||
function hook_render_article($article) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Invoked when article is rendered by backend (before it gets passed to frontent JS code) - combined mode
|
||||
* @param array<string, mixed> $article
|
||||
* @return array<string, mixed>
|
||||
* @see PluginHost::HOOK_RENDER_ARTICLE_CDM
|
||||
*/
|
||||
function hook_render_article_cdm($article) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Invoked when raw feed XML data has been successfully downloaded (but not parsed yet)
|
||||
* @param string $feed_data
|
||||
* @param string $fetch_url
|
||||
* @param int $owner_uid
|
||||
* @param int $feed
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_FEED_FETCHED
|
||||
*/
|
||||
function hook_feed_fetched($feed_data, $fetch_url, $owner_uid, $feed) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Invoked on article content when it is sanitized (i.e. potentially harmful tags removed)
|
||||
* @param DOMDocument $doc
|
||||
* @param string $site_url
|
||||
* @param array<string> $allowed_elements
|
||||
* @param array<string> $disallowed_attributes
|
||||
* @param int $article_id
|
||||
* @return DOMDocument|array<int,DOMDocument|array<string>>
|
||||
* @see PluginHost::HOOK_SANITIZE
|
||||
*/
|
||||
function hook_sanitize($doc, $site_url, $allowed_elements, $disallowed_attributes, $article_id) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return $doc;
|
||||
}
|
||||
|
||||
/** Invoked when article is rendered by backend (before it gets passed to frontent JS code) - exclusive to API clients
|
||||
* @param array{'article': array<string,mixed>|null, 'headline': array<string,mixed>|null} $params
|
||||
* @return array<string, string>
|
||||
* @see PluginHost::HOOK_RENDER_ARTICLE_API
|
||||
*/
|
||||
function hook_render_article_api($params) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Allows adding new UI elements to tt-rss main toolbar (to the right, before Actions... dropdown)
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_TOOLBAR_BUTTON
|
||||
*/
|
||||
function hook_toolbar_button() {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Allows adding new items to tt-rss main Actions... dropdown menu
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_ACTION_ITEM
|
||||
*/
|
||||
function hook_action_item() {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Allows adding new UI elements to the toolbar area related to currently loaded feed headlines
|
||||
* @param int $feed_id
|
||||
* @param bool $is_cat
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_HEADLINE_TOOLBAR_BUTTON
|
||||
*/
|
||||
function hook_headline_toolbar_button($feed_id, $is_cat) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Allows adding new hotkey action names and descriptions
|
||||
* @param array<string, array<string, string>> $hotkeys
|
||||
* @return array<string, array<string, string>>
|
||||
* @see PluginHost::HOOK_HOTKEY_INFO
|
||||
* @see Plugin::hook_hotkey_map()
|
||||
*/
|
||||
function hook_hotkey_info($hotkeys) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Adds per-article buttons on the left side. Generated markup must be valid XML.
|
||||
* @param array<string,mixed> $row
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_ARTICLE_LEFT_BUTTON
|
||||
* @see Plugin::hook_article_button()
|
||||
*/
|
||||
function hook_article_left_button($row) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Allows adding new UI elements to the "Plugins" tab of the feed editor UI
|
||||
* @param int $feed_id
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_PREFS_EDIT_FEED
|
||||
*/
|
||||
function hook_prefs_edit_feed($feed_id) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** Invoked when data is saved in the feed editor
|
||||
* @param int $feed_id
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_PREFS_SAVE_FEED
|
||||
*/
|
||||
function hook_prefs_save_feed($feed_id) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** Allows overriding built-in fetching mechanism for feeds, substituting received data if necessary
|
||||
* (i.e. origin site doesn't actually provide any RSS feeds), or XML is invalid
|
||||
* @param string $feed_data
|
||||
* @param string $fetch_url
|
||||
* @param int $owner_uid
|
||||
* @param int $feed
|
||||
* @param int $last_article_timestamp
|
||||
* @param string $auth_login
|
||||
* @param string $auth_pass
|
||||
* @return string (possibly mangled feed data)
|
||||
* @see PluginHost::HOOK_FETCH_FEED
|
||||
*/
|
||||
function hook_fetch_feed($feed_data, $fetch_url, $owner_uid, $feed, $last_article_timestamp, $auth_login, $auth_pass) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Invoked when headlines data ($row) has been retrieved from the database
|
||||
* @param array<string,mixed> $row
|
||||
* @param int $excerpt_length
|
||||
* @return array<string,mixed>
|
||||
* @see PluginHost::HOOK_QUERY_HEADLINES
|
||||
*/
|
||||
function hook_query_headlines($row, $excerpt_length) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** This is run periodically by the update daemon when idle (available both to user and system plugins)
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_HOUSE_KEEPING */
|
||||
function hook_house_keeping() {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** Allows overriding built-in article search
|
||||
* @param string $query
|
||||
* @return array<int, string|array<string>> - list(SQL search query, highlight keywords)
|
||||
* @see PluginHost::HOOK_SEARCH
|
||||
*/
|
||||
function hook_search($query) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Invoked when enclosures are rendered to HTML (when article itself is rendered)
|
||||
* @param string $enclosures_formatted
|
||||
* @param array<int, array<string, mixed>> $enclosures
|
||||
* @param int $article_id
|
||||
* @param bool $always_display_enclosures
|
||||
* @param string $article_content
|
||||
* @param bool $hide_images
|
||||
* @return string|array<string,array<int, array<string, mixed>>> ($enclosures_formatted, $enclosures)
|
||||
* @see PluginHost::HOOK_FORMAT_ENCLOSURES
|
||||
*/
|
||||
function hook_format_enclosures($enclosures_formatted, $enclosures, $article_id, $always_display_enclosures, $article_content, $hide_images) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Invoked during feed subscription (after data has been fetched)
|
||||
* @param string $contents
|
||||
* @param string $url
|
||||
* @param string $auth_login
|
||||
* @param string $auth_pass
|
||||
* @return string (possibly mangled feed data)
|
||||
* @see PluginHost::HOOK_SUBSCRIBE_FEED
|
||||
*/
|
||||
function hook_subscribe_feed($contents, $url, $auth_login, $auth_pass) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $feed
|
||||
* @param bool $is_cat
|
||||
* @param array<string,mixed> $qfh_ret (headlines object)
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_HEADLINES_BEFORE
|
||||
*/
|
||||
function hook_headlines_before($feed, $is_cat, $qfh_ret) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $entry
|
||||
* @param int $article_id
|
||||
* @param array<string,mixed> $rv
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_RENDER_ENCLOSURE
|
||||
*/
|
||||
function hook_render_enclosure($entry, $article_id, $rv) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $article
|
||||
* @param string $action
|
||||
* @return array<string,mixed> ($article)
|
||||
* @see PluginHost::HOOK_ARTICLE_FILTER_ACTION
|
||||
*/
|
||||
function hook_article_filter_action($article, $action) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $line
|
||||
* @param int $feed
|
||||
* @param bool $is_cat
|
||||
* @param int $owner_uid
|
||||
* @return array<string,mixed> ($line)
|
||||
* @see PluginHost::HOOK_ARTICLE_EXPORT_FEED
|
||||
*/
|
||||
function hook_article_export_feed($line, $feed, $is_cat, $owner_uid) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Allows adding custom buttons to tt-rss main toolbar (left side)
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_MAIN_TOOLBAR_BUTTON
|
||||
*/
|
||||
function hook_main_toolbar_button() {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** Invoked for every enclosure entry as article is being rendered
|
||||
* @param array<string,string> $entry
|
||||
* @param int $id article id
|
||||
* @param array{'formatted': string, 'entries': array<int, array<string, mixed>>} $rv
|
||||
* @return array<string,string> ($entry)
|
||||
* @see PluginHost::HOOK_ENCLOSURE_ENTRY
|
||||
*/
|
||||
function hook_enclosure_entry($entry, $id, $rv) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Share plugins run this when article is being rendered as HTML for sharing
|
||||
* @param string $html
|
||||
* @param array<string,mixed> $row
|
||||
* @return string ($html)
|
||||
* @see PluginHost::HOOK_FORMAT_ARTICLE
|
||||
*/
|
||||
function hook_format_article($html, $row) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Invoked when basic feed information (title, site_url) is being collected, useful to override default if feed doesn't provide anything (or feed itself is synthesized)
|
||||
* @param array{"title": string, "site_url": string} $basic_info
|
||||
* @param string $fetch_url
|
||||
* @param int $owner_uid
|
||||
* @param int $feed_id
|
||||
* @param string $auth_login
|
||||
* @param string $auth_pass
|
||||
* @return array{"title": string, "site_url": string}
|
||||
* @see PluginHost::HOOK_FEED_BASIC_INFO
|
||||
*/
|
||||
function hook_feed_basic_info($basic_info, $fetch_url, $owner_uid, $feed_id, $auth_login, $auth_pass) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return $basic_info;
|
||||
}
|
||||
|
||||
/** Invoked when file (e.g. cache entry, static data) is being sent to client, may override default mechanism
|
||||
* using faster httpd-specific implementation (see nginx_xaccel)
|
||||
* @param string $filename
|
||||
* @return bool
|
||||
* @see PluginHost::HOOK_SEND_LOCAL_FILE
|
||||
*/
|
||||
function hook_send_local_file($filename) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Invoked when user tries to unsubscribe from a feed, returning true would prevent any further default actions
|
||||
* @param int $feed_id
|
||||
* @param int $owner_uid
|
||||
* @return bool
|
||||
* @see PluginHost::HOOK_UNSUBSCRIBE_FEED
|
||||
*/
|
||||
function hook_unsubscribe_feed($feed_id, $owner_uid) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Invoked when mail is being sent (if no hooks are registered, tt-rss uses PHP mail() as a fallback)
|
||||
* @param Mailer $mailer
|
||||
* @param array<string,mixed> $params
|
||||
* @return int
|
||||
* @see PluginHost::HOOK_SEND_MAIL
|
||||
*/
|
||||
function hook_send_mail($mailer, $params) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Invoked when filter is triggered on an article, may be used to implement logging for filters
|
||||
* NOTE: $article_filters should be renamed $filter_actions because that's what this is
|
||||
* @param int $feed_id
|
||||
* @param int $owner_uid
|
||||
* @param array<string,mixed> $article
|
||||
* @param array<string,mixed> $matched_filters
|
||||
* @param array<string,string|bool|int> $matched_rules
|
||||
* @param array<string,string> $article_filters
|
||||
* @return void
|
||||
* @see PluginHost::HOOK_FILTER_TRIGGERED
|
||||
*/
|
||||
function hook_filter_triggered($feed_id, $owner_uid, $article, $matched_filters, $matched_rules, $article_filters) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** Plugins may provide this to allow getting full article text (af_readbility implements this)
|
||||
* @param string $url
|
||||
* @return string|false
|
||||
* @see PluginHost::HOOK_GET_FULL_TEXT
|
||||
*/
|
||||
function hook_get_full_text($url) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Invoked when article flavor image is being determined, allows overriding default selection logic
|
||||
* @param array<string,string> $enclosures
|
||||
* @param string $content
|
||||
* @param string $site_url
|
||||
* @param array<string,mixed> $article
|
||||
* @return string|array<int,string>
|
||||
* @see PluginHost::HOOK_ARTICLE_IMAGE
|
||||
*/
|
||||
function hook_article_image($enclosures, $content, $site_url, $article) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Allows adding arbitrary elements before feed tree
|
||||
* @return string HTML
|
||||
* @see PluginHost::HOOK_FEED_TREE
|
||||
* */
|
||||
function hook_feed_tree() {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Invoked for every iframe to determine if it is allowed to be displayed
|
||||
* @param string $url
|
||||
* @return bool
|
||||
* @see PluginHost::HOOK_IFRAME_WHITELISTED
|
||||
*/
|
||||
function hook_iframe_whitelisted($url) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param object $enclosure
|
||||
* @param int $feed
|
||||
* @return object ($enclosure)
|
||||
* @see PluginHost::HOOK_ENCLOSURE_IMPORTED
|
||||
*/
|
||||
function hook_enclosure_imported($enclosure, $feed) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return $enclosure;
|
||||
}
|
||||
|
||||
/** Allows adding custom elements to headline sort dropdown (name -> caption)
|
||||
* @return array<string,string>
|
||||
* @see PluginHost::HOOK_HEADLINES_CUSTOM_SORT_MAP
|
||||
*/
|
||||
function hook_headlines_custom_sort_map() {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return ["" => ""];
|
||||
}
|
||||
|
||||
/** Allows overriding headline sorting (or provide custom sort methods)
|
||||
* @param string $order
|
||||
* @return array<int, string|bool> -- (query, skip_first_id)
|
||||
* @see PluginHost::HOOK_HEADLINES_CUSTOM_SORT_OVERRIDE
|
||||
*/
|
||||
function hook_headlines_custom_sort_override($order) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return ["", false];
|
||||
}
|
||||
|
||||
/** Allows adding custom elements to headlines Select... dropdown
|
||||
* @deprecated removed, see Plugin::hook_headline_toolbar_select_menu_item2()
|
||||
* @param int $feed_id
|
||||
* @param int $is_cat
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM
|
||||
*/
|
||||
function hook_headline_toolbar_select_menu_item($feed_id, $is_cat) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Allows adding custom elements to headlines Select... select dropdown (<option> format)
|
||||
* @param int $feed_id
|
||||
* @param int $is_cat
|
||||
* @return string
|
||||
* @see PluginHost::HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM2
|
||||
*/
|
||||
function hook_headline_toolbar_select_menu_item2($feed_id, $is_cat) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Invoked when user tries to subscribe to feed, may override information (i.e. feed URL) used afterwards
|
||||
* @param string $url
|
||||
* @param string $auth_login
|
||||
* @param string $auth_pass
|
||||
* @return bool
|
||||
* @see PluginHost::HOOK_PRE_SUBSCRIBE
|
||||
*/
|
||||
function hook_pre_subscribe(&$url, $auth_login, $auth_pass) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Invoked after user logout, may override built-in behavior (redirect back to login page)
|
||||
* @param string $login
|
||||
* @param int $user_id
|
||||
* @return array<mixed> - [0] - if set, url to redirect to
|
||||
*/
|
||||
function hook_post_logout($login, $user_id) {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
|
||||
return [""];
|
||||
}
|
||||
|
||||
/** Adds buttons to the right of default Login button
|
||||
* @return void
|
||||
*/
|
||||
function hook_loginform_additional_buttons() {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
}
|
||||
|
||||
/** Returns false if session is considered invalid, true cascades to next handler */
|
||||
function hook_validate_session(): bool {
|
||||
user_error("Dummy method invoked.", E_USER_ERROR);
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
29
classes/PluginHandler.php
Normal file
29
classes/PluginHandler.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
class PluginHandler extends Handler_Protected {
|
||||
function csrf_ignore(string $method): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
function catchall(string $method): void {
|
||||
$plugin_name = clean($_REQUEST["plugin"]);
|
||||
$plugin = PluginHost::getInstance()->get_plugin($plugin_name);
|
||||
$csrf_token = ($_POST["csrf_token"] ?? "");
|
||||
|
||||
if ($plugin) {
|
||||
if (method_exists($plugin, $method)) {
|
||||
if (validate_csrf($csrf_token) || $plugin->csrf_ignore($method)) {
|
||||
$plugin->$method();
|
||||
} else {
|
||||
user_error("Rejected {$plugin_name}->{$method}(): invalid CSRF token.", E_USER_WARNING);
|
||||
print Errors::to_json(Errors::E_UNAUTHORIZED);
|
||||
}
|
||||
} else {
|
||||
user_error("Rejected {$plugin_name}->{$method}(): unknown method.", E_USER_WARNING);
|
||||
print Errors::to_json(Errors::E_UNKNOWN_METHOD);
|
||||
}
|
||||
} else {
|
||||
user_error("Rejected {$plugin_name}->{$method}(): unknown plugin.", E_USER_WARNING);
|
||||
print Errors::to_json(Errors::E_UNKNOWN_PLUGIN);
|
||||
}
|
||||
}
|
||||
}
|
||||
934
classes/PluginHost.php
Normal file
934
classes/PluginHost.php
Normal file
@@ -0,0 +1,934 @@
|
||||
<?php
|
||||
class PluginHost {
|
||||
private ?PDO $pdo = null;
|
||||
|
||||
/**
|
||||
* separate handle for plugin data so transaction while saving wouldn't clash with possible main
|
||||
* tt-rss code transactions; only initialized when first needed
|
||||
*/
|
||||
private ?PDO $pdo_data = null;
|
||||
|
||||
/** @var array<string, array<int, array<int, Plugin>>> hook types -> priority levels -> Plugins */
|
||||
private array $hooks = [];
|
||||
|
||||
/** @var array<string, Plugin> */
|
||||
private array $plugins = [];
|
||||
|
||||
/** @var array<string, array<string, Plugin>> handler type -> method type -> Plugin */
|
||||
private array $handlers = [];
|
||||
|
||||
/** @var array<string, array{'description': string, 'suffix': string, 'arghelp': string, 'class': Plugin}> command type -> details array */
|
||||
private array $commands = [];
|
||||
|
||||
/** @var array<string, array<string, mixed>> plugin name -> (potential profile array) -> key -> value */
|
||||
private array $storage = [];
|
||||
|
||||
/** @var array<int, array<int, array{'id': int, 'title': string, 'sender': Plugin, 'icon': string}>> */
|
||||
private array $feeds = [];
|
||||
|
||||
/** @var array<string, Plugin> API method name, Plugin sender */
|
||||
private array $api_methods = [];
|
||||
|
||||
/** @var array<string, array<int, array{'action': string, 'description': string, 'sender': Plugin}>> */
|
||||
private array $plugin_actions = [];
|
||||
|
||||
private ?int $owner_uid = null;
|
||||
|
||||
private bool $data_loaded = false;
|
||||
|
||||
private static ?PluginHost $instance = null;
|
||||
|
||||
const API_VERSION = 2;
|
||||
const PUBLIC_METHOD_DELIMITER = "--";
|
||||
|
||||
/** @see Plugin::hook_article_button() */
|
||||
const HOOK_ARTICLE_BUTTON = "hook_article_button";
|
||||
|
||||
/** @see Plugin::hook_article_filter() */
|
||||
const HOOK_ARTICLE_FILTER = "hook_article_filter";
|
||||
|
||||
/** @see Plugin::hook_prefs_tab() */
|
||||
const HOOK_PREFS_TAB = "hook_prefs_tab";
|
||||
|
||||
/** @see Plugin::hook_prefs_tab_section() */
|
||||
const HOOK_PREFS_TAB_SECTION = "hook_prefs_tab_section";
|
||||
|
||||
/** @see Plugin::hook_prefs_tabs() */
|
||||
const HOOK_PREFS_TABS = "hook_prefs_tabs";
|
||||
|
||||
/** @see Plugin::hook_feed_parsed() */
|
||||
const HOOK_FEED_PARSED = "hook_feed_parsed";
|
||||
|
||||
/** @see Plugin::hook_update_task() */
|
||||
const HOOK_UPDATE_TASK = "hook_update_task"; //*1
|
||||
|
||||
/** @see Plugin::hook_auth_user() */
|
||||
const HOOK_AUTH_USER = "hook_auth_user";
|
||||
|
||||
/** @see Plugin::hook_hotkey_map() */
|
||||
const HOOK_HOTKEY_MAP = "hook_hotkey_map";
|
||||
|
||||
/** @see Plugin::hook_render_article() */
|
||||
const HOOK_RENDER_ARTICLE = "hook_render_article";
|
||||
|
||||
/** @see Plugin::hook_render_article_cdm() */
|
||||
const HOOK_RENDER_ARTICLE_CDM = "hook_render_article_cdm";
|
||||
|
||||
/** @see Plugin::hook_feed_fetched() */
|
||||
const HOOK_FEED_FETCHED = "hook_feed_fetched";
|
||||
|
||||
/** @see Plugin::hook_sanitize() */
|
||||
const HOOK_SANITIZE = "hook_sanitize";
|
||||
|
||||
/** @see Plugin::hook_render_article_api() */
|
||||
const HOOK_RENDER_ARTICLE_API = "hook_render_article_api";
|
||||
|
||||
/** @see Plugin::hook_toolbar_button() */
|
||||
const HOOK_TOOLBAR_BUTTON = "hook_toolbar_button";
|
||||
|
||||
/** @see Plugin::hook_action_item() */
|
||||
const HOOK_ACTION_ITEM = "hook_action_item";
|
||||
|
||||
/** @see Plugin::hook_headline_toolbar_button() */
|
||||
const HOOK_HEADLINE_TOOLBAR_BUTTON = "hook_headline_toolbar_button";
|
||||
|
||||
/** @see Plugin::hook_hotkey_info() */
|
||||
const HOOK_HOTKEY_INFO = "hook_hotkey_info";
|
||||
|
||||
/** @see Plugin::hook_article_left_button() */
|
||||
const HOOK_ARTICLE_LEFT_BUTTON = "hook_article_left_button";
|
||||
|
||||
/** @see Plugin::hook_prefs_edit_feed() */
|
||||
const HOOK_PREFS_EDIT_FEED = "hook_prefs_edit_feed";
|
||||
|
||||
/** @see Plugin::hook_prefs_save_feed() */
|
||||
const HOOK_PREFS_SAVE_FEED = "hook_prefs_save_feed";
|
||||
|
||||
/** @see Plugin::hook_fetch_feed() */
|
||||
const HOOK_FETCH_FEED = "hook_fetch_feed";
|
||||
|
||||
/** @see Plugin::hook_query_headlines() */
|
||||
const HOOK_QUERY_HEADLINES = "hook_query_headlines";
|
||||
|
||||
/** @see Plugin::hook_house_keeping() */
|
||||
const HOOK_HOUSE_KEEPING = "hook_house_keeping"; //*1
|
||||
|
||||
/** @see Plugin::hook_search() */
|
||||
const HOOK_SEARCH = "hook_search";
|
||||
|
||||
/** @see Plugin::hook_format_enclosures() */
|
||||
const HOOK_FORMAT_ENCLOSURES = "hook_format_enclosures";
|
||||
|
||||
/** @see Plugin::hook_subscribe_feed() */
|
||||
const HOOK_SUBSCRIBE_FEED = "hook_subscribe_feed";
|
||||
|
||||
/** @see Plugin::hook_headlines_before() */
|
||||
const HOOK_HEADLINES_BEFORE = "hook_headlines_before";
|
||||
|
||||
/** @see Plugin::hook_render_enclosure() */
|
||||
const HOOK_RENDER_ENCLOSURE = "hook_render_enclosure";
|
||||
|
||||
/** @see Plugin::hook_article_filter_action() */
|
||||
const HOOK_ARTICLE_FILTER_ACTION = "hook_article_filter_action";
|
||||
|
||||
/** @see Plugin::hook_article_export_feed() */
|
||||
const HOOK_ARTICLE_EXPORT_FEED = "hook_article_export_feed";
|
||||
|
||||
/** @see Plugin::hook_main_toolbar_button() */
|
||||
const HOOK_MAIN_TOOLBAR_BUTTON = "hook_main_toolbar_button";
|
||||
|
||||
/** @see Plugin::hook_enclosure_entry() */
|
||||
const HOOK_ENCLOSURE_ENTRY = "hook_enclosure_entry";
|
||||
|
||||
/** @see Plugin::hook_format_article() */
|
||||
const HOOK_FORMAT_ARTICLE = "hook_format_article";
|
||||
|
||||
/** @see Plugin::hook_format_article_cdm() */
|
||||
const HOOK_FORMAT_ARTICLE_CDM = "hook_format_article_cdm";
|
||||
|
||||
/** @see Plugin::hook_feed_basic_info() */
|
||||
const HOOK_FEED_BASIC_INFO = "hook_feed_basic_info";
|
||||
|
||||
/** @see Plugin::hook_send_local_file() */
|
||||
const HOOK_SEND_LOCAL_FILE = "hook_send_local_file";
|
||||
|
||||
/** @see Plugin::hook_unsubscribe_feed() */
|
||||
const HOOK_UNSUBSCRIBE_FEED = "hook_unsubscribe_feed";
|
||||
|
||||
/** @see Plugin::hook_send_mail() */
|
||||
const HOOK_SEND_MAIL = "hook_send_mail";
|
||||
|
||||
/** @see Plugin::hook_filter_triggered() */
|
||||
const HOOK_FILTER_TRIGGERED = "hook_filter_triggered";
|
||||
|
||||
/** @see Plugin::hook_get_full_text() */
|
||||
const HOOK_GET_FULL_TEXT = "hook_get_full_text";
|
||||
|
||||
/** @see Plugin::hook_article_image() */
|
||||
const HOOK_ARTICLE_IMAGE = "hook_article_image";
|
||||
|
||||
/** @see Plugin::hook_feed_tree() */
|
||||
const HOOK_FEED_TREE = "hook_feed_tree";
|
||||
|
||||
/** @see Plugin::hook_iframe_whitelisted() */
|
||||
const HOOK_IFRAME_WHITELISTED = "hook_iframe_whitelisted";
|
||||
|
||||
/** @see Plugin::hook_enclosure_imported() */
|
||||
const HOOK_ENCLOSURE_IMPORTED = "hook_enclosure_imported";
|
||||
|
||||
/** @see Plugin::hook_headlines_custom_sort_map() */
|
||||
const HOOK_HEADLINES_CUSTOM_SORT_MAP = "hook_headlines_custom_sort_map";
|
||||
|
||||
/** @see Plugin::hook_headlines_custom_sort_override() */
|
||||
const HOOK_HEADLINES_CUSTOM_SORT_OVERRIDE = "hook_headlines_custom_sort_override";
|
||||
|
||||
/** @see Plugin::hook_headline_toolbar_select_menu_item()
|
||||
* @deprecated removed, see PluginHost::HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM2
|
||||
*/
|
||||
const HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM = "hook_headline_toolbar_select_menu_item";
|
||||
|
||||
/** @see Plugin::hook_headline_toolbar_select_menu_item() */
|
||||
const HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM2 = "hook_headline_toolbar_select_menu_item2";
|
||||
|
||||
/** @see Plugin::hook_pre_subscribe() */
|
||||
const HOOK_PRE_SUBSCRIBE = "hook_pre_subscribe";
|
||||
|
||||
/** @see Plugin::hook_post_logout() */
|
||||
const HOOK_POST_LOGOUT = "hook_post_logout";
|
||||
|
||||
/** @see Plugin::hook_loginform_additional_buttons() */
|
||||
const HOOK_LOGINFORM_ADDITIONAL_BUTTONS = "hook_loginform_additional_buttons";
|
||||
|
||||
/** @see Plugin::hook_validate_session() */
|
||||
const HOOK_VALIDATE_SESSION = "hook_validate_session";
|
||||
|
||||
const KIND_ALL = 1;
|
||||
const KIND_SYSTEM = 2;
|
||||
const KIND_USER = 3;
|
||||
|
||||
static function object_to_domain(Plugin $plugin): string {
|
||||
return strtolower(get_class($plugin));
|
||||
}
|
||||
|
||||
function __construct() {
|
||||
$this->pdo = Db::pdo();
|
||||
$this->storage = [];
|
||||
}
|
||||
|
||||
private function __clone() {
|
||||
//
|
||||
}
|
||||
|
||||
public static function getInstance(): PluginHost {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function register_plugin(string $name, Plugin $plugin): void {
|
||||
//array_push($this->plugins, $plugin);
|
||||
$this->plugins[$name] = $plugin;
|
||||
}
|
||||
|
||||
/** needed for compatibility with API 1 */
|
||||
function get_link(): bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** needed for compatibility with API 2 (?) */
|
||||
function get_dbh(): bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
function get_pdo(): PDO {
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
function get_plugin_names(): array {
|
||||
$names = [];
|
||||
|
||||
foreach ($this->plugins as $p) {
|
||||
array_push($names, get_class($p));
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Plugin>
|
||||
*/
|
||||
function get_plugins(): array {
|
||||
return $this->plugins;
|
||||
}
|
||||
|
||||
function get_plugin(string $name): ?Plugin {
|
||||
return $this->plugins[strtolower($name)] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PluginHost::HOOK_* $hook
|
||||
* @param mixed $args
|
||||
*/
|
||||
function run_hooks(string $hook, ...$args): void {
|
||||
|
||||
$method = strtolower((string)$hook);
|
||||
|
||||
foreach ($this->get_hooks((string)$hook) as $plugin) {
|
||||
//Debug::log("invoking: " . get_class($plugin) . "->$hook()", Debug::$LOG_VERBOSE);
|
||||
|
||||
try {
|
||||
$plugin->$method(...$args);
|
||||
} catch (Exception $ex) {
|
||||
user_error($ex, E_USER_WARNING);
|
||||
} catch (Error $err) {
|
||||
user_error($err, E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PluginHost::HOOK_* $hook
|
||||
* @param mixed $args
|
||||
* @param mixed $check
|
||||
*/
|
||||
function run_hooks_until(string $hook, $check, ...$args): bool {
|
||||
$method = strtolower((string)$hook);
|
||||
|
||||
foreach ($this->get_hooks((string)$hook) as $plugin) {
|
||||
try {
|
||||
$result = $plugin->$method(...$args);
|
||||
|
||||
if ($result == $check)
|
||||
return true;
|
||||
|
||||
} catch (Exception $ex) {
|
||||
user_error($ex, E_USER_WARNING);
|
||||
} catch (Error $err) {
|
||||
user_error($err, E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PluginHost::HOOK_* $hook
|
||||
* @param mixed $args
|
||||
*/
|
||||
function run_hooks_callback(string $hook, Closure $callback, ...$args): void {
|
||||
$method = strtolower((string)$hook);
|
||||
|
||||
foreach ($this->get_hooks((string)$hook) as $plugin) {
|
||||
//Debug::log("invoking: " . get_class($plugin) . "->$hook()", Debug::$LOG_VERBOSE);
|
||||
|
||||
try {
|
||||
if ($callback($plugin->$method(...$args), $plugin))
|
||||
break;
|
||||
} catch (Exception $ex) {
|
||||
user_error($ex, E_USER_WARNING);
|
||||
} catch (Error $err) {
|
||||
user_error($err, E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PluginHost::HOOK_* $hook
|
||||
* @param mixed $args
|
||||
*/
|
||||
function chain_hooks_callback(string $hook, Closure $callback, &...$args): void {
|
||||
$method = strtolower((string)$hook);
|
||||
$span = OpenTelemetry\API\Trace\Span::getCurrent();
|
||||
$span->addEvent("chain_hooks_callback: $hook");
|
||||
|
||||
foreach ($this->get_hooks((string)$hook) as $plugin) {
|
||||
//Debug::log("invoking: " . get_class($plugin) . "->$hook()", Debug::$LOG_VERBOSE);
|
||||
|
||||
//$p_span = Tracer::start("$hook - " . get_class($plugin));
|
||||
|
||||
$span->addEvent("$hook - " . get_class($plugin));
|
||||
|
||||
try {
|
||||
if ($callback($plugin->$method(...$args), $plugin))
|
||||
break;
|
||||
} catch (Exception $ex) {
|
||||
user_error($ex, E_USER_WARNING);
|
||||
} catch (Error $err) {
|
||||
user_error($err, E_USER_WARNING);
|
||||
}
|
||||
|
||||
//$p_span->end();
|
||||
}
|
||||
|
||||
//$span->end();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PluginHost::HOOK_* $type
|
||||
*/
|
||||
function add_hook(string $type, Plugin $sender, int $priority = 50): void {
|
||||
$priority = (int) $priority;
|
||||
|
||||
if (!method_exists($sender, strtolower((string)$type))) {
|
||||
user_error(
|
||||
sprintf("Plugin %s tried to register a hook without implementation: %s",
|
||||
get_class($sender), $type),
|
||||
E_USER_WARNING
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($this->hooks[$type])) {
|
||||
$this->hooks[$type] = [];
|
||||
}
|
||||
|
||||
if (empty($this->hooks[$type][$priority])) {
|
||||
$this->hooks[$type][$priority] = [];
|
||||
}
|
||||
|
||||
array_push($this->hooks[$type][$priority], $sender);
|
||||
ksort($this->hooks[$type]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PluginHost::HOOK_* $type
|
||||
*/
|
||||
function del_hook(string $type, Plugin $sender): void {
|
||||
if (is_array($this->hooks[$type])) {
|
||||
foreach (array_keys($this->hooks[$type]) as $prio) {
|
||||
$key = array_search($sender, $this->hooks[$type][$prio]);
|
||||
|
||||
if ($key !== false) {
|
||||
unset($this->hooks[$type][$prio][$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PluginHost::HOOK_* $type
|
||||
* @return array<int, Plugin>
|
||||
*/
|
||||
function get_hooks(string $type) {
|
||||
if (isset($this->hooks[$type])) {
|
||||
$tmp = [];
|
||||
|
||||
foreach (array_keys($this->hooks[$type]) as $prio) {
|
||||
array_push($tmp, ...$this->hooks[$type][$prio]);
|
||||
}
|
||||
|
||||
return $tmp;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PluginHost::KIND_* $kind
|
||||
*/
|
||||
function load_all(int $kind, ?int $owner_uid = null, bool $skip_init = false): void {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
$span->setAttribute('func.args', json_encode(func_get_args()));
|
||||
|
||||
$plugins = [...(glob("plugins/*") ?: []), ...(glob("plugins.local/*") ?: [])];
|
||||
$plugins = array_filter($plugins, "is_dir");
|
||||
$plugins = array_map("basename", $plugins);
|
||||
|
||||
asort($plugins);
|
||||
|
||||
$this->load(join(",", $plugins), (int)$kind, $owner_uid, $skip_init);
|
||||
|
||||
$span->end();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param PluginHost::KIND_* $kind
|
||||
*/
|
||||
function load(string $classlist, int $kind, ?int $owner_uid = null, bool $skip_init = false): void {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
$span->setAttribute('func.args', json_encode(func_get_args()));
|
||||
|
||||
$plugins = explode(",", $classlist);
|
||||
|
||||
$this->owner_uid = (int) $owner_uid;
|
||||
|
||||
foreach ($plugins as $class) {
|
||||
$class = trim($class);
|
||||
$class_file = strtolower(basename(clean($class)));
|
||||
|
||||
$span->addEvent("$class_file: load");
|
||||
|
||||
// try system plugin directory first
|
||||
$file = Config::get_self_dir() . "/plugins/$class_file/init.php";
|
||||
|
||||
if (!file_exists($file)) {
|
||||
$file = Config::get_self_dir() . "/plugins.local/$class_file/init.php";
|
||||
|
||||
if (!file_exists($file)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($this->plugins[$class])) {
|
||||
// WIP hack
|
||||
// we can't catch incompatible method signatures via Throwable
|
||||
// this also enables global tt-rss safe mode in case there are more plugins like this
|
||||
if (!getenv('TTRSS_XDEBUG_ENABLED') && ($_SESSION["plugin_blacklist"][$class] ?? 0)) {
|
||||
|
||||
// only report once per-plugin per-session
|
||||
if ($_SESSION["plugin_blacklist"][$class] < 2) {
|
||||
user_error("Plugin $class has caused a PHP fatal error so it won't be loaded again in this session.", E_USER_WARNING);
|
||||
$_SESSION["plugin_blacklist"][$class] = 2;
|
||||
}
|
||||
|
||||
$_SESSION["safe_mode"] = 1;
|
||||
|
||||
$span->setAttribute('error', 'plugin is blacklisted');
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$_SESSION["plugin_blacklist"][$class] = 1;
|
||||
require_once $file;
|
||||
unset($_SESSION["plugin_blacklist"][$class]);
|
||||
|
||||
} catch (Error $err) {
|
||||
user_error($err, E_USER_WARNING);
|
||||
|
||||
$span->setAttribute('error', $err);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (class_exists($class) && is_subclass_of($class, "Plugin")) {
|
||||
$plugin = new $class($this);
|
||||
$plugin_api = $plugin->api_version();
|
||||
|
||||
if ($plugin_api < self::API_VERSION) {
|
||||
user_error("Plugin $class is not compatible with current API version (need: " . self::API_VERSION . ", got: $plugin_api)", E_USER_WARNING);
|
||||
|
||||
$span->setAttribute('error', 'plugin is not compatible with API version');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file_exists(dirname($file) . "/locale")) {
|
||||
_bindtextdomain($class, dirname($file) . "/locale");
|
||||
_bind_textdomain_codeset($class, "UTF-8");
|
||||
}
|
||||
|
||||
$span->addEvent("$class_file: initialize");
|
||||
|
||||
try {
|
||||
switch ($kind) {
|
||||
case $this::KIND_SYSTEM:
|
||||
if ($this->is_system($plugin)) {
|
||||
if (!$skip_init) $plugin->init($this);
|
||||
$this->register_plugin($class, $plugin);
|
||||
}
|
||||
break;
|
||||
case $this::KIND_USER:
|
||||
if (!$this->is_system($plugin)) {
|
||||
if (!$skip_init) $plugin->init($this);
|
||||
$this->register_plugin($class, $plugin);
|
||||
}
|
||||
break;
|
||||
case $this::KIND_ALL:
|
||||
if (!$skip_init) $plugin->init($this);
|
||||
$this->register_plugin($class, $plugin);
|
||||
break;
|
||||
}
|
||||
} catch (Exception $ex) {
|
||||
user_error($ex, E_USER_WARNING);
|
||||
} catch (Error $err) {
|
||||
user_error($err, E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->load_data();
|
||||
$span->end();
|
||||
}
|
||||
|
||||
function is_system(Plugin $plugin): bool {
|
||||
$about = $plugin->about();
|
||||
|
||||
return ($about[3] ?? false) === true;
|
||||
}
|
||||
|
||||
// only system plugins are allowed to modify routing
|
||||
function add_handler(string $handler, string $method, Plugin $sender): void {
|
||||
$handler = str_replace("-", "_", strtolower($handler));
|
||||
$method = strtolower($method);
|
||||
|
||||
if ($this->is_system($sender)) {
|
||||
$this->handlers[$handler] ??= [];
|
||||
$this->handlers[$handler][$method] = $sender;
|
||||
}
|
||||
}
|
||||
|
||||
function del_handler(string $handler, string $method, Plugin $sender): void {
|
||||
$handler = str_replace("-", "_", strtolower($handler));
|
||||
$method = strtolower($method);
|
||||
|
||||
if ($this->is_system($sender)) {
|
||||
unset($this->handlers[$handler][$method]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|Plugin false if the handler couldn't be found, otherwise the Plugin/handler
|
||||
*/
|
||||
function lookup_handler(string $handler, string $method) {
|
||||
$handler = str_replace("-", "_", strtolower($handler));
|
||||
$method = strtolower($method);
|
||||
|
||||
if (isset($this->handlers[$handler])) {
|
||||
return $this->handlers[$handler]["*"] ?? $this->handlers[$handler][$method];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function add_command(string $command, string $description, Plugin $sender, string $suffix = "", string $arghelp = ""): void {
|
||||
$command = str_replace("-", "_", strtolower($command));
|
||||
|
||||
$this->commands[$command] = array("description" => $description,
|
||||
"suffix" => $suffix,
|
||||
"arghelp" => $arghelp,
|
||||
"class" => $sender);
|
||||
}
|
||||
|
||||
function del_command(string $command): void {
|
||||
$command = "-" . strtolower($command);
|
||||
|
||||
unset($this->commands[$command]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|Plugin false if the command couldn't be found, otherwise the registered Plugin
|
||||
*/
|
||||
function lookup_command(string $command) {
|
||||
$command = "-" . strtolower($command);
|
||||
|
||||
if (array_key_exists($command, $this->commands)) {
|
||||
return $this->commands[$command]["class"];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, array{'description': string, 'suffix': string, 'arghelp': string, 'class': Plugin}>> command type -> details array */
|
||||
function get_commands() {
|
||||
return $this->commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $args
|
||||
*/
|
||||
function run_commands(array $args): void {
|
||||
foreach ($this->get_commands() as $command => $data) {
|
||||
if (isset($args[$command])) {
|
||||
$command = str_replace("-", "", $command);
|
||||
$data["class"]->$command($args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function load_data(): void {
|
||||
$span = OpenTelemetry\API\Trace\Span::getCurrent();
|
||||
$span->addEvent('load plugin data');
|
||||
|
||||
if ($this->owner_uid && !$this->data_loaded && Config::get_schema_version() > 100) {
|
||||
$sth = $this->pdo->prepare("SELECT name, content FROM ttrss_plugin_storage
|
||||
WHERE owner_uid = ?");
|
||||
$sth->execute([$this->owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$span->addEvent($line["name"] . ': unserialize');
|
||||
|
||||
$this->storage[$line["name"]] = unserialize($line["content"]);
|
||||
}
|
||||
|
||||
$this->data_loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
private function save_data(string $plugin): void {
|
||||
if ($this->owner_uid) {
|
||||
$span = OpenTelemetry\API\Trace\Span::getCurrent();
|
||||
$span->addEvent(__METHOD__ . ": $plugin");
|
||||
|
||||
if (!$this->pdo_data)
|
||||
$this->pdo_data = Db::instance()->pdo_connect();
|
||||
|
||||
$this->pdo_data->beginTransaction();
|
||||
|
||||
$sth = $this->pdo_data->prepare("SELECT id FROM ttrss_plugin_storage WHERE
|
||||
owner_uid= ? AND name = ?");
|
||||
$sth->execute([$this->owner_uid, $plugin]);
|
||||
|
||||
$this->storage[$plugin] ??= [];
|
||||
|
||||
$content = serialize($this->storage[$plugin]);
|
||||
|
||||
if ($sth->fetch()) {
|
||||
$sth = $this->pdo_data->prepare("UPDATE ttrss_plugin_storage SET content = ?
|
||||
WHERE owner_uid= ? AND name = ?");
|
||||
$sth->execute([$content, $this->owner_uid, $plugin]);
|
||||
|
||||
} else {
|
||||
$sth = $this->pdo_data->prepare("INSERT INTO ttrss_plugin_storage
|
||||
(name,owner_uid,content) VALUES
|
||||
(?, ?, ?)");
|
||||
$sth->execute([$plugin, $this->owner_uid, $content]);
|
||||
}
|
||||
|
||||
$this->pdo_data->commit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* same as set(), but sets data to current preference profile
|
||||
*
|
||||
* @param mixed $value
|
||||
*/
|
||||
function profile_set(Plugin $sender, string $name, $value): void {
|
||||
$profile_id = $_SESSION["profile"] ?? null;
|
||||
|
||||
if ($profile_id) {
|
||||
$idx = get_class($sender);
|
||||
|
||||
$this->storage[$idx] ??= [];
|
||||
$this->storage[$idx][$profile_id] ??= [];
|
||||
$this->storage[$idx][$profile_id][$name] = $value;
|
||||
|
||||
$this->save_data(get_class($sender));
|
||||
} else {
|
||||
$this->set($sender, $name, $value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
function set(Plugin $sender, string $name, $value): void {
|
||||
$idx = get_class($sender);
|
||||
|
||||
$this->storage[$idx] ??= [];
|
||||
$this->storage[$idx][$name] = $value;
|
||||
|
||||
$this->save_data(get_class($sender));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $params
|
||||
*/
|
||||
function set_array(Plugin $sender, array $params): void {
|
||||
$idx = get_class($sender);
|
||||
|
||||
$this->storage[$idx] ??= [];
|
||||
|
||||
foreach ($params as $name => $value)
|
||||
$this->storage[$idx][$name] = $value;
|
||||
|
||||
$this->save_data(get_class($sender));
|
||||
}
|
||||
|
||||
/**
|
||||
* same as get(), but sets data to current preference profile
|
||||
*
|
||||
* @param mixed $default_value
|
||||
* @return mixed
|
||||
*/
|
||||
function profile_get(Plugin $sender, string $name, $default_value = false) {
|
||||
$profile_id = $_SESSION["profile"] ?? null;
|
||||
|
||||
if ($profile_id) {
|
||||
$idx = get_class($sender);
|
||||
$this->load_data();
|
||||
return $this->storage[$idx][$profile_id][$name] ?? $default_value;
|
||||
} else {
|
||||
return $this->get($sender, $name, $default_value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $default_value
|
||||
* @return mixed
|
||||
*/
|
||||
function get(Plugin $sender, string $name, $default_value = false) {
|
||||
$idx = get_class($sender);
|
||||
$this->load_data();
|
||||
return $this->storage[$idx][$name] ?? $default_value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $default_value
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
function get_array(Plugin $sender, string $name, array $default_value = []) {
|
||||
$tmp = $this->get($sender, $name);
|
||||
|
||||
if (!is_array($tmp)) $tmp = $default_value;
|
||||
|
||||
return $tmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function get_all(Plugin $sender) {
|
||||
$idx = get_class($sender);
|
||||
|
||||
return $this->storage[$idx] ?? [];
|
||||
}
|
||||
|
||||
function clear_data(Plugin $sender): void {
|
||||
if ($this->owner_uid) {
|
||||
$idx = get_class($sender);
|
||||
|
||||
unset($this->storage[$idx]);
|
||||
|
||||
$sth = $this->pdo->prepare("DELETE FROM ttrss_plugin_storage WHERE name = ?
|
||||
AND owner_uid = ?");
|
||||
$sth->execute([$idx, $this->owner_uid]);
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin feed functions are *EXPERIMENTAL*!
|
||||
|
||||
// cat_id: only -1 (Feeds::CATEGORY_SPECIAL) is supported (Special)
|
||||
function add_feed(int $cat_id, string $title, string $icon, Plugin $sender): int {
|
||||
|
||||
if (empty($this->feeds[$cat_id]))
|
||||
$this->feeds[$cat_id] = [];
|
||||
|
||||
$id = count($this->feeds[$cat_id]);
|
||||
|
||||
array_push($this->feeds[$cat_id],
|
||||
['id' => $id, 'title' => $title, 'sender' => $sender, 'icon' => $icon]);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{'id': int, 'title': string, 'sender': Plugin, 'icon': string}>
|
||||
*/
|
||||
function get_feeds(int $cat_id) {
|
||||
return $this->feeds[$cat_id] ?? [];
|
||||
}
|
||||
|
||||
// convert feed_id (e.g. -129) to pfeed_id first
|
||||
function get_feed_handler(int $pfeed_id): ?Plugin {
|
||||
foreach ($this->feeds as $cat) {
|
||||
foreach ($cat as $feed) {
|
||||
if ($feed['id'] == $pfeed_id) {
|
||||
return $feed['sender'];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static function pfeed_to_feed_id(int $pfeed): int {
|
||||
return PLUGIN_FEED_BASE_INDEX - 1 - abs($pfeed);
|
||||
}
|
||||
|
||||
static function feed_to_pfeed_id(int $feed): int {
|
||||
return PLUGIN_FEED_BASE_INDEX - 1 + abs($feed);
|
||||
}
|
||||
|
||||
function add_api_method(string $name, Plugin $sender): void {
|
||||
if ($this->is_system($sender)) {
|
||||
$this->api_methods[strtolower($name)] = $sender;
|
||||
}
|
||||
}
|
||||
|
||||
function get_api_method(string $name): ?Plugin {
|
||||
return $this->api_methods[$name] ?? null;
|
||||
}
|
||||
|
||||
function add_filter_action(Plugin $sender, string $action_name, string $action_desc): void {
|
||||
$sender_class = get_class($sender);
|
||||
|
||||
$this->plugin_actions[$sender_class] ??= [];
|
||||
|
||||
$this->plugin_actions[$sender_class][] = [
|
||||
"action" => $action_name,
|
||||
"description" => $action_desc,
|
||||
"sender" => $sender,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, array{'action': string, 'description': string, 'sender': Plugin}>>
|
||||
*/
|
||||
function get_filter_actions() {
|
||||
return $this->plugin_actions;
|
||||
}
|
||||
|
||||
function get_owner_uid(): ?int {
|
||||
return $this->owner_uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* handled by classes/PluginHandler.php, requires valid session
|
||||
*
|
||||
* @param array<int|string, mixed> $params
|
||||
*/
|
||||
function get_method_url(Plugin $sender, string $method, array $params = []): string {
|
||||
return Config::get_self_url() . "/backend.php?" .
|
||||
http_build_query(
|
||||
array_merge(
|
||||
[
|
||||
"op" => "pluginhandler",
|
||||
"plugin" => strtolower(get_class($sender)),
|
||||
"method" => $method
|
||||
],
|
||||
$params));
|
||||
}
|
||||
|
||||
// shortcut syntax (disabled for now)
|
||||
/* function get_method_url(Plugin $sender, string $method, $params) {
|
||||
return Config::get_self_url() . "/backend.php?" .
|
||||
http_build_query(
|
||||
array_merge(
|
||||
[
|
||||
"op" => strtolower(get_class($sender) . self::PUBLIC_METHOD_DELIMITER . $method),
|
||||
],
|
||||
$params));
|
||||
} */
|
||||
|
||||
/**
|
||||
* WARNING: endpoint in public.php, exposed to unauthenticated users
|
||||
*
|
||||
* @param array<int|string, mixed> $params
|
||||
*/
|
||||
function get_public_method_url(Plugin $sender, string $method, array $params = []): ?string {
|
||||
if ($sender->is_public_method($method)) {
|
||||
return Config::get_self_url() . "/public.php?" .
|
||||
http_build_query(
|
||||
array_merge(
|
||||
[
|
||||
"op" => strtolower(get_class($sender) . self::PUBLIC_METHOD_DELIMITER . $method),
|
||||
],
|
||||
$params));
|
||||
}
|
||||
user_error("get_public_method_url: requested method '$method' of '" . get_class($sender) . "' is private.");
|
||||
return null;
|
||||
}
|
||||
|
||||
function get_plugin_dir(Plugin $plugin): string {
|
||||
$ref = new ReflectionClass(get_class($plugin));
|
||||
return dirname($ref->getFileName());
|
||||
}
|
||||
|
||||
// TODO: use get_plugin_dir()
|
||||
function is_local(Plugin $plugin): bool {
|
||||
$ref = new ReflectionClass(get_class($plugin));
|
||||
return basename(dirname(dirname($ref->getFileName()))) == "plugins.local";
|
||||
}
|
||||
}
|
||||
1298
classes/Pref_Feeds.php
Normal file
1298
classes/Pref_Feeds.php
Normal file
File diff suppressed because it is too large
Load Diff
968
classes/Pref_Filters.php
Normal file
968
classes/Pref_Filters.php
Normal file
@@ -0,0 +1,968 @@
|
||||
<?php
|
||||
class Pref_Filters extends Handler_Protected {
|
||||
|
||||
const ACTION_TAG = 4;
|
||||
const ACTION_SCORE = 6;
|
||||
const ACTION_LABEL = 7;
|
||||
const ACTION_PLUGIN = 9;
|
||||
const ACTION_REMOVE_TAG = 10;
|
||||
|
||||
const PARAM_ACTIONS = [self::ACTION_TAG, self::ACTION_SCORE,
|
||||
self::ACTION_LABEL, self::ACTION_PLUGIN, self::ACTION_REMOVE_TAG];
|
||||
|
||||
function csrf_ignore(string $method): bool {
|
||||
$csrf_ignored = array("index", "getfiltertree", "savefilterorder");
|
||||
|
||||
return array_search($method, $csrf_ignored) !== false;
|
||||
}
|
||||
|
||||
function filtersortreset(): void {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_filters2
|
||||
SET order_id = 0 WHERE owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
}
|
||||
|
||||
function savefilterorder(): void {
|
||||
$data = json_decode($_POST['payload'], true);
|
||||
|
||||
#file_put_contents("/tmp/saveorder.json", clean($_POST['payload']));
|
||||
#$data = json_decode(file_get_contents("/tmp/saveorder.json"), true);
|
||||
|
||||
if (!is_array($data['items']))
|
||||
$data['items'] = json_decode($data['items'], true);
|
||||
|
||||
$index = 0;
|
||||
|
||||
if (is_array($data) && is_array($data['items'])) {
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_filters2 SET
|
||||
order_id = ? WHERE id = ? AND
|
||||
owner_uid = ?");
|
||||
|
||||
foreach ($data['items'][0]['items'] as $item) {
|
||||
$filter_id = (int) str_replace("FILTER:", "", $item['_reference']);
|
||||
|
||||
if ($filter_id > 0) {
|
||||
$sth->execute([$index, $filter_id, $_SESSION['uid']]);
|
||||
++$index;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function testFilterDo(): void {
|
||||
$offset = (int) clean($_REQUEST["offset"]);
|
||||
$limit = (int) clean($_REQUEST["limit"]);
|
||||
|
||||
$filter = array();
|
||||
|
||||
$filter["enabled"] = true;
|
||||
$filter["match_any_rule"] = checkbox_to_sql_bool($_REQUEST["match_any_rule"] ?? false);
|
||||
$filter["inverse"] = checkbox_to_sql_bool($_REQUEST["inverse"] ?? false);
|
||||
|
||||
$filter["rules"] = array();
|
||||
$filter["actions"] = array("dummy-action");
|
||||
|
||||
$res = $this->pdo->query("SELECT id,name FROM ttrss_filter_types");
|
||||
|
||||
/** @var array<int, string> */
|
||||
$filter_types = [];
|
||||
|
||||
while ($line = $res->fetch()) {
|
||||
$filter_types[$line["id"]] = $line["name"];
|
||||
}
|
||||
|
||||
$scope_qparts = array();
|
||||
|
||||
$rctr = 0;
|
||||
|
||||
/** @var string $r */
|
||||
foreach (clean($_REQUEST["rule"]) AS $r) {
|
||||
/** @var array{'reg_exp': string, 'filter_type': int, 'feed_id': array<int, int|string>, 'name': string}|null */
|
||||
$rule = json_decode($r, true);
|
||||
|
||||
if ($rule && $rctr < 5) {
|
||||
$rule["type"] = $filter_types[$rule["filter_type"]];
|
||||
unset($rule["filter_type"]);
|
||||
|
||||
$scope_inner_qparts = [];
|
||||
|
||||
/** @var int|string $feed_id may be a category string (e.g. 'CAT:7') or feed ID int */
|
||||
foreach ($rule["feed_id"] as $feed_id) {
|
||||
|
||||
if (strpos("$feed_id", "CAT:") === 0) {
|
||||
$cat_id = (int) substr("$feed_id", 4);
|
||||
array_push($scope_inner_qparts, "cat_id = " . $cat_id);
|
||||
} else if (is_numeric($feed_id) && $feed_id > 0) {
|
||||
array_push($scope_inner_qparts, "feed_id = " . (int)$feed_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (count($scope_inner_qparts) > 0) {
|
||||
array_push($scope_qparts, "(" . implode(" OR ", $scope_inner_qparts) . ")");
|
||||
}
|
||||
|
||||
array_push($filter["rules"], $rule);
|
||||
|
||||
++$rctr;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (count($scope_qparts) == 0) $scope_qparts = ["true"];
|
||||
|
||||
$glue = $filter['match_any_rule'] ? " OR " : " AND ";
|
||||
$scope_qpart = join($glue, $scope_qparts);
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
if (!$scope_qpart) $scope_qpart = "true";
|
||||
|
||||
$rv = array();
|
||||
|
||||
//while ($found < $limit && $offset < $limit * 1000 && time() - $started < ini_get("max_execution_time") * 0.7) {
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT ttrss_entries.id,
|
||||
ttrss_entries.title,
|
||||
ttrss_feeds.id AS feed_id,
|
||||
ttrss_feeds.title AS feed_title,
|
||||
ttrss_feed_categories.id AS cat_id,
|
||||
content,
|
||||
date_entered,
|
||||
link,
|
||||
author,
|
||||
tag_cache
|
||||
FROM
|
||||
ttrss_entries, ttrss_user_entries
|
||||
LEFT JOIN ttrss_feeds ON (feed_id = ttrss_feeds.id)
|
||||
LEFT JOIN ttrss_feed_categories ON (ttrss_feeds.cat_id = ttrss_feed_categories.id)
|
||||
WHERE
|
||||
ref_id = ttrss_entries.id AND
|
||||
($scope_qpart) AND
|
||||
ttrss_user_entries.owner_uid = ?
|
||||
ORDER BY date_entered DESC LIMIT $limit OFFSET $offset");
|
||||
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
$rc = RSSUtils::get_article_filters(array($filter), $line['title'], $line['content'], $line['link'],
|
||||
$line['author'], explode(",", $line['tag_cache']));
|
||||
|
||||
if (count($rc) > 0) {
|
||||
|
||||
$line["content_preview"] = truncate_string(strip_tags($line["content"]), 200, '…');
|
||||
|
||||
$excerpt_length = 100;
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES,
|
||||
function ($result) use (&$line) {
|
||||
$line = $result;
|
||||
},
|
||||
$line, $excerpt_length);
|
||||
|
||||
$content_preview = $line["content_preview"];
|
||||
|
||||
$tmp = "<li><span class='title'>" . $line["title"] . "</span><br/>" .
|
||||
"<span class='feed'>" . $line['feed_title'] . "</span>, <span class='date'>" . mb_substr($line["date_entered"], 0, 16) . "</span>" .
|
||||
"<div class='preview text-muted'>" . $content_preview . "</div>" .
|
||||
"</li>";
|
||||
|
||||
array_push($rv, $tmp);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
print json_encode($rv);
|
||||
}
|
||||
|
||||
private function _get_rules_list(int $filter_id): string {
|
||||
$sth = $this->pdo->prepare("SELECT reg_exp,
|
||||
inverse,
|
||||
match_on,
|
||||
feed_id,
|
||||
cat_id,
|
||||
cat_filter,
|
||||
ttrss_filter_types.description AS field
|
||||
FROM
|
||||
ttrss_filters2_rules, ttrss_filter_types
|
||||
WHERE
|
||||
filter_id = ? AND filter_type = ttrss_filter_types.id
|
||||
ORDER BY reg_exp");
|
||||
$sth->execute([$filter_id]);
|
||||
|
||||
$rv = "";
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
if ($line["match_on"]) {
|
||||
$feeds = json_decode($line["match_on"], true);
|
||||
$feeds_fmt = [];
|
||||
|
||||
foreach ($feeds as $feed_id) {
|
||||
|
||||
if (strpos($feed_id, "CAT:") === 0) {
|
||||
$feed_id = (int)substr($feed_id, 4);
|
||||
array_push($feeds_fmt, Feeds::_get_cat_title($feed_id));
|
||||
} else {
|
||||
if ($feed_id)
|
||||
array_push($feeds_fmt, Feeds::_get_title((int)$feed_id));
|
||||
else
|
||||
array_push($feeds_fmt, __("All feeds"));
|
||||
}
|
||||
}
|
||||
|
||||
$where = implode(", ", $feeds_fmt);
|
||||
|
||||
} else {
|
||||
|
||||
$where = $line["cat_filter"] ?
|
||||
Feeds::_get_cat_title($line["cat_id"] ?? 0) :
|
||||
($line["feed_id"] ?
|
||||
Feeds::_get_title($line["feed_id"]) : __("All feeds"));
|
||||
}
|
||||
|
||||
# $where = $line["cat_id"] . "/" . $line["feed_id"];
|
||||
|
||||
$inverse = $line["inverse"] ? "inverse" : "";
|
||||
|
||||
$rv .= "<li class='$inverse'>" . T_sprintf("%s on %s in %s %s",
|
||||
htmlspecialchars($line["reg_exp"]),
|
||||
$line["field"],
|
||||
$where,
|
||||
$line["inverse"] ? __("(inverse)") : "") . "</li>";
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
function getfiltertree(): void {
|
||||
$root = array();
|
||||
$root['id'] = 'root';
|
||||
$root['name'] = __('Filters');
|
||||
$root['enabled'] = true;
|
||||
$root['items'] = array();
|
||||
|
||||
$filter_search = ($_SESSION["prefs_filter_search"] ?? "");
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT *,
|
||||
(SELECT action_param FROM ttrss_filters2_actions
|
||||
WHERE filter_id = ttrss_filters2.id ORDER BY id LIMIT 1) AS action_param,
|
||||
(SELECT action_id FROM ttrss_filters2_actions
|
||||
WHERE filter_id = ttrss_filters2.id ORDER BY id LIMIT 1) AS action_id,
|
||||
(SELECT description FROM ttrss_filter_actions
|
||||
WHERE id = (SELECT action_id FROM ttrss_filters2_actions
|
||||
WHERE filter_id = ttrss_filters2.id ORDER BY id LIMIT 1)) AS action_name,
|
||||
(SELECT reg_exp FROM ttrss_filters2_rules
|
||||
WHERE filter_id = ttrss_filters2.id ORDER BY id LIMIT 1) AS reg_exp
|
||||
FROM ttrss_filters2 WHERE
|
||||
owner_uid = ? ORDER BY order_id, title");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
|
||||
$folder = array();
|
||||
$folder['items'] = array();
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
$name = $this->_get_name($line["id"]);
|
||||
|
||||
$match_ok = false;
|
||||
if ($filter_search) {
|
||||
if (mb_strpos($line['title'], $filter_search) !== false) {
|
||||
$match_ok = true;
|
||||
}
|
||||
|
||||
$rules_sth = $this->pdo->prepare("SELECT reg_exp
|
||||
FROM ttrss_filters2_rules WHERE filter_id = ?");
|
||||
$rules_sth->execute([$line['id']]);
|
||||
|
||||
while ($rule_line = $rules_sth->fetch()) {
|
||||
if (mb_strpos($rule_line['reg_exp'], $filter_search) !== false) {
|
||||
$match_ok = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($line['action_id'] == self::ACTION_LABEL) {
|
||||
$label_sth = $this->pdo->prepare("SELECT fg_color, bg_color
|
||||
FROM ttrss_labels2 WHERE caption = ? AND
|
||||
owner_uid = ?");
|
||||
$label_sth->execute([$line['action_param'], $_SESSION['uid']]);
|
||||
|
||||
if ($label_row = $label_sth->fetch()) {
|
||||
//$fg_color = $label_row["fg_color"];
|
||||
$bg_color = $label_row["bg_color"];
|
||||
|
||||
$name[1] = "<i class=\"material-icons\" style='color : $bg_color; margin-right : 4px'>label</i>" . $name[1];
|
||||
}
|
||||
}
|
||||
|
||||
$filter = array();
|
||||
$filter['id'] = 'FILTER:' . $line['id'];
|
||||
$filter['bare_id'] = $line['id'];
|
||||
$filter['name'] = $name[0];
|
||||
$filter['param'] = $name[1];
|
||||
$filter['checkbox'] = false;
|
||||
$filter['last_triggered'] = $line["last_triggered"] ? TimeHelper::make_local_datetime($line["last_triggered"], false) : null;
|
||||
$filter['enabled'] = sql_bool_to_bool($line["enabled"]);
|
||||
$filter['rules'] = $this->_get_rules_list($line['id']);
|
||||
|
||||
if (!$filter_search || $match_ok) {
|
||||
array_push($folder['items'], $filter);
|
||||
}
|
||||
}
|
||||
|
||||
$root['items'] = $folder['items'];
|
||||
|
||||
$fl = array();
|
||||
$fl['identifier'] = 'id';
|
||||
$fl['label'] = 'name';
|
||||
$fl['items'] = array($root);
|
||||
|
||||
print json_encode($fl);
|
||||
}
|
||||
|
||||
function edit(): void {
|
||||
|
||||
$filter_id = (int) clean($_REQUEST["id"] ?? 0);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2
|
||||
WHERE id = ? AND owner_uid = ?");
|
||||
$sth->execute([$filter_id, $_SESSION['uid']]);
|
||||
|
||||
if (empty($filter_id) || $row = $sth->fetch()) {
|
||||
$rv = [
|
||||
"id" => $filter_id,
|
||||
"enabled" => $row["enabled"] ?? true,
|
||||
"match_any_rule" => $row["match_any_rule"] ?? false,
|
||||
"inverse" => $row["inverse"] ?? false,
|
||||
"title" => $row["title"] ?? "",
|
||||
"rules" => [],
|
||||
"actions" => [],
|
||||
"filter_types" => [],
|
||||
"action_types" => [],
|
||||
"plugin_actions" => [],
|
||||
"labels" => Labels::get_all($_SESSION["uid"])
|
||||
];
|
||||
|
||||
$res = $this->pdo->query("SELECT id,description
|
||||
FROM ttrss_filter_types WHERE id != 5 ORDER BY description");
|
||||
|
||||
while ($line = $res->fetch()) {
|
||||
$rv["filter_types"][$line["id"]] = __($line["description"]);
|
||||
}
|
||||
|
||||
$res = $this->pdo->query("SELECT id,description FROM ttrss_filter_actions
|
||||
ORDER BY name");
|
||||
|
||||
while ($line = $res->fetch()) {
|
||||
$rv["action_types"][$line["id"]] = __($line["description"]);
|
||||
}
|
||||
|
||||
$filter_actions = PluginHost::getInstance()->get_filter_actions();
|
||||
|
||||
foreach ($filter_actions as $fclass => $factions) {
|
||||
foreach ($factions as $faction) {
|
||||
|
||||
$rv["plugin_actions"][$fclass . ":" . $faction["action"]] =
|
||||
$fclass . ": " . $faction["description"];
|
||||
}
|
||||
}
|
||||
|
||||
if ($filter_id) {
|
||||
$rules_sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2_rules
|
||||
WHERE filter_id = ? ORDER BY reg_exp, id");
|
||||
$rules_sth->execute([$filter_id]);
|
||||
|
||||
while ($rrow = $rules_sth->fetch(PDO::FETCH_ASSOC)) {
|
||||
if ($rrow["match_on"]) {
|
||||
$rrow["feed_id"] = json_decode($rrow["match_on"], true);
|
||||
} else {
|
||||
if ($rrow["cat_filter"]) {
|
||||
$feed_id = "CAT:" . (int)$rrow["cat_id"];
|
||||
} else {
|
||||
$feed_id = (int)$rrow["feed_id"];
|
||||
}
|
||||
|
||||
$rrow["feed_id"] = ["" . $feed_id]; // set item type to string for in_array()
|
||||
}
|
||||
|
||||
unset($rrow["cat_filter"]);
|
||||
unset($rrow["cat_id"]);
|
||||
unset($rrow["filter_id"]);
|
||||
unset($rrow["id"]);
|
||||
if (!$rrow["inverse"]) unset($rrow["inverse"]);
|
||||
unset($rrow["match_on"]);
|
||||
|
||||
$rrow["name"] = $this->_get_rule_name($rrow);
|
||||
|
||||
array_push($rv["rules"], $rrow);
|
||||
}
|
||||
|
||||
$actions_sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2_actions
|
||||
WHERE filter_id = ? ORDER BY id");
|
||||
$actions_sth->execute([$filter_id]);
|
||||
|
||||
while ($arow = $actions_sth->fetch(PDO::FETCH_ASSOC)) {
|
||||
$arow["action_param_label"] = $arow["action_param"];
|
||||
|
||||
unset($arow["filter_id"]);
|
||||
unset($arow["id"]);
|
||||
|
||||
$arow["name"] = $this->_get_action_name($arow);
|
||||
|
||||
array_push($rv["actions"], $arow);
|
||||
}
|
||||
}
|
||||
print json_encode($rv);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $rule
|
||||
*/
|
||||
private function _get_rule_name(?array $rule = null): string {
|
||||
if (!$rule) $rule = json_decode(clean($_REQUEST["rule"]), true);
|
||||
|
||||
$feeds = $rule["feed_id"];
|
||||
$feeds_fmt = [];
|
||||
|
||||
if (!is_array($feeds)) $feeds = [$feeds];
|
||||
|
||||
foreach ($feeds as $feed_id) {
|
||||
|
||||
if (strpos($feed_id, "CAT:") === 0) {
|
||||
$feed_id = (int)substr($feed_id, 4);
|
||||
array_push($feeds_fmt, Feeds::_get_cat_title($feed_id));
|
||||
} else {
|
||||
if ($feed_id)
|
||||
array_push($feeds_fmt, Feeds::_get_title((int)$feed_id));
|
||||
else
|
||||
array_push($feeds_fmt, __("All feeds"));
|
||||
}
|
||||
}
|
||||
|
||||
$feed = implode(", ", $feeds_fmt);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT description FROM ttrss_filter_types
|
||||
WHERE id = ?");
|
||||
$sth->execute([(int)$rule["filter_type"]]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$filter_type = $row["description"];
|
||||
} else {
|
||||
$filter_type = "?UNKNOWN?";
|
||||
}
|
||||
|
||||
$inverse = isset($rule["inverse"]) ? "inverse" : "";
|
||||
|
||||
return "<span class='filterRule $inverse'>" .
|
||||
T_sprintf("%s on %s in %s %s", htmlspecialchars($rule["reg_exp"]),
|
||||
"<span class='field'>$filter_type</span>", "<span class='feed'>$feed</span>", isset($rule["inverse"]) ? __("(inverse)") : "") . "</span>";
|
||||
}
|
||||
|
||||
function printRuleName(): void {
|
||||
print $this->_get_rule_name(json_decode(clean($_REQUEST["rule"]), true));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $action
|
||||
*/
|
||||
private function _get_action_name(?array $action = null): string {
|
||||
if (!$action) {
|
||||
return "";
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT description FROM
|
||||
ttrss_filter_actions WHERE id = ?");
|
||||
$sth->execute([(int)$action["action_id"]]);
|
||||
|
||||
$title = "";
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
|
||||
$title = __($row["description"]);
|
||||
|
||||
if ($action["action_id"] == self::ACTION_PLUGIN) {
|
||||
list ($pfclass, $pfaction) = explode(":", $action["action_param"]);
|
||||
|
||||
$filter_actions = PluginHost::getInstance()->get_filter_actions();
|
||||
|
||||
foreach ($filter_actions as $fclass => $factions) {
|
||||
foreach ($factions as $faction) {
|
||||
if ($pfaction == $faction["action"] && $pfclass == $fclass) {
|
||||
$title .= ": " . $fclass . ": " . $faction["description"];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (in_array($action["action_id"], self::PARAM_ACTIONS)) {
|
||||
$title .= ": " . $action["action_param"];
|
||||
}
|
||||
}
|
||||
|
||||
return $title;
|
||||
}
|
||||
|
||||
function printActionName(): void {
|
||||
print $this->_get_action_name(json_decode(clean($_REQUEST["action"] ?? ""), true));
|
||||
}
|
||||
|
||||
function editSave(): void {
|
||||
$filter_id = (int) clean($_REQUEST["id"]);
|
||||
$enabled = checkbox_to_sql_bool($_REQUEST["enabled"] ?? false);
|
||||
$match_any_rule = checkbox_to_sql_bool($_REQUEST["match_any_rule"] ?? false);
|
||||
$inverse = checkbox_to_sql_bool($_REQUEST["inverse"] ?? false);
|
||||
$title = clean($_REQUEST["title"]);
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_filters2 SET enabled = ?,
|
||||
match_any_rule = ?,
|
||||
inverse = ?,
|
||||
title = ?
|
||||
WHERE id = ? AND owner_uid = ?");
|
||||
|
||||
$sth->execute([$enabled, $match_any_rule, $inverse, $title, $filter_id, $_SESSION['uid']]);
|
||||
|
||||
$this->_save_rules_and_actions($filter_id);
|
||||
|
||||
$this->pdo->commit();
|
||||
}
|
||||
|
||||
function remove(): void {
|
||||
|
||||
$ids = explode(",", clean($_REQUEST["ids"]));
|
||||
$ids_qmarks = arr_qmarks($ids);
|
||||
|
||||
$sth = $this->pdo->prepare("DELETE FROM ttrss_filters2 WHERE id IN ($ids_qmarks)
|
||||
AND owner_uid = ?");
|
||||
$sth->execute([...$ids, $_SESSION['uid']]);
|
||||
}
|
||||
|
||||
private function _save_rules_and_actions(int $filter_id): void {
|
||||
|
||||
$sth = $this->pdo->prepare("DELETE FROM ttrss_filters2_rules WHERE filter_id = ?");
|
||||
$sth->execute([$filter_id]);
|
||||
|
||||
$sth = $this->pdo->prepare("DELETE FROM ttrss_filters2_actions WHERE filter_id = ?");
|
||||
$sth->execute([$filter_id]);
|
||||
|
||||
if (!is_array(clean($_REQUEST["rule"] ?? ""))) $_REQUEST["rule"] = [];
|
||||
if (!is_array(clean($_REQUEST["action"] ?? ""))) $_REQUEST["action"] = [];
|
||||
|
||||
if ($filter_id) {
|
||||
/* create rules */
|
||||
|
||||
$rules = array();
|
||||
$actions = array();
|
||||
|
||||
foreach (clean($_REQUEST["rule"]) as $rule) {
|
||||
$rule = json_decode($rule, true);
|
||||
unset($rule["id"]);
|
||||
|
||||
if (array_search($rule, $rules) === false) {
|
||||
array_push($rules, $rule);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (clean($_REQUEST["action"]) as $action) {
|
||||
$action = json_decode($action, true);
|
||||
unset($action["id"]);
|
||||
|
||||
if (array_search($action, $actions) === false) {
|
||||
array_push($actions, $action);
|
||||
}
|
||||
}
|
||||
|
||||
$rsth = $this->pdo->prepare("INSERT INTO ttrss_filters2_rules
|
||||
(filter_id, reg_exp,filter_type,feed_id,cat_id,match_on,inverse) VALUES
|
||||
(?, ?, ?, NULL, NULL, ?, ?)");
|
||||
|
||||
foreach ($rules as $rule) {
|
||||
if ($rule) {
|
||||
|
||||
$reg_exp = trim($rule["reg_exp"]);
|
||||
$inverse = isset($rule["inverse"]) ? 1 : 0;
|
||||
|
||||
$filter_type = (int)trim($rule["filter_type"]);
|
||||
$match_on = json_encode($rule["feed_id"]);
|
||||
|
||||
$rsth->execute([$filter_id, $reg_exp, $filter_type, $match_on, $inverse]);
|
||||
}
|
||||
}
|
||||
|
||||
$asth = $this->pdo->prepare("INSERT INTO ttrss_filters2_actions
|
||||
(filter_id, action_id, action_param) VALUES
|
||||
(?, ?, ?)");
|
||||
|
||||
foreach ($actions as $action) {
|
||||
if ($action) {
|
||||
|
||||
$action_id = (int)$action["action_id"];
|
||||
$action_param = $action["action_param"];
|
||||
$action_param_label = $action["action_param_label"];
|
||||
|
||||
if ($action_id == self::ACTION_LABEL) {
|
||||
$action_param = $action_param_label;
|
||||
}
|
||||
|
||||
if ($action_id == self::ACTION_SCORE) {
|
||||
$action_param = (int)str_replace("+", "", $action_param);
|
||||
}
|
||||
|
||||
if (in_array($action_id, [self::ACTION_TAG, self::ACTION_REMOVE_TAG])) {
|
||||
$action_param = implode(", ", FeedItem_Common::normalize_categories(
|
||||
explode(",", $action_param)));
|
||||
}
|
||||
|
||||
$asth->execute([$filter_id, $action_id, $action_param]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function add(): void {
|
||||
$enabled = checkbox_to_sql_bool($_REQUEST["enabled"] ?? false);
|
||||
$match_any_rule = checkbox_to_sql_bool($_REQUEST["match_any_rule"] ?? false);
|
||||
$title = clean($_REQUEST["title"]);
|
||||
$inverse = checkbox_to_sql_bool($_REQUEST["inverse"] ?? false);
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
/* create base filter */
|
||||
|
||||
$sth = $this->pdo->prepare("INSERT INTO ttrss_filters2
|
||||
(owner_uid, match_any_rule, enabled, title, inverse) VALUES
|
||||
(?, ?, ?, ?, ?)");
|
||||
|
||||
$sth->execute([$_SESSION['uid'], $match_any_rule, $enabled, $title, $inverse]);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT MAX(id) AS id FROM ttrss_filters2
|
||||
WHERE owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$filter_id = $row['id'];
|
||||
$this->_save_rules_and_actions($filter_id);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
}
|
||||
|
||||
function index(): void {
|
||||
if (array_key_exists("search", $_REQUEST)) {
|
||||
$filter_search = clean($_REQUEST["search"]);
|
||||
$_SESSION["prefs_filter_search"] = $filter_search;
|
||||
} else {
|
||||
$filter_search = ($_SESSION["prefs_filter_search"] ?? "");
|
||||
}
|
||||
|
||||
?>
|
||||
<div dojoType='dijit.layout.BorderContainer' gutters='false'>
|
||||
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='top'>
|
||||
<div dojoType='fox.Toolbar'>
|
||||
|
||||
<div style='float : right; padding-right : 4px;'>
|
||||
<form dojoType="dijit.form.Form" onsubmit="dijit.byId('filterTree').reload(); return false;">
|
||||
<input dojoType="dijit.form.TextBox" id="filter_search" size="20" type="search"
|
||||
value="<?= htmlspecialchars($filter_search) ?>">
|
||||
<button dojoType="dijit.form.Button" type="submit">
|
||||
<?= __('Search') ?></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div dojoType="fox.form.DropDownButton">
|
||||
<span><?= __('Select') ?></span>
|
||||
<div dojoType="dijit.Menu" style="display: none;">
|
||||
<div onclick="dijit.byId('filterTree').model.setAllChecked(true)"
|
||||
dojoType="dijit.MenuItem"><?= __('All') ?></div>
|
||||
<div onclick="dijit.byId('filterTree').model.setAllChecked(false)"
|
||||
dojoType="dijit.MenuItem"><?= __('None') ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button dojoType="dijit.form.Button" onclick="return Filters.edit()">
|
||||
<?= __('Create filter') ?></button>
|
||||
<button dojoType="dijit.form.Button" onclick="return dijit.byId('filterTree').joinSelectedFilters()">
|
||||
<?= __('Combine') ?></button>
|
||||
<button dojoType="dijit.form.Button" onclick="return dijit.byId('filterTree').removeSelectedFilters()">
|
||||
<?= __('Remove') ?></button>
|
||||
<button dojoType="dijit.form.Button" onclick="return dijit.byId('filterTree').resetFilterOrder()">
|
||||
<?= __('Reset sort order') ?></button>
|
||||
<button dojoType="dijit.form.Button" onclick="return dijit.byId('filterTree').toggleRules()">
|
||||
<?= __('Toggle rule display') ?></button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='center'>
|
||||
<div dojoType="fox.PrefFilterStore" jsId="filterStore"
|
||||
url="backend.php?op=Pref_Filters&method=getfiltertree">
|
||||
</div>
|
||||
<div dojoType="lib.CheckBoxStoreModel" jsId="filterModel" store="filterStore"
|
||||
query="{id:'root'}" rootId="root" rootLabel="Filters"
|
||||
childrenAttrs="items" checkboxStrict="false" checkboxAll="false">
|
||||
</div>
|
||||
<div dojoType="fox.PrefFilterTree" id="filterTree" dndController="dijit.tree.dndSource"
|
||||
betweenThreshold="5" model="filterModel" openOnClick="true">
|
||||
</div>
|
||||
</div>
|
||||
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefFilters") ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
function editrule(): void {
|
||||
/** @var array<int, int|string> */
|
||||
$feed_ids = explode(",", clean($_REQUEST["ids"]));
|
||||
|
||||
print json_encode([
|
||||
"multiselect" => $this->_feed_multi_select("feed_id", $feed_ids, 'required="1" style="width : 100%; height : 300px" dojoType="fox.form.ValidationMultiSelect"')
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function _get_name(int $id): array {
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT title,match_any_rule,f.inverse AS inverse,COUNT(DISTINCT r.id) AS num_rules,COUNT(DISTINCT a.id) AS num_actions
|
||||
FROM ttrss_filters2 AS f LEFT JOIN ttrss_filters2_rules AS r
|
||||
ON (r.filter_id = f.id)
|
||||
LEFT JOIN ttrss_filters2_actions AS a
|
||||
ON (a.filter_id = f.id) WHERE f.id = ? GROUP BY f.title, f.match_any_rule, f.inverse");
|
||||
$sth->execute([$id]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
|
||||
$title = $row["title"];
|
||||
$num_rules = $row["num_rules"];
|
||||
$num_actions = $row["num_actions"];
|
||||
$match_any_rule = $row["match_any_rule"];
|
||||
$inverse = $row["inverse"];
|
||||
|
||||
if (!$title) $title = __("[No caption]");
|
||||
|
||||
$title = sprintf(_ngettext("%s (%d rule)", "%s (%d rules)", (int) $num_rules), $title, $num_rules);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2_actions
|
||||
WHERE filter_id = ? ORDER BY id LIMIT 1");
|
||||
$sth->execute([$id]);
|
||||
|
||||
$actions = "";
|
||||
|
||||
if ($line = $sth->fetch()) {
|
||||
$actions = $this->_get_action_name($line);
|
||||
|
||||
$num_actions -= 1;
|
||||
}
|
||||
|
||||
if ($match_any_rule) $title .= " (" . __("matches any rule") . ")";
|
||||
if ($inverse) $title .= " (" . __("inverse") . ")";
|
||||
|
||||
if ($num_actions > 0)
|
||||
$actions = sprintf(_ngettext("%s (+%d action)", "%s (+%d actions)", (int) $num_actions), $actions, $num_actions);
|
||||
|
||||
return [$title, $actions];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function join(): void {
|
||||
/** @var array<int, int> */
|
||||
$ids = array_map("intval", explode(",", clean($_REQUEST["ids"])));
|
||||
|
||||
if (count($ids) > 1) {
|
||||
$base_id = array_shift($ids);
|
||||
$ids_qmarks = arr_qmarks($ids);
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_filters2_rules
|
||||
SET filter_id = ? WHERE filter_id IN ($ids_qmarks)");
|
||||
$sth->execute([$base_id, ...$ids]);
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_filters2_actions
|
||||
SET filter_id = ? WHERE filter_id IN ($ids_qmarks)");
|
||||
$sth->execute([$base_id, ...$ids]);
|
||||
|
||||
$sth = $this->pdo->prepare("DELETE FROM ttrss_filters2 WHERE id IN ($ids_qmarks)");
|
||||
$sth->execute($ids);
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_filters2 SET match_any_rule = true WHERE id = ?");
|
||||
$sth->execute([$base_id]);
|
||||
|
||||
$this->pdo->commit();
|
||||
|
||||
$this->_optimize($base_id);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private function _optimize(int $id): void {
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2_actions
|
||||
WHERE filter_id = ?");
|
||||
$sth->execute([$id]);
|
||||
|
||||
$tmp = array();
|
||||
$dupe_ids = array();
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$id = $line["id"];
|
||||
unset($line["id"]);
|
||||
|
||||
if (array_search($line, $tmp) === false) {
|
||||
array_push($tmp, $line);
|
||||
} else {
|
||||
array_push($dupe_ids, $id);
|
||||
}
|
||||
}
|
||||
|
||||
if (count($dupe_ids) > 0) {
|
||||
$ids_str = join(",", $dupe_ids);
|
||||
|
||||
$this->pdo->query("DELETE FROM ttrss_filters2_actions WHERE id IN ($ids_str)");
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2_rules
|
||||
WHERE filter_id = ?");
|
||||
$sth->execute([$id]);
|
||||
|
||||
$tmp = array();
|
||||
$dupe_ids = array();
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$id = $line["id"];
|
||||
unset($line["id"]);
|
||||
|
||||
if (array_search($line, $tmp) === false) {
|
||||
array_push($tmp, $line);
|
||||
} else {
|
||||
array_push($dupe_ids, $id);
|
||||
}
|
||||
}
|
||||
|
||||
if (count($dupe_ids) > 0) {
|
||||
$ids_str = join(",", $dupe_ids);
|
||||
|
||||
$this->pdo->query("DELETE FROM ttrss_filters2_rules WHERE id IN ($ids_str)");
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int|string> $default_ids
|
||||
*/
|
||||
private function _feed_multi_select(string $id, array $default_ids = [], string $attributes = "",
|
||||
bool $include_all_feeds = true, ?int $root_id = null, int $nest_level = 0): string {
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$rv = "";
|
||||
|
||||
// print_r(in_array("CAT:6",$default_ids));
|
||||
|
||||
if (!$root_id) {
|
||||
$rv .= "<select multiple=\true\" id=\"$id\" name=\"$id\" $attributes>";
|
||||
if ($include_all_feeds) {
|
||||
$is_selected = (in_array("0", $default_ids)) ? "selected=\"1\"" : "";
|
||||
$rv .= "<option $is_selected value=\"0\">".__('All feeds')."</option>";
|
||||
}
|
||||
}
|
||||
|
||||
if (get_pref(Prefs::ENABLE_FEED_CATS)) {
|
||||
|
||||
if (!$root_id) $root_id = null;
|
||||
|
||||
$sth = $pdo->prepare("SELECT id,title,
|
||||
(SELECT COUNT(id) FROM ttrss_feed_categories AS c2 WHERE
|
||||
c2.parent_cat = ttrss_feed_categories.id) AS num_children
|
||||
FROM ttrss_feed_categories
|
||||
WHERE owner_uid = :uid AND
|
||||
(parent_cat = :root_id OR (:root_id IS NULL AND parent_cat IS NULL)) ORDER BY title");
|
||||
|
||||
$sth->execute([":uid" => $_SESSION['uid'], ":root_id" => $root_id]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
for ($i = 0; $i < $nest_level; $i++)
|
||||
$line["title"] = " " . $line["title"];
|
||||
|
||||
$is_selected = in_array("CAT:".$line["id"], $default_ids) ? "selected=\"1\"" : "";
|
||||
|
||||
$rv .= sprintf("<option $is_selected value='CAT:%d'>%s</option>",
|
||||
$line["id"], htmlspecialchars($line["title"]));
|
||||
|
||||
if ($line["num_children"] > 0)
|
||||
$rv .= $this->_feed_multi_select($id, $default_ids, $attributes,
|
||||
$include_all_feeds, $line["id"], $nest_level+1);
|
||||
|
||||
$f_sth = $pdo->prepare("SELECT id,title FROM ttrss_feeds
|
||||
WHERE cat_id = ? AND owner_uid = ? ORDER BY title");
|
||||
|
||||
$f_sth->execute([$line['id'], $_SESSION['uid']]);
|
||||
|
||||
while ($fline = $f_sth->fetch()) {
|
||||
$is_selected = (in_array($fline["id"], $default_ids)) ? "selected=\"1\"" : "";
|
||||
|
||||
$fline["title"] = " " . $fline["title"];
|
||||
|
||||
for ($i = 0; $i < $nest_level; $i++)
|
||||
$fline["title"] = " " . $fline["title"];
|
||||
|
||||
$rv .= sprintf("<option $is_selected value='%d'>%s</option>",
|
||||
$fline["id"], htmlspecialchars($fline["title"]));
|
||||
}
|
||||
}
|
||||
|
||||
if (!$root_id) {
|
||||
$is_selected = in_array("CAT:0", $default_ids) ? "selected=\"1\"" : "";
|
||||
|
||||
$rv .= sprintf("<option $is_selected value='CAT:0'>%s</option>",
|
||||
__("Uncategorized"));
|
||||
|
||||
$f_sth = $pdo->prepare("SELECT id,title FROM ttrss_feeds
|
||||
WHERE cat_id IS NULL AND owner_uid = ? ORDER BY title");
|
||||
$f_sth->execute([$_SESSION['uid']]);
|
||||
|
||||
while ($fline = $f_sth->fetch()) {
|
||||
$is_selected = in_array($fline["id"], $default_ids) ? "selected=\"1\"" : "";
|
||||
|
||||
$fline["title"] = " " . $fline["title"];
|
||||
|
||||
for ($i = 0; $i < $nest_level; $i++)
|
||||
$fline["title"] = " " . $fline["title"];
|
||||
|
||||
$rv .= sprintf("<option $is_selected value='%d'>%s</option>",
|
||||
$fline["id"], htmlspecialchars($fline["title"]));
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
$sth = $pdo->prepare("SELECT id,title FROM ttrss_feeds
|
||||
WHERE owner_uid = ? ORDER BY title");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
$is_selected = (in_array($line["id"], $default_ids)) ? "selected=\"1\"" : "";
|
||||
|
||||
$rv .= sprintf("<option $is_selected value='%d'>%s</option>",
|
||||
$line["id"], htmlspecialchars($line["title"]));
|
||||
}
|
||||
}
|
||||
|
||||
if (!$root_id) {
|
||||
$rv .= "</select>";
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
}
|
||||
225
classes/Pref_Labels.php
Normal file
225
classes/Pref_Labels.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
class Pref_Labels extends Handler_Protected {
|
||||
|
||||
function csrf_ignore(string $method): bool {
|
||||
$csrf_ignored = array("index", "getlabeltree");
|
||||
|
||||
return array_search($method, $csrf_ignored) !== false;
|
||||
}
|
||||
|
||||
function edit(): void {
|
||||
$label = ORM::for_table('ttrss_labels2')
|
||||
->where('owner_uid', $_SESSION['uid'])
|
||||
->find_one($_REQUEST['id']);
|
||||
|
||||
if ($label) {
|
||||
print json_encode($label->as_array());
|
||||
}
|
||||
}
|
||||
|
||||
function getlabeltree(): void {
|
||||
$root = array();
|
||||
$root['id'] = 'root';
|
||||
$root['name'] = __('Labels');
|
||||
$root['items'] = array();
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT *
|
||||
FROM ttrss_labels2
|
||||
WHERE owner_uid = ?
|
||||
ORDER BY caption");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$label = array();
|
||||
$label['id'] = 'LABEL:' . $line['id'];
|
||||
$label['bare_id'] = $line['id'];
|
||||
$label['name'] = $line['caption'];
|
||||
$label['fg_color'] = $line['fg_color'];
|
||||
$label['bg_color'] = $line['bg_color'];
|
||||
$label['type'] = 'label';
|
||||
$label['checkbox'] = false;
|
||||
|
||||
array_push($root['items'], $label);
|
||||
}
|
||||
|
||||
$fl = array();
|
||||
$fl['identifier'] = 'id';
|
||||
$fl['label'] = 'name';
|
||||
$fl['items'] = array($root);
|
||||
|
||||
print json_encode($fl);
|
||||
}
|
||||
|
||||
function colorset(): void {
|
||||
$kind = clean($_REQUEST["kind"]);
|
||||
$ids = explode(',', clean($_REQUEST["ids"]));
|
||||
$color = clean($_REQUEST["color"]);
|
||||
$fg = clean($_REQUEST["fg"]);
|
||||
$bg = clean($_REQUEST["bg"]);
|
||||
|
||||
foreach ($ids as $id) {
|
||||
|
||||
if ($kind == "fg" || $kind == "bg") {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_labels2 SET
|
||||
{$kind}_color = ? WHERE id = ?
|
||||
AND owner_uid = ?");
|
||||
|
||||
$sth->execute([$color, $id, $_SESSION['uid']]);
|
||||
|
||||
} else {
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_labels2 SET
|
||||
fg_color = ?, bg_color = ? WHERE id = ?
|
||||
AND owner_uid = ?");
|
||||
|
||||
$sth->execute([$fg, $bg, $id, $_SESSION['uid']]);
|
||||
}
|
||||
|
||||
/* Remove cached data */
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET label_cache = ''
|
||||
WHERE owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
}
|
||||
}
|
||||
|
||||
function colorreset(): void {
|
||||
$ids = explode(',', clean($_REQUEST["ids"]));
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_labels2 SET
|
||||
fg_color = '', bg_color = '' WHERE id = ?
|
||||
AND owner_uid = ?");
|
||||
$sth->execute([$id, $_SESSION['uid']]);
|
||||
|
||||
/* Remove cached data */
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET label_cache = ''
|
||||
WHERE owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
}
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
|
||||
$id = clean($_REQUEST["id"]);
|
||||
$caption = clean($_REQUEST["caption"]);
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT caption FROM ttrss_labels2
|
||||
WHERE id = ? AND owner_uid = ?");
|
||||
$sth->execute([$id, $_SESSION['uid']]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$old_caption = $row["caption"];
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT id FROM ttrss_labels2
|
||||
WHERE caption = ? AND owner_uid = ?");
|
||||
$sth->execute([$caption, $_SESSION['uid']]);
|
||||
|
||||
if (!$sth->fetch()) {
|
||||
if ($caption) {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_labels2 SET
|
||||
caption = ? WHERE id = ? AND
|
||||
owner_uid = ?");
|
||||
$sth->execute([$caption, $id, $_SESSION['uid']]);
|
||||
|
||||
/* Update filters that reference label being renamed */
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_filters2_actions SET
|
||||
action_param = ? WHERE action_param = ?
|
||||
AND action_id = 7
|
||||
AND filter_id IN (SELECT id FROM ttrss_filters2 WHERE owner_uid = ?)");
|
||||
|
||||
$sth->execute([$caption, $old_caption, $_SESSION['uid']]);
|
||||
|
||||
print clean($_REQUEST["caption"]);
|
||||
} else {
|
||||
print $old_caption;
|
||||
}
|
||||
} else {
|
||||
print $old_caption;
|
||||
}
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
|
||||
}
|
||||
|
||||
function remove(): void {
|
||||
/** @var array<int, int> */
|
||||
$ids = array_map("intval", explode(",", clean($_REQUEST["ids"])));
|
||||
|
||||
foreach ($ids as $id) {
|
||||
Labels::remove($id, $_SESSION["uid"]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function add(): void {
|
||||
$caption = clean($_REQUEST["caption"]);
|
||||
$output = clean($_REQUEST["output"] ?? false);
|
||||
|
||||
if ($caption) {
|
||||
if (Labels::create($caption)) {
|
||||
if (!$output) {
|
||||
print T_sprintf("Created label <b>%s</b>", htmlspecialchars($caption));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function index(): void {
|
||||
?>
|
||||
<div dojoType='dijit.layout.BorderContainer' gutters='false'>
|
||||
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='top'>
|
||||
<div dojoType='fox.Toolbar'>
|
||||
<div dojoType='fox.form.DropDownButton'>
|
||||
<span><?= __('Select') ?></span>
|
||||
<div dojoType='dijit.Menu' style='display: none'>
|
||||
<div onclick="dijit.byId('labelTree').model.setAllChecked(true)"
|
||||
dojoType='dijit.MenuItem'><?=('All') ?></div>
|
||||
<div onclick="dijit.byId('labelTree').model.setAllChecked(false)"
|
||||
dojoType='dijit.MenuItem'><?=('None') ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button dojoType='dijit.form.Button' onclick='CommonDialogs.addLabel()'>
|
||||
<?=('Create label') ?></button dojoType='dijit.form.Button'>
|
||||
|
||||
<button dojoType='dijit.form.Button' onclick="dijit.byId('labelTree').removeSelected()">
|
||||
<?=('Remove') ?></button dojoType='dijit.form.Button'>
|
||||
|
||||
<button dojoType='dijit.form.Button' onclick="dijit.byId('labelTree').resetColors()">
|
||||
<?=('Clear colors') ?></button dojoType='dijit.form.Button'>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='center'>
|
||||
<div dojoType='dojo.data.ItemFileWriteStore' jsId='labelStore'
|
||||
url='backend.php?op=Pref_Labels&method=getlabeltree'>
|
||||
</div>
|
||||
|
||||
<div dojoType='lib.CheckBoxStoreModel' jsId='labelModel' store='labelStore'
|
||||
query="{id:'root'}" rootId='root'
|
||||
childrenAttrs='items' checkboxStrict='false' checkboxAll='false'>
|
||||
</div>
|
||||
|
||||
<div dojoType='fox.PrefLabelTree' id='labelTree' model='labelModel' openOnClick='true'>
|
||||
<script type='dojo/method' event='onClick' args='item'>
|
||||
var id = String(item.id);
|
||||
var bare_id = id.substr(id.indexOf(':')+1);
|
||||
|
||||
if (id.match('LABEL:')) {
|
||||
dijit.byId('labelTree').editLabel(bare_id);
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefLabels") ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
1594
classes/Pref_Prefs.php
Normal file
1594
classes/Pref_Prefs.php
Normal file
File diff suppressed because it is too large
Load Diff
229
classes/Pref_System.php
Normal file
229
classes/Pref_System.php
Normal file
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
class Pref_System extends Handler_Administrative {
|
||||
|
||||
private const LOG_PAGE_LIMIT = 15;
|
||||
|
||||
function csrf_ignore(string $method): bool {
|
||||
$csrf_ignored = array("index");
|
||||
|
||||
return array_search($method, $csrf_ignored) !== false;
|
||||
}
|
||||
|
||||
function clearLog(): void {
|
||||
$this->pdo->query("DELETE FROM ttrss_error_log");
|
||||
}
|
||||
|
||||
function sendTestEmail(): void {
|
||||
$mail_address = clean($_REQUEST["mail_address"]);
|
||||
|
||||
$mailer = new Mailer();
|
||||
|
||||
$rc = $mailer->mail(["to_name" => "",
|
||||
"to_address" => $mail_address,
|
||||
"subject" => __("Test message from tt-rss"),
|
||||
"message" => ("This message confirms that tt-rss can send outgoing mail.")
|
||||
]);
|
||||
|
||||
print json_encode(['rc' => $rc, 'error' => $mailer->error()]);
|
||||
}
|
||||
|
||||
function getphpinfo(): void {
|
||||
ob_start();
|
||||
phpinfo();
|
||||
$info = ob_get_contents();
|
||||
ob_end_clean();
|
||||
|
||||
print preg_replace( '%^.*<body>(.*)</body>.*$%ms','$1', (string)$info);
|
||||
}
|
||||
|
||||
private function _log_viewer(int $page, int $severity): void {
|
||||
$errno_values = [];
|
||||
|
||||
switch ($severity) {
|
||||
case E_USER_ERROR:
|
||||
$errno_values = [ E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR ];
|
||||
break;
|
||||
case E_USER_WARNING:
|
||||
$errno_values = [ E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR, E_WARNING, E_USER_WARNING, E_DEPRECATED, E_USER_DEPRECATED ];
|
||||
break;
|
||||
}
|
||||
|
||||
if (count($errno_values) > 0) {
|
||||
$errno_qmarks = arr_qmarks($errno_values);
|
||||
$errno_filter_qpart = "errno IN ($errno_qmarks)";
|
||||
} else {
|
||||
$errno_filter_qpart = "true";
|
||||
}
|
||||
|
||||
$offset = self::LOG_PAGE_LIMIT * $page;
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT
|
||||
COUNT(id) AS total_pages
|
||||
FROM
|
||||
ttrss_error_log
|
||||
WHERE
|
||||
$errno_filter_qpart");
|
||||
|
||||
$sth->execute($errno_values);
|
||||
|
||||
if ($res = $sth->fetch()) {
|
||||
$total_pages = (int)($res["total_pages"] / self::LOG_PAGE_LIMIT);
|
||||
} else {
|
||||
$total_pages = 0;
|
||||
}
|
||||
|
||||
?>
|
||||
<div dojoType='dijit.layout.BorderContainer' gutters='false'>
|
||||
<div region='top' dojoType='fox.Toolbar'>
|
||||
|
||||
<button dojoType='dijit.form.Button' onclick='Helpers.EventLog.refresh()'>
|
||||
<?= __('Refresh') ?>
|
||||
</button>
|
||||
|
||||
<button dojoType='dijit.form.Button' <?= ($page <= 0 ? "disabled" : "") ?>
|
||||
onclick='Helpers.EventLog.prevPage()'>
|
||||
<?= __('<<') ?>
|
||||
</button>
|
||||
|
||||
<button dojoType='dijit.form.Button' disabled>
|
||||
<?= T_sprintf('Page %d of %d', $page+1, $total_pages+1) ?>
|
||||
</button>
|
||||
|
||||
<button dojoType='dijit.form.Button' <?= ($page >= $total_pages ? "disabled" : "") ?>
|
||||
onclick='Helpers.EventLog.nextPage()'>
|
||||
<?= __('>>') ?>
|
||||
</button>
|
||||
|
||||
<button dojoType='dijit.form.Button'
|
||||
onclick='Helpers.EventLog.clear()'>
|
||||
<?= __('Clear') ?>
|
||||
</button>
|
||||
|
||||
<div class='pull-right'>
|
||||
<label><?= __("Severity:") ?></label>
|
||||
|
||||
<?= \Controls\select_hash("severity", $severity,
|
||||
[
|
||||
E_USER_ERROR => __("Errors"),
|
||||
E_USER_WARNING => __("Warnings"),
|
||||
E_USER_NOTICE => __("Everything")
|
||||
], ["onchange"=> "Helpers.EventLog.refresh()"], "severity") ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding : 0px" dojoType="dijit.layout.ContentPane" region="center">
|
||||
|
||||
<table width='100%' class='event-log'>
|
||||
|
||||
<tr>
|
||||
<th width='5%'><?= __("Error") ?></th>
|
||||
<th><?= __("Filename") ?></th>
|
||||
<th><?= __("Message") ?></th>
|
||||
<th width='5%'><?= __("User") ?></th>
|
||||
<th width='5%'><?= __("Date") ?></th>
|
||||
</tr>
|
||||
|
||||
<?php
|
||||
$sth = $this->pdo->prepare("SELECT
|
||||
errno, errstr, filename, lineno, created_at, login, context
|
||||
FROM
|
||||
ttrss_error_log LEFT JOIN ttrss_users ON (owner_uid = ttrss_users.id)
|
||||
WHERE
|
||||
$errno_filter_qpart
|
||||
ORDER BY
|
||||
ttrss_error_log.id DESC
|
||||
LIMIT ". self::LOG_PAGE_LIMIT ." OFFSET $offset");
|
||||
|
||||
$sth->execute($errno_values);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
foreach ($line as $k => $v) { $line[$k] = htmlspecialchars($v ?? ''); }
|
||||
?>
|
||||
<tr>
|
||||
<td class='errno'>
|
||||
<?= Logger::ERROR_NAMES[$line["errno"]] . " (" . $line["errno"] . ")" ?>
|
||||
</td>
|
||||
<td class='filename'><?= $line["filename"] . ":" . $line["lineno"] ?></td>
|
||||
<td class='errstr'><?= $line["errstr"] . "\n" . $line["context"] ?></td>
|
||||
<td class='login'><?= $line["login"] ?></td>
|
||||
<td class='timestamp'>
|
||||
<?= TimeHelper::make_local_datetime($line["created_at"], false) ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
function index(): void {
|
||||
|
||||
$severity = (int) ($_REQUEST["severity"] ?? E_USER_WARNING);
|
||||
$page = (int) ($_REQUEST["page"] ?? 0);
|
||||
?>
|
||||
<div dojoType='dijit.layout.AccordionContainer' region='center'>
|
||||
<?php if (Config::get(Config::LOG_DESTINATION) == Logger::LOG_DEST_SQL) { ?>
|
||||
<div dojoType='dijit.layout.AccordionPane' style='padding : 0' title='<i class="material-icons">report</i> <?= __('Event log') ?>'>
|
||||
<?php
|
||||
$this->_log_viewer($page, $severity);
|
||||
?>
|
||||
</div>
|
||||
<?php } ?>
|
||||
<div dojoType='dijit.layout.AccordionPane' style='padding : 0' title='<i class="material-icons">mail</i> <?= __('Mail configuration') ?>'>
|
||||
<div dojoType="dijit.layout.ContentPane">
|
||||
|
||||
<form dojoType="dijit.form.Form">
|
||||
<script type="dojo/method" event="onSubmit" args="evt">
|
||||
evt.preventDefault();
|
||||
if (this.validate()) {
|
||||
xhr.json("backend.php", this.getValues(), (reply) => {
|
||||
const msg = App.byId("mail-test-result");
|
||||
|
||||
if (reply.rc) {
|
||||
msg.innerHTML = __("Mail sent.");
|
||||
msg.className = 'alert alert-success';
|
||||
} else {
|
||||
msg.innerHTML = reply.error;
|
||||
msg.className = 'alert alert-danger';
|
||||
}
|
||||
|
||||
msg.show();
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<?= \Controls\hidden_tag("op", "Pref_System") ?>
|
||||
<?= \Controls\hidden_tag("method", "sendTestEmail") ?>
|
||||
|
||||
<?php
|
||||
$user = ORM::for_table('ttrss_users')->find_one($_SESSION["uid"]);
|
||||
?>
|
||||
|
||||
<fieldset>
|
||||
<label><?= __("To:") ?></label>
|
||||
<?= \Controls\input_tag("mail_address",$user->email, "text", ['required' => 1]) ?>
|
||||
<?= \Controls\submit_tag(__("Send test email")) ?>
|
||||
<span style="display: none; margin-left : 10px" class="alert alert-error" id="mail-test-result">...</span>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div dojoType='dijit.layout.AccordionPane' title='<i class="material-icons">info</i> <?= __('PHP Information') ?>'>
|
||||
<script type='dojo/method' event='onSelected' args='evt'>
|
||||
if (this.domNode.querySelector('.loading'))
|
||||
window.setTimeout(() => {
|
||||
xhr.post("backend.php", {op: 'Pref_System', method: 'getphpinfo'}, (reply) => {
|
||||
this.attr('content', `<div class='phpinfo'>${reply}</div>`);
|
||||
});
|
||||
}, 200);
|
||||
</script>
|
||||
<span class='loading'><?= __("Loading, please wait...") ?></span>
|
||||
</div>
|
||||
|
||||
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefSystem") ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
296
classes/Pref_Users.php
Normal file
296
classes/Pref_Users.php
Normal file
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
class Pref_Users extends Handler_Administrative {
|
||||
function csrf_ignore(string $method): bool {
|
||||
return $method == "index";
|
||||
}
|
||||
|
||||
function edit(): void {
|
||||
$user = ORM::for_table('ttrss_users')
|
||||
->select_expr("id,login,access_level,email,full_name,otp_enabled")
|
||||
->find_one((int)$_REQUEST["id"])
|
||||
->as_array();
|
||||
|
||||
global $access_level_names;
|
||||
|
||||
if ($user) {
|
||||
print json_encode([
|
||||
"user" => $user,
|
||||
"access_level_names" => $access_level_names
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function userdetails(): void {
|
||||
$id = (int) clean($_REQUEST["id"]);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT login,
|
||||
".SUBSTRING_FOR_DATE."(last_login,1,16) AS last_login,
|
||||
access_level,
|
||||
(SELECT COUNT(int_id) FROM ttrss_user_entries
|
||||
WHERE owner_uid = id) AS stored_articles,
|
||||
".SUBSTRING_FOR_DATE."(created,1,16) AS created
|
||||
FROM ttrss_users
|
||||
WHERE id = ?");
|
||||
$sth->execute([$id]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
|
||||
$last_login = TimeHelper::make_local_datetime(
|
||||
$row["last_login"], true);
|
||||
|
||||
$created = TimeHelper::make_local_datetime(
|
||||
$row["created"], true);
|
||||
|
||||
$stored_articles = $row["stored_articles"];
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT COUNT(id) as num_feeds FROM ttrss_feeds
|
||||
WHERE owner_uid = ?");
|
||||
$sth->execute([$id]);
|
||||
$row = $sth->fetch();
|
||||
|
||||
$num_feeds = $row["num_feeds"];
|
||||
|
||||
?>
|
||||
|
||||
<fieldset>
|
||||
<label><?= __('Registered') ?>:</label>
|
||||
<?= $created ?>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<label><?= __('Last logged in') ?>:</label>
|
||||
<?= $last_login ?>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<label><?= __('Subscribed feeds') ?>:</label>
|
||||
<?= $num_feeds ?>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<label><?= __('Stored articles') ?>:</label>
|
||||
<?= $stored_articles ?>
|
||||
</fieldset>
|
||||
|
||||
<?php
|
||||
$sth = $this->pdo->prepare("SELECT id,title,site_url FROM ttrss_feeds
|
||||
WHERE owner_uid = ? ORDER BY title");
|
||||
$sth->execute([$id]);
|
||||
?>
|
||||
|
||||
<ul class="panel panel-scrollable list list-unstyled">
|
||||
<?php while ($row = $sth->fetch()) { ?>
|
||||
<li>
|
||||
<?php
|
||||
$icon_url = Feeds::_get_icon_url($row['id'], 'images/blank_icon.gif');
|
||||
?>
|
||||
|
||||
<img class="icon" src="<?= htmlspecialchars($icon_url) ?>">
|
||||
|
||||
<a target="_blank" href="<?= htmlspecialchars($row["site_url"]) ?>">
|
||||
<?= htmlspecialchars($row["title"]) ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php } ?>
|
||||
</ul>
|
||||
|
||||
<?php
|
||||
|
||||
} else {
|
||||
print_error(__('User not found'));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function editSave(): void {
|
||||
$id = (int)$_REQUEST['id'];
|
||||
$password = clean($_REQUEST["password"]);
|
||||
$user = ORM::for_table('ttrss_users')->find_one($id);
|
||||
|
||||
if ($user) {
|
||||
$login = clean($_REQUEST["login"]);
|
||||
|
||||
if ($id == 1) $login = "admin";
|
||||
if (!$login) return;
|
||||
|
||||
$user->login = mb_strtolower($login);
|
||||
$user->access_level = (int) clean($_REQUEST["access_level"]);
|
||||
$user->email = clean($_REQUEST["email"]);
|
||||
$user->otp_enabled = checkbox_to_sql_bool($_REQUEST["otp_enabled"] ?? "");
|
||||
|
||||
// force new OTP secret when next enabled
|
||||
if (Config::get_schema_version() >= 143 && !$user->otp_enabled) {
|
||||
$user->otp_secret = null;
|
||||
}
|
||||
|
||||
$user->save();
|
||||
}
|
||||
|
||||
if ($password) {
|
||||
UserHelper::reset_password($id, false, $password);
|
||||
}
|
||||
}
|
||||
|
||||
function remove(): void {
|
||||
$ids = explode(",", clean($_REQUEST["ids"]));
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if ($id != $_SESSION["uid"] && $id != 1) {
|
||||
$sth = $this->pdo->prepare("DELETE FROM ttrss_tags WHERE owner_uid = ?");
|
||||
$sth->execute([$id]);
|
||||
|
||||
$sth = $this->pdo->prepare("DELETE FROM ttrss_feeds WHERE owner_uid = ?");
|
||||
$sth->execute([$id]);
|
||||
|
||||
$sth = $this->pdo->prepare("DELETE FROM ttrss_users WHERE id = ?");
|
||||
$sth->execute([$id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function add(): void {
|
||||
$login = clean($_REQUEST["login"]);
|
||||
|
||||
if (!$login) return; // no blank usernames
|
||||
|
||||
if (!UserHelper::find_user_by_login($login)) {
|
||||
|
||||
$new_password = make_password();
|
||||
|
||||
$user = ORM::for_table('ttrss_users')->create();
|
||||
|
||||
$user->salt = UserHelper::get_salt();
|
||||
$user->login = mb_strtolower($login);
|
||||
$user->pwd_hash = UserHelper::hash_password($new_password, $user->salt);
|
||||
$user->access_level = 0;
|
||||
$user->created = Db::NOW();
|
||||
$user->save();
|
||||
|
||||
if (!is_null(UserHelper::find_user_by_login($login))) {
|
||||
print T_sprintf("Added user %s with password %s",
|
||||
$login, $new_password);
|
||||
} else {
|
||||
print T_sprintf("Could not create user %s", $login);
|
||||
}
|
||||
} else {
|
||||
print T_sprintf("User %s already exists.", $login);
|
||||
}
|
||||
}
|
||||
|
||||
function resetPass(): void {
|
||||
UserHelper::reset_password(clean($_REQUEST["id"]));
|
||||
}
|
||||
|
||||
function index(): void {
|
||||
|
||||
global $access_level_names;
|
||||
|
||||
$user_search = clean($_REQUEST["search"] ?? "");
|
||||
|
||||
if (array_key_exists("search", $_REQUEST)) {
|
||||
$_SESSION["prefs_user_search"] = $user_search;
|
||||
} else {
|
||||
$user_search = ($_SESSION["prefs_user_search"] ?? "");
|
||||
}
|
||||
|
||||
$sort = clean($_REQUEST["sort"] ?? "");
|
||||
|
||||
if (!$sort || $sort == "undefined") {
|
||||
$sort = "login";
|
||||
}
|
||||
|
||||
if (!in_array($sort, ["login", "access_level", "created", "num_feeds", "created", "last_login"]))
|
||||
$sort = "login";
|
||||
|
||||
if ($sort != "login") $sort = "$sort DESC";
|
||||
?>
|
||||
|
||||
<div dojoType='dijit.layout.BorderContainer' gutters='false'>
|
||||
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='top'>
|
||||
<div dojoType='fox.Toolbar'>
|
||||
|
||||
<div style='float : right'>
|
||||
<form dojoType="dijit.form.Form" onsubmit="Users.reload(); return false;">
|
||||
<input dojoType='dijit.form.TextBox' id='user_search' size='20' type='search'
|
||||
value="<?= htmlspecialchars($user_search) ?>">
|
||||
<button dojoType='dijit.form.Button' type='submit'>
|
||||
<?= __('Search') ?>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div dojoType='fox.form.DropDownButton'>
|
||||
<span><?= __('Select') ?></span>
|
||||
<div dojoType='dijit.Menu' style='display: none'>
|
||||
<div onclick="Tables.select('users-list', true)"
|
||||
dojoType='dijit.MenuItem'><?= __('All') ?></div>
|
||||
<div onclick="Tables.select('users-list', false)"
|
||||
dojoType='dijit.MenuItem'><?= __('None') ?></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button dojoType='dijit.form.Button' onclick='Users.add()'>
|
||||
<?= __('Create user') ?>
|
||||
</button>
|
||||
|
||||
<button dojoType='dijit.form.Button' onclick='Users.removeSelected()'>
|
||||
<?= __('Remove') ?>
|
||||
</button>
|
||||
|
||||
<button dojoType='dijit.form.Button' onclick='Users.resetSelected()'>
|
||||
<?= __('Reset password') ?>
|
||||
</button>
|
||||
|
||||
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB_SECTION, "prefUsersToolbar") ?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='center'>
|
||||
|
||||
<table width='100%' class='users-list' id='users-list'>
|
||||
|
||||
<tr>
|
||||
<th></th>
|
||||
<th><a href='#' onclick="Users.reload('login')"><?= __('Login') ?></a></th>
|
||||
<th><a href='#' onclick="Users.reload('access_level')"><?= __('Access level') ?></a></th>
|
||||
<th><a href='#' onclick="Users.reload('num_feeds')"><?= __('Subscribed feeds') ?></a></th>
|
||||
<th><a href='#' onclick="Users.reload('created')"><?= __('Registered') ?></a></th>
|
||||
<th><a href='#' onclick="Users.reload('last_login')"><?= __('Last login') ?></a></th>
|
||||
</tr>
|
||||
|
||||
<?php
|
||||
$users = ORM::for_table('ttrss_users')
|
||||
->table_alias('u')
|
||||
->left_outer_join("ttrss_feeds", ["owner_uid", "=", "u.id"], 'f')
|
||||
->select_expr('u.*,COUNT(f.id) AS num_feeds')
|
||||
->where_like("login", $user_search ? "%$user_search%" : "%")
|
||||
->order_by_expr($sort)
|
||||
->group_by_expr('u.id')
|
||||
->find_many();
|
||||
|
||||
foreach ($users as $user) { ?>
|
||||
|
||||
<tr data-row-id='<?= $user["id"] ?>' onclick='Users.edit(<?= $user["id"] ?>)' title="<?= __('Click to edit') ?>">
|
||||
<td class='checkbox'>
|
||||
<input onclick='Tables.onRowChecked(this); event.stopPropagation();'
|
||||
dojoType='dijit.form.CheckBox' type='checkbox'>
|
||||
</td>
|
||||
|
||||
<td width='30%'>
|
||||
<i class='material-icons'>person</i>
|
||||
<strong><?= htmlspecialchars($user["login"]) ?></strong>
|
||||
</td>
|
||||
<td><?= $access_level_names[$user["access_level"]] ?></td>
|
||||
<td><?= $user["num_feeds"] ?></td>
|
||||
<td class='text-muted'><?= TimeHelper::make_local_datetime($user["created"], false) ?></td>
|
||||
<td class='text-muted'><?= TimeHelper::make_local_datetime($user["last_login"], false) ?></td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
</table>
|
||||
</div>
|
||||
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefUsers") ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
}
|
||||
428
classes/Prefs.php
Normal file
428
classes/Prefs.php
Normal file
@@ -0,0 +1,428 @@
|
||||
<?php
|
||||
class Prefs {
|
||||
// (this is the database-backed version of Config.php)
|
||||
|
||||
const PURGE_OLD_DAYS = "PURGE_OLD_DAYS";
|
||||
const DEFAULT_UPDATE_INTERVAL = "DEFAULT_UPDATE_INTERVAL";
|
||||
//const DEFAULT_ARTICLE_LIMIT = "DEFAULT_ARTICLE_LIMIT";
|
||||
//const ALLOW_DUPLICATE_POSTS = "ALLOW_DUPLICATE_POSTS";
|
||||
const ENABLE_FEED_CATS = "ENABLE_FEED_CATS";
|
||||
const SHOW_CONTENT_PREVIEW = "SHOW_CONTENT_PREVIEW";
|
||||
const SHORT_DATE_FORMAT = "SHORT_DATE_FORMAT";
|
||||
const LONG_DATE_FORMAT = "LONG_DATE_FORMAT";
|
||||
const COMBINED_DISPLAY_MODE = "COMBINED_DISPLAY_MODE";
|
||||
const HIDE_READ_FEEDS = "HIDE_READ_FEEDS";
|
||||
const ON_CATCHUP_SHOW_NEXT_FEED = "ON_CATCHUP_SHOW_NEXT_FEED";
|
||||
const FEEDS_SORT_BY_UNREAD = "FEEDS_SORT_BY_UNREAD";
|
||||
const REVERSE_HEADLINES = "REVERSE_HEADLINES";
|
||||
const DIGEST_ENABLE = "DIGEST_ENABLE";
|
||||
const CONFIRM_FEED_CATCHUP = "CONFIRM_FEED_CATCHUP";
|
||||
const CDM_AUTO_CATCHUP = "CDM_AUTO_CATCHUP";
|
||||
const _DEFAULT_VIEW_MODE = "_DEFAULT_VIEW_MODE";
|
||||
const _DEFAULT_VIEW_LIMIT = "_DEFAULT_VIEW_LIMIT";
|
||||
//const _PREFS_ACTIVE_TAB = "_PREFS_ACTIVE_TAB";
|
||||
//const STRIP_UNSAFE_TAGS = "STRIP_UNSAFE_TAGS";
|
||||
const BLACKLISTED_TAGS = "BLACKLISTED_TAGS";
|
||||
const FRESH_ARTICLE_MAX_AGE = "FRESH_ARTICLE_MAX_AGE";
|
||||
const DIGEST_CATCHUP = "DIGEST_CATCHUP";
|
||||
const CDM_EXPANDED = "CDM_EXPANDED";
|
||||
const PURGE_UNREAD_ARTICLES = "PURGE_UNREAD_ARTICLES";
|
||||
const HIDE_READ_SHOWS_SPECIAL = "HIDE_READ_SHOWS_SPECIAL";
|
||||
const VFEED_GROUP_BY_FEED = "VFEED_GROUP_BY_FEED";
|
||||
const STRIP_IMAGES = "STRIP_IMAGES";
|
||||
const _DEFAULT_VIEW_ORDER_BY = "_DEFAULT_VIEW_ORDER_BY";
|
||||
const ENABLE_API_ACCESS = "ENABLE_API_ACCESS";
|
||||
//const _COLLAPSED_SPECIAL = "_COLLAPSED_SPECIAL";
|
||||
//const _COLLAPSED_LABELS = "_COLLAPSED_LABELS";
|
||||
//const _COLLAPSED_UNCAT = "_COLLAPSED_UNCAT";
|
||||
//const _COLLAPSED_FEEDLIST = "_COLLAPSED_FEEDLIST";
|
||||
//const _MOBILE_ENABLE_CATS = "_MOBILE_ENABLE_CATS";
|
||||
//const _MOBILE_SHOW_IMAGES = "_MOBILE_SHOW_IMAGES";
|
||||
//const _MOBILE_HIDE_READ = "_MOBILE_HIDE_READ";
|
||||
//const _MOBILE_SORT_FEEDS_UNREAD = "_MOBILE_SORT_FEEDS_UNREAD";
|
||||
//const _MOBILE_BROWSE_CATS = "_MOBILE_BROWSE_CATS";
|
||||
//const _THEME_ID = "_THEME_ID";
|
||||
const USER_TIMEZONE = "USER_TIMEZONE";
|
||||
const USER_STYLESHEET = "USER_STYLESHEET";
|
||||
//const SORT_HEADLINES_BY_FEED_DATE = "SORT_HEADLINES_BY_FEED_DATE";
|
||||
const SSL_CERT_SERIAL = "SSL_CERT_SERIAL";
|
||||
const DIGEST_PREFERRED_TIME = "DIGEST_PREFERRED_TIME";
|
||||
//const _PREFS_SHOW_EMPTY_CATS = "_PREFS_SHOW_EMPTY_CATS";
|
||||
const _DEFAULT_INCLUDE_CHILDREN = "_DEFAULT_INCLUDE_CHILDREN";
|
||||
//const AUTO_ASSIGN_LABELS = "AUTO_ASSIGN_LABELS";
|
||||
const _ENABLED_PLUGINS = "_ENABLED_PLUGINS";
|
||||
//const _MOBILE_REVERSE_HEADLINES = "_MOBILE_REVERSE_HEADLINES";
|
||||
const USER_CSS_THEME = "USER_CSS_THEME";
|
||||
const USER_LANGUAGE = "USER_LANGUAGE";
|
||||
const DEFAULT_SEARCH_LANGUAGE = "DEFAULT_SEARCH_LANGUAGE";
|
||||
const _PREFS_MIGRATED = "_PREFS_MIGRATED";
|
||||
const HEADLINES_NO_DISTINCT = "HEADLINES_NO_DISTINCT";
|
||||
const DEBUG_HEADLINE_IDS = "DEBUG_HEADLINE_IDS";
|
||||
const DISABLE_CONDITIONAL_COUNTERS = "DISABLE_CONDITIONAL_COUNTERS";
|
||||
const WIDESCREEN_MODE = "WIDESCREEN_MODE";
|
||||
const CDM_ENABLE_GRID = "CDM_ENABLE_GRID";
|
||||
const DIGEST_MIN_SCORE = "DIGEST_MIN_SCORE";
|
||||
|
||||
private const _DEFAULTS = [
|
||||
Prefs::PURGE_OLD_DAYS => [ 60, Config::T_INT ],
|
||||
Prefs::DEFAULT_UPDATE_INTERVAL => [ 30, Config::T_INT ],
|
||||
//Prefs::DEFAULT_ARTICLE_LIMIT => [ 30, Config::T_INT ],
|
||||
//Prefs::ALLOW_DUPLICATE_POSTS => [ false, Config::T_BOOL ],
|
||||
Prefs::ENABLE_FEED_CATS => [ true, Config::T_BOOL ],
|
||||
Prefs::SHOW_CONTENT_PREVIEW => [ true, Config::T_BOOL ],
|
||||
Prefs::SHORT_DATE_FORMAT => [ "M d, G:i", Config::T_STRING ],
|
||||
Prefs::LONG_DATE_FORMAT => [ "D, M d Y - G:i", Config::T_STRING ],
|
||||
Prefs::COMBINED_DISPLAY_MODE => [ true, Config::T_BOOL ],
|
||||
Prefs::HIDE_READ_FEEDS => [ false, Config::T_BOOL ],
|
||||
Prefs::ON_CATCHUP_SHOW_NEXT_FEED => [ false, Config::T_BOOL ],
|
||||
Prefs::FEEDS_SORT_BY_UNREAD => [ false, Config::T_BOOL ],
|
||||
Prefs::REVERSE_HEADLINES => [ false, Config::T_BOOL ],
|
||||
Prefs::DIGEST_ENABLE => [ false, Config::T_BOOL ],
|
||||
Prefs::CONFIRM_FEED_CATCHUP => [ true, Config::T_BOOL ],
|
||||
Prefs::CDM_AUTO_CATCHUP => [ false, Config::T_BOOL ],
|
||||
Prefs::_DEFAULT_VIEW_MODE => [ "adaptive", Config::T_STRING ],
|
||||
Prefs::_DEFAULT_VIEW_LIMIT => [ 30, Config::T_INT ],
|
||||
//Prefs::_PREFS_ACTIVE_TAB => [ "", Config::T_STRING ],
|
||||
//Prefs::STRIP_UNSAFE_TAGS => [ true, Config::T_BOOL ],
|
||||
Prefs::BLACKLISTED_TAGS => [ 'main, generic, misc, uncategorized, blog, blogroll, general, news', Config::T_STRING ],
|
||||
Prefs::FRESH_ARTICLE_MAX_AGE => [ 24, Config::T_INT ],
|
||||
Prefs::DIGEST_CATCHUP => [ false, Config::T_BOOL ],
|
||||
Prefs::CDM_EXPANDED => [ true, Config::T_BOOL ],
|
||||
Prefs::PURGE_UNREAD_ARTICLES => [ true, Config::T_BOOL ],
|
||||
Prefs::HIDE_READ_SHOWS_SPECIAL => [ true, Config::T_BOOL ],
|
||||
Prefs::VFEED_GROUP_BY_FEED => [ false, Config::T_BOOL ],
|
||||
Prefs::STRIP_IMAGES => [ false, Config::T_BOOL ],
|
||||
Prefs::_DEFAULT_VIEW_ORDER_BY => [ "default", Config::T_STRING ],
|
||||
Prefs::ENABLE_API_ACCESS => [ false, Config::T_BOOL ],
|
||||
//Prefs::_COLLAPSED_SPECIAL => [ false, Config::T_BOOL ],
|
||||
//Prefs::_COLLAPSED_LABELS => [ false, Config::T_BOOL ],
|
||||
//Prefs::_COLLAPSED_UNCAT => [ false, Config::T_BOOL ],
|
||||
//Prefs::_COLLAPSED_FEEDLIST => [ false, Config::T_BOOL ],
|
||||
//Prefs::_MOBILE_ENABLE_CATS => [ false, Config::T_BOOL ],
|
||||
//Prefs::_MOBILE_SHOW_IMAGES => [ false, Config::T_BOOL ],
|
||||
//Prefs::_MOBILE_HIDE_READ => [ false, Config::T_BOOL ],
|
||||
//Prefs::_MOBILE_SORT_FEEDS_UNREAD => [ false, Config::T_BOOL ],
|
||||
//Prefs::_MOBILE_BROWSE_CATS => [ true, Config::T_BOOL ],
|
||||
//Prefs::_THEME_ID => [ 0, Config::T_BOOL ],
|
||||
Prefs::USER_TIMEZONE => [ "Automatic", Config::T_STRING ],
|
||||
Prefs::USER_STYLESHEET => [ "", Config::T_STRING ],
|
||||
//Prefs::SORT_HEADLINES_BY_FEED_DATE => [ false, Config::T_BOOL ],
|
||||
Prefs::SSL_CERT_SERIAL => [ "", Config::T_STRING ],
|
||||
Prefs::DIGEST_PREFERRED_TIME => [ "00:00", Config::T_STRING ],
|
||||
//Prefs::_PREFS_SHOW_EMPTY_CATS => [ false, Config::T_BOOL ],
|
||||
Prefs::_DEFAULT_INCLUDE_CHILDREN => [ false, Config::T_BOOL ],
|
||||
//Prefs::AUTO_ASSIGN_LABELS => [ false, Config::T_BOOL ],
|
||||
Prefs::_ENABLED_PLUGINS => [ "", Config::T_STRING ],
|
||||
//Prefs::_MOBILE_REVERSE_HEADLINES => [ false, Config::T_BOOL ],
|
||||
Prefs::USER_CSS_THEME => [ "" , Config::T_STRING ],
|
||||
Prefs::USER_LANGUAGE => [ "" , Config::T_STRING ],
|
||||
Prefs::DEFAULT_SEARCH_LANGUAGE => [ "" , Config::T_STRING ],
|
||||
Prefs::_PREFS_MIGRATED => [ false, Config::T_BOOL ],
|
||||
Prefs::HEADLINES_NO_DISTINCT => [ false, Config::T_BOOL ],
|
||||
Prefs::DEBUG_HEADLINE_IDS => [ false, Config::T_BOOL ],
|
||||
Prefs::DISABLE_CONDITIONAL_COUNTERS => [ false, Config::T_BOOL ],
|
||||
Prefs::WIDESCREEN_MODE => [ false, Config::T_BOOL ],
|
||||
Prefs::CDM_ENABLE_GRID => [ false, Config::T_BOOL ],
|
||||
Prefs::DIGEST_MIN_SCORE => [ 0, Config::T_INT ],
|
||||
];
|
||||
|
||||
const _PROFILE_BLACKLIST = [
|
||||
//Prefs::ALLOW_DUPLICATE_POSTS,
|
||||
Prefs::PURGE_OLD_DAYS,
|
||||
Prefs::PURGE_UNREAD_ARTICLES,
|
||||
Prefs::DIGEST_ENABLE,
|
||||
Prefs::DIGEST_CATCHUP,
|
||||
Prefs::BLACKLISTED_TAGS,
|
||||
Prefs::ENABLE_API_ACCESS,
|
||||
//Prefs::UPDATE_POST_ON_CHECKSUM_CHANGE,
|
||||
Prefs::DEFAULT_UPDATE_INTERVAL,
|
||||
Prefs::USER_TIMEZONE,
|
||||
//Prefs::SORT_HEADLINES_BY_FEED_DATE,
|
||||
Prefs::SSL_CERT_SERIAL,
|
||||
Prefs::DIGEST_PREFERRED_TIME,
|
||||
Prefs::DIGEST_MIN_SCORE,
|
||||
Prefs::_PREFS_MIGRATED
|
||||
];
|
||||
|
||||
/** @var Prefs|null */
|
||||
private static $instance;
|
||||
|
||||
/** @var array<string, bool|int|string> */
|
||||
private $cache = [];
|
||||
|
||||
/** @var PDO */
|
||||
private $pdo;
|
||||
|
||||
public static function get_instance() : Prefs {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
static function is_valid(string $pref_name): bool {
|
||||
return isset(self::_DEFAULTS[$pref_name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|int|null|string
|
||||
*/
|
||||
static function get_default(string $pref_name) {
|
||||
if (self::is_valid($pref_name))
|
||||
return self::_DEFAULTS[$pref_name][0];
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
function __construct() {
|
||||
$this->pdo = Db::pdo();
|
||||
|
||||
if (!empty($_SESSION["uid"])) {
|
||||
$owner_uid = (int) $_SESSION["uid"];
|
||||
$profile_id = $_SESSION["profile"] ?? null;
|
||||
|
||||
$this->cache_all($owner_uid, $profile_id);
|
||||
$this->migrate($owner_uid, $profile_id);
|
||||
};
|
||||
}
|
||||
|
||||
private function __clone() {
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, bool|int|null|string>>
|
||||
*/
|
||||
static function get_all(int $owner_uid, ?int $profile_id = null) {
|
||||
return self::get_instance()->_get_all($owner_uid, $profile_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, bool|int|null|string>>
|
||||
*/
|
||||
private function _get_all(int $owner_uid, ?int $profile_id = null) {
|
||||
$rv = [];
|
||||
|
||||
$ref = new ReflectionClass(get_class($this));
|
||||
|
||||
foreach ($ref->getConstants() as $const => $cvalue) {
|
||||
if (isset($this::_DEFAULTS[$const])) {
|
||||
list ($def_val, $type_hint) = $this::_DEFAULTS[$const];
|
||||
|
||||
array_push($rv, [
|
||||
"pref_name" => $const,
|
||||
"value" => $this->_get($const, $owner_uid, $profile_id),
|
||||
"type_hint" => $type_hint,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
private function cache_all(int $owner_uid, ?int $profile_id): void {
|
||||
if (!$profile_id) $profile_id = null;
|
||||
|
||||
// fill cache with defaults
|
||||
$ref = new ReflectionClass(get_class($this));
|
||||
foreach ($ref->getConstants() as $const => $cvalue) {
|
||||
if (isset($this::_DEFAULTS[$const])) {
|
||||
list ($def_val, $type_hint) = $this::_DEFAULTS[$const];
|
||||
|
||||
$this->_set_cache($const, $def_val, $owner_uid, $profile_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (Config::get_schema_version() >= 141) {
|
||||
// fill in any overrides from the database
|
||||
$sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs2
|
||||
WHERE owner_uid = :uid AND
|
||||
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
|
||||
|
||||
$sth->execute(["uid" => $owner_uid, "profile" => $profile_id]);
|
||||
|
||||
while ($row = $sth->fetch()) {
|
||||
$this->_set_cache($row["pref_name"], $row["value"], $owner_uid, $profile_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|int|null|string
|
||||
*/
|
||||
static function get(string $pref_name, int $owner_uid, ?int $profile_id = null) {
|
||||
return self::get_instance()->_get($pref_name, $owner_uid, $profile_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|int|null|string
|
||||
*/
|
||||
private function _get(string $pref_name, int $owner_uid, ?int $profile_id) {
|
||||
if (isset(self::_DEFAULTS[$pref_name])) {
|
||||
if (!$profile_id || in_array($pref_name, self::_PROFILE_BLACKLIST)) $profile_id = null;
|
||||
|
||||
list ($def_val, $type_hint) = self::_DEFAULTS[$pref_name];
|
||||
|
||||
if ($this->_is_cached($pref_name, $owner_uid, $profile_id)) {
|
||||
$cached_value = $this->_get_cache($pref_name, $owner_uid, $profile_id);
|
||||
return Config::cast_to($cached_value, $type_hint);
|
||||
} else if (Config::get_schema_version() >= 141) {
|
||||
$sth = $this->pdo->prepare("SELECT value FROM ttrss_user_prefs2
|
||||
WHERE pref_name = :name AND owner_uid = :uid AND
|
||||
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
|
||||
|
||||
$sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name ]);
|
||||
|
||||
if ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
|
||||
$this->_set_cache($pref_name, $row["value"], $owner_uid, $profile_id);
|
||||
|
||||
return Config::cast_to($row["value"], $type_hint);
|
||||
} else {
|
||||
$this->_set_cache($pref_name, $def_val, $owner_uid, $profile_id);
|
||||
|
||||
return $def_val;
|
||||
}
|
||||
} else {
|
||||
return Config::cast_to($def_val, $type_hint);
|
||||
|
||||
}
|
||||
} else {
|
||||
user_error("Attempt to get invalid preference key: $pref_name (UID: $owner_uid, profile: $profile_id)", E_USER_WARNING);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function _is_cached(string $pref_name, int $owner_uid, ?int $profile_id): bool {
|
||||
$cache_key = sprintf("%d/%d/%s", $owner_uid, $profile_id, $pref_name);
|
||||
return isset($this->cache[$cache_key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool|int|null|string
|
||||
*/
|
||||
private function _get_cache(string $pref_name, int $owner_uid, ?int $profile_id) {
|
||||
$cache_key = sprintf("%d/%d/%s", $owner_uid, $profile_id, $pref_name);
|
||||
return $this->cache[$cache_key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool|int|string $value
|
||||
*/
|
||||
private function _set_cache(string $pref_name, $value, int $owner_uid, ?int $profile_id): void {
|
||||
$cache_key = sprintf("%d/%d/%s", $owner_uid, $profile_id, $pref_name);
|
||||
|
||||
$this->cache[$cache_key] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool|int|string $value
|
||||
*/
|
||||
static function set(string $pref_name, $value, int $owner_uid, ?int $profile_id, bool $strip_tags = true): bool {
|
||||
return self::get_instance()->_set($pref_name, $value, $owner_uid, $profile_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool|int|string $value
|
||||
*/
|
||||
private function _set(string $pref_name, $value, int $owner_uid, ?int $profile_id, bool $strip_tags = true): bool {
|
||||
if (!$profile_id) $profile_id = null;
|
||||
|
||||
if ($profile_id && in_array($pref_name, self::_PROFILE_BLACKLIST))
|
||||
return false;
|
||||
|
||||
if (isset(self::_DEFAULTS[$pref_name])) {
|
||||
list ($def_val, $type_hint) = self::_DEFAULTS[$pref_name];
|
||||
|
||||
if ($strip_tags)
|
||||
$value = trim(strip_tags($value));
|
||||
|
||||
$value = Config::cast_to($value, $type_hint);
|
||||
|
||||
if ($value == $this->_get($pref_name, $owner_uid, $profile_id))
|
||||
return true; // no need to actually set this to the same value, let's just say we did
|
||||
|
||||
$this->_set_cache($pref_name, $value, $owner_uid, $profile_id);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT COUNT(pref_name) AS count FROM ttrss_user_prefs2
|
||||
WHERE pref_name = :name AND owner_uid = :uid AND
|
||||
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
|
||||
$sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name ]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
if ($row["count"] == 0) {
|
||||
$sth = $this->pdo->prepare("INSERT INTO ttrss_user_prefs2
|
||||
(pref_name, value, owner_uid, profile)
|
||||
VALUES
|
||||
(:name, :value, :uid, :profile)");
|
||||
|
||||
return $sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name, "value" => $value ]);
|
||||
|
||||
} else {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_prefs2
|
||||
SET value = :value
|
||||
WHERE pref_name = :name AND owner_uid = :uid AND
|
||||
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
|
||||
|
||||
return $sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name, "value" => $value ]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
user_error("Attempt to set invalid preference key: $pref_name (UID: $owner_uid, profile: $profile_id)", E_USER_WARNING);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function migrate(int $owner_uid, ?int $profile_id): void {
|
||||
if (Config::get_schema_version() < 141)
|
||||
return;
|
||||
|
||||
if (!$profile_id) $profile_id = null;
|
||||
|
||||
if (!$this->_get(Prefs::_PREFS_MIGRATED, $owner_uid, $profile_id)) {
|
||||
|
||||
$in_nested_tr = false;
|
||||
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
} catch (PDOException $e) {
|
||||
$in_nested_tr = true;
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs
|
||||
WHERE owner_uid = :uid AND
|
||||
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
|
||||
$sth->execute(["uid" => $owner_uid, "profile" => $profile_id]);
|
||||
|
||||
while ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
|
||||
if (isset(self::_DEFAULTS[$row["pref_name"]])) {
|
||||
list ($def_val, $type_hint) = self::_DEFAULTS[$row["pref_name"]];
|
||||
|
||||
$user_val = Config::cast_to($row["value"], $type_hint);
|
||||
|
||||
if ($user_val != $def_val) {
|
||||
$this->_set($row["pref_name"], $user_val, $owner_uid, $profile_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->_set(Prefs::_PREFS_MIGRATED, "1", $owner_uid, $profile_id);
|
||||
|
||||
if (!$in_nested_tr)
|
||||
$this->pdo->commit();
|
||||
|
||||
Logger::log(E_USER_NOTICE, sprintf("Migrated preferences of user %d (profile %d)", $owner_uid, $profile_id));
|
||||
}
|
||||
}
|
||||
|
||||
static function reset(int $owner_uid, ?int $profile_id): void {
|
||||
if (!$profile_id) $profile_id = null;
|
||||
|
||||
$sth = Db::pdo()->prepare("DELETE FROM ttrss_user_prefs2
|
||||
WHERE owner_uid = :uid AND pref_name != :mig_key AND
|
||||
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
|
||||
|
||||
$sth->execute(["uid" => $owner_uid, "mig_key" => self::_PREFS_MIGRATED, "profile" => $profile_id]);
|
||||
}
|
||||
}
|
||||
846
classes/RPC.php
Normal file
846
classes/RPC.php
Normal file
@@ -0,0 +1,846 @@
|
||||
<?php
|
||||
class RPC extends Handler_Protected {
|
||||
|
||||
/*function csrf_ignore(string $method): bool {
|
||||
$csrf_ignored = array("completelabels");
|
||||
|
||||
return array_search($method, $csrf_ignored) !== false;
|
||||
}*/
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function _translations_as_array(): array {
|
||||
|
||||
global $text_domains;
|
||||
|
||||
$rv = [];
|
||||
|
||||
foreach (array_keys($text_domains) as $domain) {
|
||||
|
||||
/** @var gettext_reader $l10n */
|
||||
$l10n = _get_reader($domain);
|
||||
|
||||
for ($i = 0; $i < $l10n->total; $i++) {
|
||||
if (isset($l10n->table_originals[$i * 2 + 2]) && $orig = $l10n->get_original_string($i)) {
|
||||
if(strpos($orig, "\000") !== false) { // Plural forms
|
||||
$key = explode(chr(0), $orig);
|
||||
|
||||
$rv[$key[0]] = _ngettext($key[0], $key[1], 1); // Singular
|
||||
$rv[$key[1]] = _ngettext($key[0], $key[1], 2); // Plural
|
||||
} else {
|
||||
$translation = _dgettext($domain,$orig);
|
||||
$rv[$orig] = $translation;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
|
||||
function togglepref(): void {
|
||||
$key = clean($_REQUEST["key"]);
|
||||
set_pref($key, !get_pref($key));
|
||||
$value = get_pref($key);
|
||||
|
||||
print json_encode(array("param" =>$key, "value" => $value));
|
||||
}
|
||||
|
||||
function setpref(): void {
|
||||
// set_pref escapes input, so no need to double escape it here
|
||||
$key = clean($_REQUEST['key']);
|
||||
$value = $_REQUEST['value'];
|
||||
|
||||
set_pref($key, $value, $_SESSION["uid"], $key != 'USER_STYLESHEET');
|
||||
|
||||
print json_encode(array("param" =>$key, "value" => $value));
|
||||
}
|
||||
|
||||
function mark(): void {
|
||||
$mark = clean($_REQUEST["mark"]);
|
||||
$id = clean($_REQUEST["id"]);
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET marked = ?,
|
||||
last_marked = NOW()
|
||||
WHERE ref_id = ? AND owner_uid = ?");
|
||||
|
||||
$sth->execute([$mark, $id, $_SESSION['uid']]);
|
||||
|
||||
print json_encode(array("message" => "UPDATE_COUNTERS"));
|
||||
}
|
||||
|
||||
function delete(): void {
|
||||
$ids = explode(",", clean($_REQUEST["ids"]));
|
||||
$ids_qmarks = arr_qmarks($ids);
|
||||
|
||||
$sth = $this->pdo->prepare("DELETE FROM ttrss_user_entries
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
$sth->execute([...$ids, $_SESSION['uid']]);
|
||||
|
||||
Article::_purge_orphans();
|
||||
|
||||
print json_encode(array("message" => "UPDATE_COUNTERS"));
|
||||
}
|
||||
|
||||
function publ(): void {
|
||||
$pub = clean($_REQUEST["pub"]);
|
||||
$id = clean($_REQUEST["id"]);
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
published = ?, last_published = NOW()
|
||||
WHERE ref_id = ? AND owner_uid = ?");
|
||||
|
||||
$sth->execute([$pub, $id, $_SESSION['uid']]);
|
||||
|
||||
print json_encode(array("message" => "UPDATE_COUNTERS"));
|
||||
}
|
||||
|
||||
function getRuntimeInfo(): void {
|
||||
$reply = [
|
||||
'runtime-info' => $this->_make_runtime_info()
|
||||
];
|
||||
|
||||
print json_encode($reply);
|
||||
}
|
||||
|
||||
function getAllCounters(): void {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
|
||||
@$seq = (int) $_REQUEST['seq'];
|
||||
|
||||
$feed_id_count = (int) ($_REQUEST["feed_id_count"] ?? -1);
|
||||
$label_id_count = (int) ($_REQUEST["label_id_count"] ?? -1);
|
||||
|
||||
// it seems impossible to distinguish empty array [] from a null - both become unset in $_REQUEST
|
||||
// so, count is >= 0 means we had an array, -1 means null
|
||||
// we need null because it means "return all counters"; [] would return nothing
|
||||
if ($feed_id_count == -1)
|
||||
$feed_ids = null;
|
||||
else
|
||||
$feed_ids = array_map("intval", clean($_REQUEST["feed_ids"] ?? []));
|
||||
|
||||
if ($label_id_count == -1)
|
||||
$label_ids = null;
|
||||
else
|
||||
$label_ids = array_map("intval", clean($_REQUEST["label_ids"] ?? []));
|
||||
|
||||
$counters = is_array($feed_ids) && !get_pref(Prefs::DISABLE_CONDITIONAL_COUNTERS) ?
|
||||
Counters::get_conditional($feed_ids, $label_ids) : Counters::get_all();
|
||||
|
||||
$reply = [
|
||||
'counters' => $counters,
|
||||
'seq' => $seq
|
||||
];
|
||||
|
||||
$span->end();
|
||||
print json_encode($reply);
|
||||
}
|
||||
|
||||
/* GET["cmode"] = 0 - mark as read, 1 - as unread, 2 - toggle */
|
||||
function catchupSelected(): void {
|
||||
$ids = array_map("intval", clean($_REQUEST["ids"] ?? []));
|
||||
$cmode = (int)clean($_REQUEST["cmode"]);
|
||||
|
||||
if (count($ids) > 0)
|
||||
Article::_catchup_by_id($ids, $cmode);
|
||||
|
||||
print json_encode(["message" => "UPDATE_COUNTERS",
|
||||
"labels" => Article::_labels_of($ids),
|
||||
"feeds" => Article::_feeds_of($ids)]);
|
||||
}
|
||||
|
||||
function markSelected(): void {
|
||||
$ids = array_map("intval", clean($_REQUEST["ids"] ?? []));
|
||||
$cmode = (int)clean($_REQUEST["cmode"]);
|
||||
|
||||
if (count($ids) > 0)
|
||||
$this->markArticlesById($ids, $cmode);
|
||||
|
||||
print json_encode(["message" => "UPDATE_COUNTERS",
|
||||
"labels" => Article::_labels_of($ids),
|
||||
"feeds" => Article::_feeds_of($ids)]);
|
||||
}
|
||||
|
||||
function publishSelected(): void {
|
||||
$ids = array_map("intval", clean($_REQUEST["ids"] ?? []));
|
||||
$cmode = (int)clean($_REQUEST["cmode"]);
|
||||
|
||||
if (count($ids) > 0)
|
||||
$this->publishArticlesById($ids, $cmode);
|
||||
|
||||
print json_encode(["message" => "UPDATE_COUNTERS",
|
||||
"labels" => Article::_labels_of($ids),
|
||||
"feeds" => Article::_feeds_of($ids)]);
|
||||
}
|
||||
|
||||
function sanityCheck(): void {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
|
||||
$_SESSION["hasSandbox"] = self::_param_to_bool($_REQUEST["hasSandbox"] ?? false);
|
||||
$_SESSION["clientTzOffset"] = clean($_REQUEST["clientTzOffset"]);
|
||||
|
||||
$client_location = $_REQUEST["clientLocation"];
|
||||
|
||||
$error = Errors::E_SUCCESS;
|
||||
$error_params = [];
|
||||
|
||||
$client_scheme = parse_url($client_location, PHP_URL_SCHEME);
|
||||
$server_scheme = parse_url(Config::get_self_url(), PHP_URL_SCHEME);
|
||||
|
||||
if (Config::is_migration_needed()) {
|
||||
$error = Errors::E_SCHEMA_MISMATCH;
|
||||
} else if ($client_scheme != $server_scheme) {
|
||||
$error = Errors::E_URL_SCHEME_MISMATCH;
|
||||
$error_params["client_scheme"] = $client_scheme;
|
||||
$error_params["server_scheme"] = $server_scheme;
|
||||
$error_params["self_url_path"] = Config::get_self_url();
|
||||
}
|
||||
|
||||
if ($error == Errors::E_SUCCESS) {
|
||||
$reply = [];
|
||||
|
||||
$reply['init-params'] = $this->_make_init_params();
|
||||
$reply['runtime-info'] = $this->_make_runtime_info();
|
||||
$reply['translations'] = $this->_translations_as_array();
|
||||
|
||||
print json_encode($reply);
|
||||
} else {
|
||||
print Errors::to_json($error, $error_params);
|
||||
}
|
||||
|
||||
$span->end();
|
||||
}
|
||||
|
||||
/*function completeLabels() {
|
||||
$search = clean($_REQUEST["search"]);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT DISTINCT caption FROM
|
||||
ttrss_labels2
|
||||
WHERE owner_uid = ? AND
|
||||
LOWER(caption) LIKE LOWER(?) ORDER BY caption
|
||||
LIMIT 5");
|
||||
$sth->execute([$_SESSION['uid'], "%$search%"]);
|
||||
|
||||
print "<ul>";
|
||||
while ($line = $sth->fetch()) {
|
||||
print "<li>" . $line["caption"] . "</li>";
|
||||
}
|
||||
print "</ul>";
|
||||
}*/
|
||||
|
||||
function catchupFeed(): void {
|
||||
$feed_id = clean($_REQUEST['feed_id']);
|
||||
$is_cat = self::_param_to_bool($_REQUEST['is_cat'] ?? false);
|
||||
$mode = clean($_REQUEST['mode'] ?? '');
|
||||
$search_query = clean($_REQUEST['search_query']);
|
||||
$search_lang = clean($_REQUEST['search_lang']);
|
||||
|
||||
Feeds::_catchup($feed_id, $is_cat, null, $mode, [$search_query, $search_lang]);
|
||||
|
||||
// return counters here synchronously so that frontend can figure out next unread feed properly
|
||||
print json_encode(['counters' => Counters::get_all()]);
|
||||
|
||||
//print json_encode(array("message" => "UPDATE_COUNTERS"));
|
||||
}
|
||||
|
||||
function setWidescreen(): void {
|
||||
$wide = (int) clean($_REQUEST["wide"]);
|
||||
|
||||
set_pref(Prefs::WIDESCREEN_MODE, $wide);
|
||||
|
||||
print json_encode(["wide" => $wide]);
|
||||
}
|
||||
|
||||
static function updaterandomfeed_real(): void {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
|
||||
$default_interval = (int) Prefs::get_default(Prefs::DEFAULT_UPDATE_INTERVAL);
|
||||
|
||||
// Test if the feed need a update (update interval exceded).
|
||||
if (Config::get(Config::DB_TYPE) == "pgsql") {
|
||||
$update_limit_qpart = "AND ((
|
||||
update_interval = 0
|
||||
AND (p.value IS NULL OR p.value != '-1')
|
||||
AND last_updated < NOW() - CAST((COALESCE(p.value, '$default_interval') || ' minutes') AS INTERVAL)
|
||||
) OR (
|
||||
update_interval > 0
|
||||
AND last_updated < NOW() - CAST((update_interval || ' minutes') AS INTERVAL)
|
||||
) OR (
|
||||
update_interval >= 0
|
||||
AND (p.value IS NULL OR p.value != '-1')
|
||||
AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL)
|
||||
))";
|
||||
} else {
|
||||
$update_limit_qpart = "AND ((
|
||||
update_interval = 0
|
||||
AND (p.value IS NULL OR p.value != '-1')
|
||||
AND last_updated < DATE_SUB(NOW(), INTERVAL CONVERT(COALESCE(p.value, '$default_interval'), SIGNED INTEGER) MINUTE)
|
||||
) OR (
|
||||
update_interval > 0
|
||||
AND last_updated < DATE_SUB(NOW(), INTERVAL update_interval MINUTE)
|
||||
) OR (
|
||||
update_interval >= 0
|
||||
AND (p.value IS NULL OR p.value != '-1')
|
||||
AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL)
|
||||
))";
|
||||
}
|
||||
|
||||
// Test if feed is currently being updated by another process.
|
||||
if (Config::get(Config::DB_TYPE) == "pgsql") {
|
||||
$updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < NOW() - INTERVAL '5 minutes')";
|
||||
} else {
|
||||
$updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < DATE_SUB(NOW(), INTERVAL 5 MINUTE))";
|
||||
}
|
||||
|
||||
$random_qpart = Db::sql_random_function();
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
// we could be invoked from public.php with no active session
|
||||
if (!empty($_SESSION["uid"])) {
|
||||
$owner_check_qpart = "AND f.owner_uid = ".$pdo->quote($_SESSION["uid"]);
|
||||
} else {
|
||||
$owner_check_qpart = "";
|
||||
}
|
||||
|
||||
$query = "SELECT f.feed_url,f.id
|
||||
FROM
|
||||
ttrss_feeds f, ttrss_users u LEFT JOIN ttrss_user_prefs2 p ON
|
||||
(p.owner_uid = u.id AND profile IS NULL AND pref_name = 'DEFAULT_UPDATE_INTERVAL')
|
||||
WHERE
|
||||
f.owner_uid = u.id AND
|
||||
u.access_level NOT IN (".sprintf("%d, %d", UserHelper::ACCESS_LEVEL_DISABLED, UserHelper::ACCESS_LEVEL_READONLY).")
|
||||
$owner_check_qpart
|
||||
$update_limit_qpart
|
||||
$updstart_thresh_qpart
|
||||
ORDER BY $random_qpart LIMIT 30";
|
||||
|
||||
$res = $pdo->query($query);
|
||||
|
||||
$num_updated = 0;
|
||||
|
||||
$tstart = time();
|
||||
|
||||
while ($line = $res->fetch()) {
|
||||
$feed_id = $line["id"];
|
||||
|
||||
if (time() - $tstart < ini_get("max_execution_time") * 0.7) {
|
||||
RSSUtils::update_rss_feed($feed_id, true);
|
||||
++$num_updated;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Purge orphans and cleanup tags
|
||||
Article::_purge_orphans();
|
||||
//cleanup_tags(14, 50000);
|
||||
|
||||
if ($num_updated > 0) {
|
||||
print json_encode(array("message" => "UPDATE_COUNTERS",
|
||||
"num_updated" => $num_updated));
|
||||
} else {
|
||||
print json_encode(array("message" => "NOTHING_TO_UPDATE"));
|
||||
}
|
||||
|
||||
$span->end();
|
||||
}
|
||||
|
||||
function updaterandomfeed(): void {
|
||||
self::updaterandomfeed_real();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $ids
|
||||
*/
|
||||
private function markArticlesById(array $ids, int $cmode): void {
|
||||
|
||||
$ids_qmarks = arr_qmarks($ids);
|
||||
|
||||
if ($cmode == Article::CATCHUP_MODE_MARK_AS_READ) {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
marked = false, last_marked = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
} else if ($cmode == Article::CATCHUP_MODE_MARK_AS_UNREAD) {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
marked = true, last_marked = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
} else {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
marked = NOT marked,last_marked = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
}
|
||||
|
||||
$sth->execute([...$ids, $_SESSION['uid']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $ids
|
||||
*/
|
||||
private function publishArticlesById(array $ids, int $cmode): void {
|
||||
|
||||
$ids_qmarks = arr_qmarks($ids);
|
||||
|
||||
if ($cmode == Article::CATCHUP_MODE_MARK_AS_READ) {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
published = false, last_published = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
} else if ($cmode == Article::CATCHUP_MODE_MARK_AS_UNREAD) {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
published = true, last_published = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
} else {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
published = NOT published,last_published = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
}
|
||||
|
||||
$sth->execute([...$ids, $_SESSION['uid']]);
|
||||
}
|
||||
|
||||
function log(): void {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
|
||||
$msg = clean($_REQUEST['msg'] ?? "");
|
||||
$file = basename(clean($_REQUEST['file'] ?? ""));
|
||||
$line = (int) clean($_REQUEST['line'] ?? 0);
|
||||
$context = clean($_REQUEST['context'] ?? "");
|
||||
|
||||
if ($msg) {
|
||||
Logger::log_error(E_USER_WARNING,
|
||||
$msg, 'client-js:' . $file, $line, $context);
|
||||
|
||||
echo json_encode(array("message" => "HOST_ERROR_LOGGED"));
|
||||
}
|
||||
|
||||
$span->end();
|
||||
}
|
||||
|
||||
function checkforupdates(): void {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
|
||||
$rv = ["changeset" => [], "plugins" => []];
|
||||
|
||||
$version = Config::get_version(false);
|
||||
|
||||
$git_timestamp = $version["timestamp"] ?? false;
|
||||
$git_commit = $version["commit"] ?? false;
|
||||
|
||||
if (Config::get(Config::CHECK_FOR_UPDATES) && $_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN && $git_timestamp) {
|
||||
$content = @UrlHelper::fetch(["url" => "https://tt-rss.org/version.json"]);
|
||||
|
||||
if ($content) {
|
||||
$content = json_decode($content, true);
|
||||
|
||||
if ($content && isset($content["changeset"])) {
|
||||
if ($git_timestamp < (int)$content["changeset"]["timestamp"] &&
|
||||
$git_commit != $content["changeset"]["id"]) {
|
||||
|
||||
$rv["changeset"] = $content["changeset"];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$rv["plugins"] = Pref_Prefs::_get_updated_plugins();
|
||||
}
|
||||
|
||||
$span->end();
|
||||
|
||||
print json_encode($rv);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function _make_init_params(): array {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
|
||||
$params = array();
|
||||
|
||||
foreach ([Prefs::ON_CATCHUP_SHOW_NEXT_FEED, Prefs::HIDE_READ_FEEDS,
|
||||
Prefs::ENABLE_FEED_CATS, Prefs::FEEDS_SORT_BY_UNREAD,
|
||||
Prefs::CONFIRM_FEED_CATCHUP, Prefs::CDM_AUTO_CATCHUP,
|
||||
Prefs::FRESH_ARTICLE_MAX_AGE, Prefs::HIDE_READ_SHOWS_SPECIAL,
|
||||
Prefs::COMBINED_DISPLAY_MODE, Prefs::DEBUG_HEADLINE_IDS, Prefs::CDM_ENABLE_GRID] as $param) {
|
||||
|
||||
$params[strtolower($param)] = (int) get_pref($param);
|
||||
}
|
||||
|
||||
$params["safe_mode"] = !empty($_SESSION["safe_mode"]);
|
||||
$params["check_for_updates"] = Config::get(Config::CHECK_FOR_UPDATES);
|
||||
$params["icons_url"] = Config::get_self_url() . '/public.php';
|
||||
$params["cookie_lifetime"] = Config::get(Config::SESSION_COOKIE_LIFETIME);
|
||||
$params["default_view_mode"] = get_pref(Prefs::_DEFAULT_VIEW_MODE);
|
||||
$params["default_view_limit"] = (int) get_pref(Prefs::_DEFAULT_VIEW_LIMIT);
|
||||
$params["default_view_order_by"] = get_pref(Prefs::_DEFAULT_VIEW_ORDER_BY);
|
||||
$params["bw_limit"] = (int) ($_SESSION["bw_limit"] ?? false);
|
||||
$params["is_default_pw"] = UserHelper::is_default_password();
|
||||
$params["label_base_index"] = LABEL_BASE_INDEX;
|
||||
|
||||
$theme = get_pref(Prefs::USER_CSS_THEME);
|
||||
$params["theme"] = theme_exists($theme) ? $theme : "";
|
||||
|
||||
$params["plugins"] = implode(", ", PluginHost::getInstance()->get_plugin_names());
|
||||
|
||||
$params["php_platform"] = PHP_OS;
|
||||
$params["php_version"] = PHP_VERSION;
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT MAX(id) AS mid, COUNT(*) AS nf FROM
|
||||
ttrss_feeds WHERE owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
$row = $sth->fetch();
|
||||
|
||||
$max_feed_id = $row["mid"];
|
||||
$num_feeds = $row["nf"];
|
||||
|
||||
$params["self_url_prefix"] = Config::get_self_url();
|
||||
$params["max_feed_id"] = (int) $max_feed_id;
|
||||
$params["num_feeds"] = (int) $num_feeds;
|
||||
$params["hotkeys"] = $this->get_hotkeys_map();
|
||||
$params["widescreen"] = (int) get_pref(Prefs::WIDESCREEN_MODE);
|
||||
$params['simple_update'] = Config::get(Config::SIMPLE_UPDATE_MODE);
|
||||
$params["icon_indicator_white"] = $this->image_to_base64("images/indicator_white.gif");
|
||||
$params["icon_oval"] = $this->image_to_base64("images/oval.svg");
|
||||
$params["icon_three_dots"] = $this->image_to_base64("images/three-dots.svg");
|
||||
$params["icon_blank"] = $this->image_to_base64("images/blank_icon.gif");
|
||||
$params["labels"] = Labels::get_all($_SESSION["uid"]);
|
||||
|
||||
$span->end();
|
||||
|
||||
return $params;
|
||||
}
|
||||
|
||||
private function image_to_base64(string $filename): string {
|
||||
if (file_exists($filename)) {
|
||||
$ext = pathinfo($filename, PATHINFO_EXTENSION);
|
||||
|
||||
if ($ext == "svg") $ext = "svg+xml";
|
||||
|
||||
return "data:image/$ext;base64," . base64_encode((string)file_get_contents($filename));
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
static function _make_runtime_info(): array {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
|
||||
$data = array();
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT MAX(id) AS mid, COUNT(*) AS nf FROM
|
||||
ttrss_feeds WHERE owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
$row = $sth->fetch();
|
||||
|
||||
$max_feed_id = $row['mid'];
|
||||
$num_feeds = $row['nf'];
|
||||
|
||||
$data["max_feed_id"] = (int) $max_feed_id;
|
||||
$data["num_feeds"] = (int) $num_feeds;
|
||||
$data['cdm_expanded'] = get_pref(Prefs::CDM_EXPANDED);
|
||||
$data["labels"] = Labels::get_all($_SESSION["uid"]);
|
||||
|
||||
if (Config::get(Config::LOG_DESTINATION) == 'sql' && $_SESSION['access_level'] >= UserHelper::ACCESS_LEVEL_ADMIN) {
|
||||
if (Config::get(Config::DB_TYPE) == 'pgsql') {
|
||||
$log_interval = "created_at > NOW() - interval '1 hour'";
|
||||
} else {
|
||||
$log_interval = "created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)";
|
||||
}
|
||||
|
||||
$sth = $pdo->prepare("SELECT COUNT(id) AS cid
|
||||
FROM ttrss_error_log
|
||||
WHERE
|
||||
errno NOT IN (".E_USER_NOTICE.", ".E_USER_DEPRECATED.") AND
|
||||
$log_interval AND
|
||||
errstr NOT LIKE '%Returning bool from comparison function is deprecated%' AND
|
||||
errstr NOT LIKE '%imagecreatefromstring(): Data is not in a recognized format%'");
|
||||
$sth->execute();
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$data['recent_log_events'] = $row['cid'];
|
||||
}
|
||||
}
|
||||
|
||||
if (file_exists(Config::get(Config::LOCK_DIRECTORY) . "/update_daemon.lock")) {
|
||||
|
||||
$data['daemon_is_running'] = (int) file_is_locked("update_daemon.lock");
|
||||
|
||||
if (time() - ($_SESSION["daemon_stamp_check"] ?? 0) > 30) {
|
||||
|
||||
$stamp = (int) @file_get_contents(Config::get(Config::LOCK_DIRECTORY) . "/update_daemon.stamp");
|
||||
|
||||
if ($stamp) {
|
||||
$stamp_delta = time() - $stamp;
|
||||
|
||||
if ($stamp_delta > 1800) {
|
||||
$stamp_check = 0;
|
||||
} else {
|
||||
$stamp_check = 1;
|
||||
$_SESSION["daemon_stamp_check"] = time();
|
||||
}
|
||||
|
||||
$data['daemon_stamp_ok'] = $stamp_check;
|
||||
|
||||
$stamp_fmt = date("Y.m.d, G:i", $stamp);
|
||||
|
||||
$data['daemon_stamp'] = $stamp_fmt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$span->end();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, string>>
|
||||
*/
|
||||
static function get_hotkeys_info(): array {
|
||||
$hotkeys = array(
|
||||
__("Navigation") => array(
|
||||
"next_feed" => __("Open next feed"),
|
||||
"next_unread_feed" => __("Open next unread feed"),
|
||||
"prev_feed" => __("Open previous feed"),
|
||||
"prev_unread_feed" => __("Open previous unread feed"),
|
||||
"next_article_or_scroll" => __("Open next article (in combined mode, scroll down)"),
|
||||
"prev_article_or_scroll" => __("Open previous article (in combined mode, scroll up)"),
|
||||
"next_headlines_page" => __("Scroll headlines by one page down"),
|
||||
"prev_headlines_page" => __("Scroll headlines by one page up"),
|
||||
"next_article_noscroll" => __("Open next article"),
|
||||
"prev_article_noscroll" => __("Open previous article"),
|
||||
"next_article_noexpand" => __("Move to next article (don't expand)"),
|
||||
"prev_article_noexpand" => __("Move to previous article (don't expand)"),
|
||||
"search_dialog" => __("Show search dialog"),
|
||||
"cancel_search" => __("Cancel active search")),
|
||||
__("Article") => array(
|
||||
"toggle_mark" => __("Toggle starred"),
|
||||
"toggle_publ" => __("Toggle published"),
|
||||
"toggle_unread" => __("Toggle unread"),
|
||||
"edit_tags" => __("Edit tags"),
|
||||
"open_in_new_window" => __("Open in new window"),
|
||||
"catchup_below" => __("Mark below as read"),
|
||||
"catchup_above" => __("Mark above as read"),
|
||||
"article_scroll_down" => __("Scroll down"),
|
||||
"article_scroll_up" => __("Scroll up"),
|
||||
"article_page_down" => __("Scroll down page"),
|
||||
"article_page_up" => __("Scroll up page"),
|
||||
"select_article_cursor" => __("Select article under cursor"),
|
||||
"email_article" => __("Email article"),
|
||||
"close_article" => __("Close/collapse article"),
|
||||
"toggle_expand" => __("Toggle article expansion (combined mode)"),
|
||||
"toggle_widescreen" => __("Toggle widescreen mode"),
|
||||
"toggle_full_text" => __("Toggle full article text via Readability")),
|
||||
__("Article selection") => array(
|
||||
"select_all" => __("Select all articles"),
|
||||
"select_unread" => __("Select unread"),
|
||||
"select_marked" => __("Select starred"),
|
||||
"select_published" => __("Select published"),
|
||||
"select_invert" => __("Invert selection"),
|
||||
"select_none" => __("Deselect everything")),
|
||||
__("Feed") => array(
|
||||
"feed_refresh" => __("Refresh current feed"),
|
||||
"feed_unhide_read" => __("Un/hide read feeds"),
|
||||
"feed_subscribe" => __("Subscribe to feed"),
|
||||
"feed_edit" => __("Edit feed"),
|
||||
"feed_catchup" => __("Mark as read"),
|
||||
"feed_reverse" => __("Reverse headlines"),
|
||||
"feed_toggle_vgroup" => __("Toggle headline grouping"),
|
||||
"feed_toggle_grid" => __("Toggle grid view"),
|
||||
"feed_debug_update" => __("Debug feed update"),
|
||||
"feed_debug_viewfeed" => __("Debug viewfeed()"),
|
||||
"catchup_all" => __("Mark all feeds as read"),
|
||||
"cat_toggle_collapse" => __("Un/collapse current category"),
|
||||
"toggle_cdm_expanded" => __("Toggle auto expand in combined mode"),
|
||||
"toggle_combined_mode" => __("Toggle combined mode")),
|
||||
__("Go to") => array(
|
||||
"goto_all" => __("All articles"),
|
||||
"goto_fresh" => __("Fresh"),
|
||||
"goto_marked" => __("Starred"),
|
||||
"goto_published" => __("Published"),
|
||||
"goto_read" => __("Recently read"),
|
||||
"goto_prefs" => __("Preferences")),
|
||||
__("Other") => array(
|
||||
"create_label" => __("Create label"),
|
||||
"create_filter" => __("Create filter"),
|
||||
"collapse_sidebar" => __("Un/collapse sidebar"),
|
||||
"help_dialog" => __("Show help dialog"))
|
||||
);
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HOTKEY_INFO,
|
||||
function ($result) use (&$hotkeys) {
|
||||
$hotkeys = $result;
|
||||
},
|
||||
$hotkeys);
|
||||
|
||||
return $hotkeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* {3} - 3 panel mode only
|
||||
* {C} - combined mode only
|
||||
*
|
||||
* @return array{0: array<int, string>, 1: array<string, string>} $prefixes, $hotkeys
|
||||
*/
|
||||
static function get_hotkeys_map() {
|
||||
$hotkeys = array(
|
||||
"k" => "next_feed",
|
||||
"K" => "next_unread_feed",
|
||||
"j" => "prev_feed",
|
||||
"J" => "prev_unread_feed",
|
||||
"n" => "next_article_noscroll",
|
||||
"p" => "prev_article_noscroll",
|
||||
"N" => "article_page_down",
|
||||
"P" => "article_page_up",
|
||||
"*(33)|Shift+PgUp" => "article_page_up",
|
||||
"*(34)|Shift+PgDn" => "article_page_down",
|
||||
"{3}(38)|Up" => "prev_article_or_scroll",
|
||||
"{3}(40)|Down" => "next_article_or_scroll",
|
||||
"*(38)|Shift+Up" => "article_scroll_up",
|
||||
"*(40)|Shift+Down" => "article_scroll_down",
|
||||
"^(38)|Ctrl+Up" => "prev_article_noscroll",
|
||||
"^(40)|Ctrl+Down" => "next_article_noscroll",
|
||||
"/" => "search_dialog",
|
||||
"\\" => "cancel_search",
|
||||
"s" => "toggle_mark",
|
||||
"S" => "toggle_publ",
|
||||
"u" => "toggle_unread",
|
||||
"T" => "edit_tags",
|
||||
"o" => "open_in_new_window",
|
||||
"c p" => "catchup_below",
|
||||
"c n" => "catchup_above",
|
||||
"a W" => "toggle_widescreen",
|
||||
"a e" => "toggle_full_text",
|
||||
"e" => "email_article",
|
||||
"a q" => "close_article",
|
||||
"a s" => "article_span_grid",
|
||||
"a a" => "select_all",
|
||||
"a u" => "select_unread",
|
||||
"a U" => "select_marked",
|
||||
"a p" => "select_published",
|
||||
"a i" => "select_invert",
|
||||
"a n" => "select_none",
|
||||
"f r" => "feed_refresh",
|
||||
"f a" => "feed_unhide_read",
|
||||
"f s" => "feed_subscribe",
|
||||
"f e" => "feed_edit",
|
||||
"f q" => "feed_catchup",
|
||||
"f x" => "feed_reverse",
|
||||
"f g" => "feed_toggle_vgroup",
|
||||
"f G" => "feed_toggle_grid",
|
||||
"f D" => "feed_debug_update",
|
||||
"f %" => "feed_debug_viewfeed",
|
||||
"f C" => "toggle_combined_mode",
|
||||
"f c" => "toggle_cdm_expanded",
|
||||
"Q" => "catchup_all",
|
||||
"x" => "cat_toggle_collapse",
|
||||
"g a" => "goto_all",
|
||||
"g f" => "goto_fresh",
|
||||
"g s" => "goto_marked",
|
||||
"g p" => "goto_published",
|
||||
"g r" => "goto_read",
|
||||
"g P" => "goto_prefs",
|
||||
"r" => "select_article_cursor",
|
||||
"c l" => "create_label",
|
||||
"c f" => "create_filter",
|
||||
"c s" => "collapse_sidebar",
|
||||
"?" => "help_dialog",
|
||||
);
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HOTKEY_MAP,
|
||||
function ($result) use (&$hotkeys) {
|
||||
$hotkeys = $result;
|
||||
},
|
||||
$hotkeys);
|
||||
|
||||
$prefixes = array();
|
||||
|
||||
foreach (array_keys($hotkeys) as $hotkey) {
|
||||
$pair = explode(" ", (string)$hotkey, 2);
|
||||
|
||||
if (count($pair) > 1 && !in_array($pair[0], $prefixes)) {
|
||||
array_push($prefixes, $pair[0]);
|
||||
}
|
||||
}
|
||||
|
||||
return array($prefixes, $hotkeys);
|
||||
}
|
||||
|
||||
function hotkeyHelp(): void {
|
||||
$info = self::get_hotkeys_info();
|
||||
$imap = self::get_hotkeys_map();
|
||||
$omap = [];
|
||||
|
||||
foreach ($imap[1] as $sequence => $action) {
|
||||
$omap[$action] ??= [];
|
||||
$omap[$action][] = $sequence;
|
||||
}
|
||||
|
||||
?>
|
||||
<ul class='panel panel-scrollable hotkeys-help' style='height : 300px'>
|
||||
<?php
|
||||
|
||||
foreach ($info as $section => $hotkeys) {
|
||||
?>
|
||||
<li><h3><?= $section ?></h3></li>
|
||||
<?php
|
||||
|
||||
foreach ($hotkeys as $action => $description) {
|
||||
|
||||
if (!empty($omap[$action])) {
|
||||
foreach ($omap[$action] as $sequence) {
|
||||
if (strpos($sequence, "|") !== false) {
|
||||
$sequence = substr($sequence,
|
||||
strpos($sequence, "|")+1,
|
||||
strlen($sequence));
|
||||
} else {
|
||||
$keys = explode(" ", $sequence);
|
||||
|
||||
for ($i = 0; $i < count($keys); $i++) {
|
||||
if (strlen($keys[$i]) > 1) {
|
||||
$tmp = '';
|
||||
foreach (str_split($keys[$i]) as $c) {
|
||||
switch ($c) {
|
||||
case '*':
|
||||
$tmp .= __('Shift') . '+';
|
||||
break;
|
||||
case '^':
|
||||
$tmp .= __('Ctrl') . '+';
|
||||
break;
|
||||
default:
|
||||
$tmp .= $c;
|
||||
}
|
||||
}
|
||||
$keys[$i] = $tmp;
|
||||
}
|
||||
}
|
||||
$sequence = join(" ", $keys);
|
||||
}
|
||||
|
||||
?>
|
||||
<li>
|
||||
<div class='hk'><code><?= $sequence ?></code></div>
|
||||
<div class='desc'><?= $description ?></div>
|
||||
</li>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
</ul>
|
||||
<footer class='text-center'>
|
||||
<?= \Controls\submit_tag(__('Close this window')) ?>
|
||||
</footer>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
2091
classes/RSSUtils.php
Normal file
2091
classes/RSSUtils.php
Normal file
File diff suppressed because it is too large
Load Diff
238
classes/Sanitizer.php
Normal file
238
classes/Sanitizer.php
Normal file
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
class Sanitizer {
|
||||
/**
|
||||
* @param array<int, string> $allowed_elements
|
||||
* @param array<int, string> $disallowed_attributes
|
||||
*/
|
||||
private static function strip_harmful_tags(DOMDocument $doc, array $allowed_elements, $disallowed_attributes): DOMDocument {
|
||||
$xpath = new DOMXPath($doc);
|
||||
$entries = $xpath->query('//*');
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if (!in_array($entry->nodeName, $allowed_elements)) {
|
||||
$entry->parentNode->removeChild($entry);
|
||||
}
|
||||
|
||||
if ($entry->hasAttributes()) {
|
||||
$attrs_to_remove = array();
|
||||
|
||||
foreach ($entry->attributes as $attr) {
|
||||
|
||||
if (strpos($attr->nodeName, 'on') === 0) {
|
||||
array_push($attrs_to_remove, $attr);
|
||||
}
|
||||
|
||||
if (strpos($attr->nodeName, "data-") === 0) {
|
||||
array_push($attrs_to_remove, $attr);
|
||||
}
|
||||
|
||||
if ($attr->nodeName == 'href' && stripos($attr->value, 'javascript:') === 0) {
|
||||
array_push($attrs_to_remove, $attr);
|
||||
}
|
||||
|
||||
if (in_array($attr->nodeName, $disallowed_attributes)) {
|
||||
array_push($attrs_to_remove, $attr);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($attrs_to_remove as $attr) {
|
||||
$entry->removeAttributeNode($attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $doc;
|
||||
}
|
||||
|
||||
public static function iframe_whitelisted(DOMElement $entry): bool {
|
||||
$src = parse_url($entry->getAttribute("src"), PHP_URL_HOST);
|
||||
|
||||
if (!empty($src))
|
||||
return PluginHost::getInstance()->run_hooks_until(PluginHost::HOOK_IFRAME_WHITELISTED, true, $src);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function is_prefix_https(): bool {
|
||||
return parse_url(Config::get_self_url(), PHP_URL_SCHEME) == 'https';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string>|null $highlight_words Words to highlight in the HTML output.
|
||||
*
|
||||
* @return false|string The HTML, or false if an error occurred.
|
||||
*/
|
||||
public static function sanitize(string $str, ?bool $force_remove_images = false, ?int $owner = null, ?string $site_url = null, ?array $highlight_words = null, ?int $article_id = null) {
|
||||
$span = OpenTelemetry\API\Trace\Span::getCurrent();
|
||||
$span->addEvent("Sanitizer::sanitize");
|
||||
|
||||
if (!$owner && isset($_SESSION["uid"]))
|
||||
$owner = $_SESSION["uid"];
|
||||
|
||||
$res = trim($str); if (!$res) return '';
|
||||
|
||||
$doc = new DOMDocument();
|
||||
$doc->loadHTML('<?xml encoding="UTF-8">' . $res);
|
||||
$xpath = new DOMXPath($doc);
|
||||
|
||||
// is it a good idea to possibly rewrite urls to our own prefix?
|
||||
// $rewrite_base_url = $site_url ? $site_url : Config::get_self_url();
|
||||
$rewrite_base_url = $site_url ? $site_url : "http://domain.invalid/";
|
||||
|
||||
$entries = $xpath->query('(//a[@href]|//img[@src]|//source[@srcset|@src]|//video[@poster])');
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
|
||||
if ($entry->hasAttribute('href')) {
|
||||
$entry->setAttribute('href',
|
||||
UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('href'), $entry->tagName, "href"));
|
||||
|
||||
$entry->setAttribute('rel', 'noopener noreferrer');
|
||||
$entry->setAttribute("target", "_blank");
|
||||
}
|
||||
|
||||
if ($entry->hasAttribute('src')) {
|
||||
$entry->setAttribute('src',
|
||||
UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('src'), $entry->tagName, "src"));
|
||||
}
|
||||
|
||||
if ($entry->nodeName == 'img') {
|
||||
$entry->setAttribute('referrerpolicy', 'no-referrer');
|
||||
$entry->setAttribute('loading', 'lazy');
|
||||
}
|
||||
|
||||
if ($entry->hasAttribute('srcset')) {
|
||||
$matches = RSSUtils::decode_srcset($entry->getAttribute('srcset'));
|
||||
|
||||
for ($i = 0; $i < count($matches); $i++) {
|
||||
$matches[$i]["url"] = UrlHelper::rewrite_relative($rewrite_base_url, $matches[$i]["url"]);
|
||||
}
|
||||
|
||||
$entry->setAttribute("srcset", RSSUtils::encode_srcset($matches));
|
||||
}
|
||||
|
||||
if ($entry->hasAttribute('poster')) {
|
||||
$entry->setAttribute('poster',
|
||||
UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('poster'), $entry->tagName, "poster"));
|
||||
}
|
||||
|
||||
if ($entry->hasAttribute('src') &&
|
||||
($owner && get_pref(Prefs::STRIP_IMAGES, $owner)) || $force_remove_images || ($_SESSION["bw_limit"] ?? false)) {
|
||||
|
||||
$p = $doc->createElement('p');
|
||||
|
||||
$a = $doc->createElement('a');
|
||||
$a->setAttribute('href', $entry->getAttribute('src'));
|
||||
|
||||
$a->appendChild(new DOMText($entry->getAttribute('src')));
|
||||
$a->setAttribute('target', '_blank');
|
||||
$a->setAttribute('rel', 'noopener noreferrer');
|
||||
|
||||
$p->appendChild($a);
|
||||
|
||||
if ($entry->nodeName == 'source') {
|
||||
|
||||
if ($entry->parentNode && $entry->parentNode->parentNode)
|
||||
$entry->parentNode->parentNode->replaceChild($p, $entry->parentNode);
|
||||
|
||||
} else if ($entry->nodeName == 'img') {
|
||||
if ($entry->parentNode)
|
||||
$entry->parentNode->replaceChild($p, $entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$entries = $xpath->query('//iframe');
|
||||
foreach ($entries as $entry) {
|
||||
if (!self::iframe_whitelisted($entry)) {
|
||||
$entry->setAttribute('sandbox', 'allow-scripts');
|
||||
} else {
|
||||
if (self::is_prefix_https()) {
|
||||
$entry->setAttribute("src",
|
||||
str_replace("http://", "https://",
|
||||
$entry->getAttribute("src")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$allowed_elements = array('a', 'abbr', 'address', 'acronym', 'audio', 'article', 'aside',
|
||||
'b', 'bdi', 'bdo', 'big', 'blockquote', 'body', 'br',
|
||||
'caption', 'cite', 'center', 'code', 'col', 'colgroup',
|
||||
'data', 'dd', 'del', 'details', 'description', 'dfn', 'div', 'dl', 'font',
|
||||
'dt', 'em', 'footer', 'figure', 'figcaption',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'html', 'i',
|
||||
'img', 'ins', 'kbd', 'li', 'main', 'mark', 'nav', 'noscript',
|
||||
'ol', 'p', 'picture', 'pre', 'q', 'ruby', 'rp', 'rt', 's', 'samp', 'section',
|
||||
'small', 'source', 'span', 'strike', 'strong', 'sub', 'summary',
|
||||
'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time',
|
||||
'tr', 'track', 'tt', 'u', 'ul', 'var', 'wbr', 'video', 'xml:namespace' );
|
||||
|
||||
if ($_SESSION['hasSandbox'] ?? false) $allowed_elements[] = 'iframe';
|
||||
|
||||
$disallowed_attributes = array('id', 'style', 'class', 'width', 'height', 'allow');
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_SANITIZE,
|
||||
function ($result) use (&$doc, &$allowed_elements, &$disallowed_attributes) {
|
||||
if (is_array($result)) {
|
||||
$doc = $result[0];
|
||||
$allowed_elements = $result[1];
|
||||
$disallowed_attributes = $result[2];
|
||||
} else {
|
||||
$doc = $result;
|
||||
}
|
||||
},
|
||||
$doc, $site_url, $allowed_elements, $disallowed_attributes, $article_id);
|
||||
|
||||
$doc->removeChild($doc->firstChild); //remove doctype
|
||||
$doc = self::strip_harmful_tags($doc, $allowed_elements, $disallowed_attributes);
|
||||
|
||||
$entries = $xpath->query('//iframe');
|
||||
foreach ($entries as $entry) {
|
||||
$div = $doc->createElement('div');
|
||||
$div->setAttribute('class', 'embed-responsive');
|
||||
$entry->parentNode->replaceChild($div, $entry);
|
||||
$div->appendChild($entry);
|
||||
}
|
||||
|
||||
if (is_array($highlight_words)) {
|
||||
foreach ($highlight_words as $word) {
|
||||
|
||||
// http://stackoverflow.com/questions/4081372/highlight-keywords-in-a-paragraph
|
||||
|
||||
$elements = $xpath->query("//*/text()");
|
||||
|
||||
foreach ($elements as $child) {
|
||||
|
||||
$fragment = $doc->createDocumentFragment();
|
||||
$text = $child->textContent;
|
||||
|
||||
while (($pos = mb_stripos($text, $word)) !== false) {
|
||||
$fragment->appendChild(new DOMText(mb_substr($text, 0, (int)$pos)));
|
||||
$word = mb_substr($text, (int)$pos, mb_strlen($word));
|
||||
$highlight = $doc->createElement('span');
|
||||
$highlight->appendChild(new DOMText($word));
|
||||
$highlight->setAttribute('class', 'highlight');
|
||||
$fragment->appendChild($highlight);
|
||||
$text = mb_substr($text, $pos + mb_strlen($word));
|
||||
}
|
||||
|
||||
if (!empty($text)) $fragment->appendChild(new DOMText($text));
|
||||
|
||||
$child->parentNode->replaceChild($fragment, $child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$res = $doc->saveHTML();
|
||||
|
||||
/* strip everything outside of <body>...</body> */
|
||||
|
||||
$res_frag = array();
|
||||
if (preg_match('/<body>(.*)<\/body>/is', $res, $res_frag)) {
|
||||
return $res_frag[1];
|
||||
} else {
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
148
classes/Sessions.php
Normal file
148
classes/Sessions.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
require_once 'lib/gettext/gettext.inc.php';
|
||||
|
||||
/**
|
||||
* @todo look into making this behave closer to what SessionHandlerInterface intends
|
||||
*/
|
||||
class Sessions implements \SessionHandlerInterface {
|
||||
private int $session_expire;
|
||||
private string $session_name;
|
||||
|
||||
public function __construct() {
|
||||
$this->session_expire = min(2147483647 - time() - 1, max(Config::get(Config::SESSION_COOKIE_LIFETIME), 86400));
|
||||
$this->session_name = Config::get(Config::SESSION_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts session-related PHP configuration options
|
||||
*/
|
||||
public function configure(): void {
|
||||
if (Config::is_server_https()) {
|
||||
ini_set('session.cookie_secure', 'true');
|
||||
}
|
||||
|
||||
ini_set('session.gc_probability', '75');
|
||||
ini_set('session.name', $this->session_name);
|
||||
ini_set('session.use_only_cookies', 'true');
|
||||
ini_set('session.gc_maxlifetime', $this->session_expire);
|
||||
ini_set('session.cookie_lifetime', '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the validity of the PHP session cookie (if it exists)
|
||||
* @return bool Whether the new cookie was set successfully
|
||||
*/
|
||||
public function extend_session(): bool {
|
||||
if (isset($_COOKIE[$this->session_name])) {
|
||||
return setcookie($this->session_name,
|
||||
$_COOKIE[$this->session_name],
|
||||
time() + $this->session_expire,
|
||||
ini_get('session.cookie_path'),
|
||||
ini_get('session.cookie_domain'),
|
||||
ini_get('session.cookie_secure'),
|
||||
ini_get('session.cookie_httponly'));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function open(string $path, string $name): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function close(): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo set return type to string|false, and remove ReturnTypeWillChange, when min supported is PHP 8
|
||||
* @return string|false
|
||||
*/
|
||||
#[\ReturnTypeWillChange]
|
||||
public function read(string $id) {
|
||||
$sth = Db::pdo()->prepare('SELECT data FROM ttrss_sessions WHERE id=?');
|
||||
$sth->execute([$id]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
return base64_decode($row['data']);
|
||||
}
|
||||
|
||||
$expire = time() + $this->session_expire;
|
||||
|
||||
$sth = Db::pdo()->prepare("INSERT INTO ttrss_sessions (id, data, expire)
|
||||
VALUES (?, '', ?)");
|
||||
return $sth->execute([$id, $expire]) ? '' : false;
|
||||
}
|
||||
|
||||
public function write(string $id, string $data): bool {
|
||||
$data = base64_encode($data);
|
||||
$expire = time() + $this->session_expire;
|
||||
|
||||
$sth = Db::pdo()->prepare('SELECT id FROM ttrss_sessions WHERE id=?');
|
||||
$sth->execute([$id]);
|
||||
|
||||
if ($sth->fetch()) {
|
||||
$sth = Db::pdo()->prepare('UPDATE ttrss_sessions SET data=?, expire=? WHERE id=?');
|
||||
return $sth->execute([$data, $expire, $id]);
|
||||
}
|
||||
|
||||
$sth = Db::pdo()->prepare('INSERT INTO ttrss_sessions (id, data, expire) VALUES (?, ?, ?)');
|
||||
return $sth->execute([$id, $data, $expire]);
|
||||
}
|
||||
|
||||
public function destroy(string $id): bool {
|
||||
$sth = Db::pdo()->prepare('DELETE FROM ttrss_sessions WHERE id = ?');
|
||||
return $sth->execute([$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo set return type to int|false, and remove ReturnTypeWillChange, when min supported is PHP 8
|
||||
* @return int|false the number of deleted sessions on success, or false on failure
|
||||
*/
|
||||
#[\ReturnTypeWillChange]
|
||||
public function gc(int $max_lifetime) {
|
||||
$result = Db::pdo()->query('DELETE FROM ttrss_sessions WHERE expire < ' . time());
|
||||
return $result === false ? false : $result->rowCount();
|
||||
}
|
||||
|
||||
public static function validate_session(): bool {
|
||||
if (Config::get(Config::SINGLE_USER_MODE)) return true;
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
if (!empty($_SESSION['uid'])) {
|
||||
$user = ORM::for_table('ttrss_users')->find_one($_SESSION['uid']);
|
||||
|
||||
if ($user) {
|
||||
if ($user->pwd_hash != $_SESSION['pwd_hash']) {
|
||||
$_SESSION['login_error_msg'] = __('Session failed to validate (password changed)');
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->access_level == UserHelper::ACCESS_LEVEL_DISABLED) {
|
||||
$_SESSION['login_error_msg'] = __('Session failed to validate (account is disabled)');
|
||||
return false;
|
||||
}
|
||||
|
||||
// default to true because there might not be any hooks and this is our last check
|
||||
$hook_result = true;
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_VALIDATE_SESSION,
|
||||
function ($result) use (&$hook_result) {
|
||||
$hook_result = $result;
|
||||
|
||||
if (!$result) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
return $hook_result;
|
||||
|
||||
} else {
|
||||
$_SESSION['login_error_msg'] = __('Session failed to validate (user not found)');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
21
classes/Templator.php
Normal file
21
classes/Templator.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
require_once "lib/MiniTemplator.class.php";
|
||||
|
||||
class Templator extends MiniTemplator {
|
||||
|
||||
/* this reads tt-rss template from templates.local/ or templates/ if only base filename is given */
|
||||
function readTemplateFromFile ($fileName) {
|
||||
if (strpos($fileName, "/") === false) {
|
||||
|
||||
$fileName = basename($fileName);
|
||||
|
||||
if (file_exists("templates.local/$fileName"))
|
||||
return parent::readTemplateFromFile("templates.local/$fileName");
|
||||
else
|
||||
return parent::readTemplateFromFile("templates/$fileName");
|
||||
|
||||
} else {
|
||||
return parent::readTemplateFromFile($fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
89
classes/TimeHelper.php
Normal file
89
classes/TimeHelper.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
class TimeHelper {
|
||||
|
||||
static function smart_date_time(int $timestamp, int $tz_offset = 0, ?int $owner_uid = null, bool $eta_min = false): string {
|
||||
if (!$owner_uid) $owner_uid = $_SESSION['uid'];
|
||||
|
||||
if ($eta_min && time() + $tz_offset - $timestamp < 3600) {
|
||||
return T_sprintf("%d min", date("i", time() + $tz_offset - $timestamp));
|
||||
} else if (date("Y.m.d", $timestamp) == date("Y.m.d", time() + $tz_offset)) {
|
||||
$format = get_pref(Prefs::SHORT_DATE_FORMAT, $owner_uid);
|
||||
if (strpos((strtolower($format)), "a") === false)
|
||||
return date("G:i", $timestamp);
|
||||
else
|
||||
return date("g:i a", $timestamp);
|
||||
} else if (date("Y", $timestamp) == date("Y", time() + $tz_offset)) {
|
||||
$format = get_pref(Prefs::SHORT_DATE_FORMAT, $owner_uid);
|
||||
return date($format, $timestamp);
|
||||
} else {
|
||||
$format = get_pref(Prefs::LONG_DATE_FORMAT, $owner_uid);
|
||||
return date($format, $timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
static function make_local_datetime(?string $timestamp, bool $long, ?int $owner_uid = null,
|
||||
bool $no_smart_dt = false, bool $eta_min = false): string {
|
||||
|
||||
if (!$owner_uid) $owner_uid = $_SESSION['uid'];
|
||||
if (!$timestamp) $timestamp = '1970-01-01 0:00';
|
||||
|
||||
global $utc_tz;
|
||||
global $user_tz;
|
||||
|
||||
if (!$utc_tz) $utc_tz = new DateTimeZone('UTC');
|
||||
|
||||
$timestamp = substr($timestamp, 0, 19);
|
||||
|
||||
# We store date in UTC internally
|
||||
$dt = new DateTime($timestamp, $utc_tz);
|
||||
|
||||
$user_tz_string = get_pref(Prefs::USER_TIMEZONE, $owner_uid);
|
||||
|
||||
if ($user_tz_string != 'Automatic') {
|
||||
|
||||
try {
|
||||
if (!$user_tz) $user_tz = new DateTimeZone($user_tz_string);
|
||||
} catch (Exception $e) {
|
||||
$user_tz = $utc_tz;
|
||||
}
|
||||
|
||||
$tz_offset = $user_tz->getOffset($dt);
|
||||
} else {
|
||||
$tz_offset = (int) -($_SESSION["clientTzOffset"] ?? 0);
|
||||
}
|
||||
|
||||
$user_timestamp = $dt->format('U') + $tz_offset;
|
||||
|
||||
if (!$no_smart_dt) {
|
||||
return self::smart_date_time($user_timestamp,
|
||||
$tz_offset, $owner_uid, $eta_min);
|
||||
} else {
|
||||
if ($long)
|
||||
$format = get_pref(Prefs::LONG_DATE_FORMAT, $owner_uid);
|
||||
else
|
||||
$format = get_pref(Prefs::SHORT_DATE_FORMAT, $owner_uid);
|
||||
|
||||
return date($format, $user_timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
static function convert_timestamp(int $timestamp, string $source_tz, string $dest_tz): int {
|
||||
|
||||
try {
|
||||
$source_tz = new DateTimeZone($source_tz);
|
||||
} catch (Exception $e) {
|
||||
$source_tz = new DateTimeZone('UTC');
|
||||
}
|
||||
|
||||
try {
|
||||
$dest_tz = new DateTimeZone($dest_tz);
|
||||
} catch (Exception $e) {
|
||||
$dest_tz = new DateTimeZone('UTC');
|
||||
}
|
||||
|
||||
$dt = new DateTime(date('Y-m-d H:i:s', $timestamp), $source_tz);
|
||||
|
||||
return (int)$dt->format('U') + $dest_tz->getOffset($dt);
|
||||
}
|
||||
|
||||
}
|
||||
216
classes/Tracer.php
Normal file
216
classes/Tracer.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
|
||||
use OpenTelemetry\API\Trace\SpanContextInterface;
|
||||
use OpenTelemetry\API\Trace\SpanInterface;
|
||||
use OpenTelemetry\API\Trace\SpanKind;
|
||||
use OpenTelemetry\API\Trace\TraceFlags;
|
||||
use OpenTelemetry\API\Trace\TraceStateInterface;
|
||||
use OpenTelemetry\Context\ContextInterface;
|
||||
use OpenTelemetry\Context\ContextKey;
|
||||
use OpenTelemetry\Context\ContextKeyInterface;
|
||||
use OpenTelemetry\Context\ImplicitContextKeyedInterface;
|
||||
use OpenTelemetry\Context\ScopeInterface;
|
||||
use OpenTelemetry\Contrib\Otlp\OtlpHttpTransportFactory;
|
||||
use OpenTelemetry\Contrib\Otlp\SpanExporter;
|
||||
use OpenTelemetry\SDK\Common\Attribute\Attributes;
|
||||
use OpenTelemetry\SDK\Resource\ResourceInfo;
|
||||
use OpenTelemetry\SDK\Resource\ResourceInfoFactory;
|
||||
use OpenTelemetry\SDK\Trace\Sampler\AlwaysOnSampler;
|
||||
use OpenTelemetry\SDK\Trace\Sampler\ParentBased;
|
||||
use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor;
|
||||
use OpenTelemetry\SDK\Trace\TracerProvider;
|
||||
use OpenTelemetry\SemConv\ResourceAttributes;
|
||||
|
||||
class DummyContextInterface implements ContextInterface {
|
||||
|
||||
/** @var DummyContextInterface */
|
||||
private static $instance;
|
||||
|
||||
public function __construct() {
|
||||
self::$instance = $this;
|
||||
}
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
public static function createKey(string $key): ContextKeyInterface { return new ContextKey(); }
|
||||
|
||||
public static function getCurrent(): ContextInterface { return self::$instance; }
|
||||
|
||||
public function activate(): ScopeInterface { return new DummyScopeInterface(); }
|
||||
|
||||
public function with(ContextKeyInterface $key, $value): ContextInterface { return $this; }
|
||||
|
||||
public function withContextValue(ImplicitContextKeyedInterface $value): ContextInterface { return $this; }
|
||||
|
||||
public function get(ContextKeyInterface $key) { return new ContextKey(); }
|
||||
|
||||
}
|
||||
|
||||
class DummySpanContextInterface implements SpanContextInterface {
|
||||
|
||||
/** @var DummySpanContextInterface $instance */
|
||||
private static $instance;
|
||||
|
||||
public function __construct() {
|
||||
self::$instance = $this;
|
||||
}
|
||||
|
||||
public static function createFromRemoteParent(string $traceId, string $spanId, int $traceFlags = TraceFlags::DEFAULT, ?TraceStateInterface $traceState = null): SpanContextInterface { return self::$instance; }
|
||||
|
||||
public static function getInvalid(): SpanContextInterface { return self::$instance; }
|
||||
|
||||
public static function create(string $traceId, string $spanId, int $traceFlags = TraceFlags::DEFAULT, ?TraceStateInterface $traceState = null): SpanContextInterface { return self::$instance; }
|
||||
|
||||
public function getTraceId(): string { return ""; }
|
||||
|
||||
public function getTraceIdBinary(): string { return ""; }
|
||||
|
||||
public function getSpanId(): string { return ""; }
|
||||
|
||||
public function getSpanIdBinary(): string { return ""; }
|
||||
|
||||
public function getTraceFlags(): int { return 0; }
|
||||
|
||||
public function getTraceState(): ?TraceStateInterface { return null; }
|
||||
|
||||
public function isValid(): bool { return false; }
|
||||
|
||||
public function isRemote(): bool { return false; }
|
||||
|
||||
public function isSampled(): bool { return false; }
|
||||
}
|
||||
|
||||
class DummyScopeInterface implements ScopeInterface {
|
||||
public function detach(): int { return 0; }
|
||||
}
|
||||
|
||||
class DummySpanInterface implements SpanInterface {
|
||||
|
||||
/** @var DummySpanInterface $instance */
|
||||
private static $instance;
|
||||
|
||||
public function __construct() {
|
||||
self::$instance = $this;
|
||||
}
|
||||
|
||||
public static function fromContext(ContextInterface $context): SpanInterface { return self::$instance; }
|
||||
|
||||
public static function getCurrent(): SpanInterface { return self::$instance; }
|
||||
|
||||
public static function getInvalid(): SpanInterface { return self::$instance; }
|
||||
|
||||
public static function wrap(SpanContextInterface $spanContext): SpanInterface { return self::$instance; }
|
||||
|
||||
public function getContext(): SpanContextInterface { return new DummySpanContextInterface(); }
|
||||
|
||||
public function isRecording(): bool { return false; }
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
public function setAttribute(string $key, $value): SpanInterface { return self::$instance; }
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
public function setAttributes(iterable $attributes): SpanInterface { return self::$instance; }
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
public function addEvent(string $name, iterable $attributes = [], ?int $timestamp = null): SpanInterface { return $this; }
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
public function recordException(Throwable $exception, iterable $attributes = []): SpanInterface { return $this; }
|
||||
|
||||
public function updateName(string $name): SpanInterface { return $this; }
|
||||
|
||||
public function setStatus(string $code, ?string $description = null): SpanInterface { return $this; }
|
||||
|
||||
public function end(?int $endEpochNanos = null): void { }
|
||||
|
||||
public function activate(): ScopeInterface { return new DummyScopeInterface(); }
|
||||
|
||||
public function storeInContext(ContextInterface $context): ContextInterface { return new DummyContextInterface(); }
|
||||
|
||||
}
|
||||
|
||||
class Tracer {
|
||||
/** @var Tracer $instance */
|
||||
private static $instance = null;
|
||||
|
||||
/** @var OpenTelemetry\SDK\Trace\TracerProviderInterface $tracerProvider */
|
||||
private $tracerProvider = null;
|
||||
|
||||
/** @var OpenTelemetry\API\Trace\TracerInterface $tracer */
|
||||
private $tracer = null;
|
||||
|
||||
public function __construct() {
|
||||
$OPENTELEMETRY_ENDPOINT = Config::get(Config::OPENTELEMETRY_ENDPOINT);
|
||||
|
||||
if ($OPENTELEMETRY_ENDPOINT) {
|
||||
$transport = (new OtlpHttpTransportFactory())->create($OPENTELEMETRY_ENDPOINT, 'application/x-protobuf');
|
||||
$exporter = new SpanExporter($transport);
|
||||
|
||||
$resource = ResourceInfoFactory::emptyResource()->merge(
|
||||
ResourceInfo::create(Attributes::create(
|
||||
[ResourceAttributes::SERVICE_NAME => Config::get(Config::OPENTELEMETRY_SERVICE)]
|
||||
), ResourceAttributes::SCHEMA_URL),
|
||||
);
|
||||
|
||||
$this->tracerProvider = TracerProvider::builder()
|
||||
->addSpanProcessor(new SimpleSpanProcessor($exporter))
|
||||
->setResource($resource)
|
||||
->setSampler(new ParentBased(new AlwaysOnSampler()))
|
||||
->build();
|
||||
|
||||
$this->tracer = $this->tracerProvider->getTracer('io.opentelemetry.contrib.php');
|
||||
|
||||
$context = TraceContextPropagator::getInstance()->extract(getallheaders());
|
||||
|
||||
$span = $this->tracer->spanBuilder($_SESSION['name'] ?? 'not logged in')
|
||||
->setParent($context)
|
||||
->setSpanKind(SpanKind::KIND_SERVER)
|
||||
->setAttribute('php.request', json_encode($_REQUEST))
|
||||
->setAttribute('php.server', json_encode($_SERVER))
|
||||
->setAttribute('php.session', json_encode($_SESSION ?? []))
|
||||
->startSpan();
|
||||
|
||||
$scope = $span->activate();
|
||||
|
||||
register_shutdown_function(function() use ($span, $scope) {
|
||||
$span->end();
|
||||
$scope->detach();
|
||||
$this->tracerProvider->shutdown();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
* @return OpenTelemetry\API\Trace\SpanInterface
|
||||
*/
|
||||
private function _start(string $name) {
|
||||
if ($this->tracer != null) {
|
||||
$span = $this->tracer
|
||||
->spanBuilder($name)
|
||||
->setSpanKind(SpanKind::KIND_SERVER)
|
||||
->startSpan();
|
||||
|
||||
$span->activate();
|
||||
} else {
|
||||
$span = new DummySpanInterface();
|
||||
}
|
||||
|
||||
return $span;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
* @return OpenTelemetry\API\Trace\SpanInterface
|
||||
*/
|
||||
public static function start(string $name) {
|
||||
return self::get_instance()->_start($name);
|
||||
}
|
||||
|
||||
public static function get_instance() : Tracer {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
}
|
||||
505
classes/UrlHelper.php
Normal file
505
classes/UrlHelper.php
Normal file
@@ -0,0 +1,505 @@
|
||||
<?php
|
||||
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
|
||||
class UrlHelper {
|
||||
const EXTRA_HREF_SCHEMES = [
|
||||
"magnet",
|
||||
"mailto",
|
||||
"tel"
|
||||
];
|
||||
|
||||
const EXTRA_SCHEMES_BY_CONTENT_TYPE = [
|
||||
"application/x-bittorrent" => [ "magnet" ],
|
||||
];
|
||||
|
||||
static string $fetch_last_error;
|
||||
static int $fetch_last_error_code;
|
||||
static string $fetch_last_error_content;
|
||||
static string $fetch_last_content_type;
|
||||
static string $fetch_last_modified;
|
||||
static string $fetch_effective_url;
|
||||
static string $fetch_effective_ip_addr;
|
||||
|
||||
public static ?GuzzleHttp\ClientInterface $client = null;
|
||||
|
||||
private static function get_client(): GuzzleHttp\ClientInterface {
|
||||
if (self::$client == null) {
|
||||
self::$client = new GuzzleHttp\Client([
|
||||
GuzzleHttp\RequestOptions::COOKIES => false,
|
||||
GuzzleHttp\RequestOptions::PROXY => Config::get(Config::HTTP_PROXY) ?: null,
|
||||
]);
|
||||
}
|
||||
|
||||
return self::$client;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|int> $parts
|
||||
*/
|
||||
static function build_url(array $parts): string {
|
||||
$tmp = $parts['scheme'] . "://" . $parts['host'];
|
||||
|
||||
if (isset($parts['path'])) $tmp .= $parts['path'];
|
||||
if (isset($parts['query'])) $tmp .= '?' . $parts['query'];
|
||||
if (isset($parts['fragment'])) $tmp .= '#' . $parts['fragment'];
|
||||
|
||||
return $tmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a (possibly) relative URL to a absolute one, using provided base URL.
|
||||
* Provides some exceptions for additional schemes like data: if called with owning element/attribute.
|
||||
*
|
||||
* @param string $base_url Base URL (i.e. from where the document is)
|
||||
* @param string $rel_url Possibly relative URL in the document
|
||||
* @param string $owner_element Owner element tag name (i.e. "a") (optional)
|
||||
* @param string $owner_attribute Owner attribute (i.e. "href") (optional)
|
||||
* @param string $content_type URL content type as specified by enclosures, etc.
|
||||
*
|
||||
* @return false|string Absolute URL or false on failure (either during URL parsing or validation)
|
||||
*/
|
||||
public static function rewrite_relative($base_url,
|
||||
$rel_url,
|
||||
string $owner_element = "",
|
||||
string $owner_attribute = "",
|
||||
string $content_type = "") {
|
||||
|
||||
$rel_parts = parse_url($rel_url);
|
||||
|
||||
if (!$rel_url) return $base_url;
|
||||
|
||||
/**
|
||||
* If parse_url failed to parse $rel_url return false to match the current "invalid thing" behavior
|
||||
* of UrlHelper::validate().
|
||||
*
|
||||
* TODO: There are many places where a string return value is assumed. We should either update those
|
||||
* to account for the possibility of failure, or look into updating this function's return values.
|
||||
*/
|
||||
if ($rel_parts === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!empty($rel_parts['host']) && !empty($rel_parts['scheme'])) {
|
||||
return self::validate($rel_url);
|
||||
|
||||
// protocol-relative URL (rare but they exist)
|
||||
} else if (strpos($rel_url, "//") === 0) {
|
||||
return self::validate("https:" . $rel_url);
|
||||
// allow some extra schemes for A href
|
||||
} else if (in_array($rel_parts["scheme"] ?? "", self::EXTRA_HREF_SCHEMES, true) &&
|
||||
$owner_element == "a" &&
|
||||
$owner_attribute == "href") {
|
||||
return $rel_url;
|
||||
// allow some extra schemes for links with feed-specified content type i.e. enclosures
|
||||
} else if ($content_type &&
|
||||
isset(self::EXTRA_SCHEMES_BY_CONTENT_TYPE[$content_type]) &&
|
||||
in_array($rel_parts["scheme"], self::EXTRA_SCHEMES_BY_CONTENT_TYPE[$content_type])) {
|
||||
return $rel_url;
|
||||
// allow limited subset of inline base64-encoded images for IMG elements
|
||||
} else if (($rel_parts["scheme"] ?? "") == "data" &&
|
||||
preg_match('%^image/(webp|gif|jpg|png|svg);base64,%', $rel_parts["path"]) &&
|
||||
$owner_element == "img" &&
|
||||
$owner_attribute == "src") {
|
||||
return $rel_url;
|
||||
} else {
|
||||
$base_parts = parse_url($base_url);
|
||||
|
||||
$rel_parts['host'] = $base_parts['host'] ?? "";
|
||||
$rel_parts['scheme'] = $base_parts['scheme'] ?? "";
|
||||
|
||||
if ($rel_parts['path'] ?? "") {
|
||||
|
||||
// we append dirname() of base path to relative URL path as per RFC 3986 section 5.2.2
|
||||
$base_path = with_trailing_slash(dirname($base_parts['path'] ?? ""));
|
||||
|
||||
// 1. absolute relative path (/test.html) = no-op, proceed as is
|
||||
|
||||
// 2. dotslash relative URI (./test.html) - strip "./", append base path
|
||||
if (strpos($rel_parts['path'], './') === 0) {
|
||||
$rel_parts['path'] = $base_path . substr($rel_parts['path'], 2);
|
||||
// 3. anything else relative (test.html) - append dirname() of base path
|
||||
} else if (strpos($rel_parts['path'], '/') !== 0) {
|
||||
$rel_parts['path'] = $base_path . $rel_parts['path'];
|
||||
}
|
||||
|
||||
//$rel_parts['path'] = str_replace("/./", "/", $rel_parts['path']);
|
||||
//$rel_parts['path'] = str_replace("//", "/", $rel_parts['path']);
|
||||
}
|
||||
|
||||
return self::validate(self::build_url($rel_parts));
|
||||
}
|
||||
}
|
||||
|
||||
/** extended filtering involves validation for safe ports and loopback
|
||||
* @return false|string false if something went wrong, otherwise the URL string
|
||||
*/
|
||||
static function validate(string $url, bool $extended_filtering = false) {
|
||||
|
||||
$url = clean($url);
|
||||
|
||||
# fix protocol-relative URLs
|
||||
if (strpos($url, "//") === 0)
|
||||
$url = "https:" . $url;
|
||||
|
||||
$tokens = parse_url($url);
|
||||
|
||||
// this isn't really necessary because filter_var(... FILTER_VALIDATE_URL) requires host and scheme
|
||||
// as per https://php.watch/versions/7.3/filter-var-flag-deprecation but it might save time
|
||||
if (empty($tokens['host']))
|
||||
return false;
|
||||
|
||||
if (!in_array(strtolower($tokens['scheme']), ['http', 'https']))
|
||||
return false;
|
||||
|
||||
//convert IDNA hostname to punycode if possible
|
||||
if (function_exists("idn_to_ascii")) {
|
||||
if (mb_detect_encoding($tokens['host']) != 'ASCII') {
|
||||
if (defined('IDNA_NONTRANSITIONAL_TO_ASCII') && defined('INTL_IDNA_VARIANT_UTS46')) {
|
||||
$tokens['host'] = idn_to_ascii($tokens['host'], IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46);
|
||||
} else {
|
||||
$tokens['host'] = idn_to_ascii($tokens['host']);
|
||||
}
|
||||
|
||||
// if `idn_to_ascii` failed
|
||||
if ($tokens['host'] === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// separate set of tokens with urlencoded 'path' because filter_var() rightfully fails on non-latin characters
|
||||
// (used for validation only, we actually request the original URL, in case of urlencode breaking it)
|
||||
$tokens_filter_var = $tokens;
|
||||
|
||||
if ($tokens['path'] ?? false) {
|
||||
$tokens_filter_var['path'] = implode("/",
|
||||
array_map("rawurlencode",
|
||||
array_map("rawurldecode",
|
||||
explode("/", $tokens['path']))));
|
||||
}
|
||||
|
||||
$url = self::build_url($tokens);
|
||||
$url_filter_var = self::build_url($tokens_filter_var);
|
||||
|
||||
if (filter_var($url_filter_var, FILTER_VALIDATE_URL) === false)
|
||||
return false;
|
||||
|
||||
if ($extended_filtering) {
|
||||
if (!in_array($tokens['port'] ?? '', [80, 443, '']))
|
||||
return false;
|
||||
|
||||
if (strtolower($tokens['host']) == 'localhost' || $tokens['host'] == '::1' || strpos($tokens['host'], '127.') === 0)
|
||||
return false;
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|string
|
||||
*/
|
||||
static function resolve_redirects(string $url, int $timeout) {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
$span->setAttribute('func.args', json_encode(func_get_args()));
|
||||
$client = self::get_client();
|
||||
|
||||
try {
|
||||
$response = $client->request('HEAD', $url, [
|
||||
GuzzleHttp\RequestOptions::CONNECT_TIMEOUT => $timeout ?: Config::get(Config::FILE_FETCH_CONNECT_TIMEOUT),
|
||||
GuzzleHttp\RequestOptions::TIMEOUT => $timeout ?: Config::get(Config::FILE_FETCH_TIMEOUT),
|
||||
GuzzleHttp\RequestOptions::ALLOW_REDIRECTS => ['max' => 10, 'track_redirects' => true],
|
||||
GuzzleHttp\RequestOptions::HTTP_ERRORS => false,
|
||||
GuzzleHttp\RequestOptions::HEADERS => [
|
||||
'User-Agent' => Config::get_user_agent(),
|
||||
'Connection' => 'close',
|
||||
],
|
||||
]);
|
||||
} catch (Exception $ex) {
|
||||
$span->setAttribute('error', (string) $ex);
|
||||
$span->end();
|
||||
return false;
|
||||
}
|
||||
|
||||
// If a history header value doesn't exist there was no redirection and the original URL is fine.
|
||||
$history_header = $response->getHeader(GuzzleHttp\RedirectMiddleware::HISTORY_HEADER);
|
||||
$span->end();
|
||||
return ($history_header ? end($history_header) : $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, bool|int|string>|string $options
|
||||
* @return false|string false if something went wrong, otherwise string contents
|
||||
*/
|
||||
// TODO: max_size currently only works for CURL transfers
|
||||
// TODO: multiple-argument way is deprecated, first parameter is a hash now
|
||||
public static function fetch($options /* previously: 0: $url , 1: $type = false, 2: $login = false, 3: $pass = false,
|
||||
4: $post_query = false, 5: $timeout = false, 6: $timestamp = 0, 7: $useragent = false, 8: $encoding = false,
|
||||
9: $auth_type = "basic" */) {
|
||||
$span = Tracer::start(__METHOD__);
|
||||
$span->setAttribute('func.args', json_encode(func_get_args()));
|
||||
|
||||
self::$fetch_last_error = "";
|
||||
self::$fetch_last_error_code = -1;
|
||||
self::$fetch_last_error_content = "";
|
||||
self::$fetch_last_content_type = "";
|
||||
self::$fetch_last_modified = "";
|
||||
self::$fetch_effective_url = "";
|
||||
self::$fetch_effective_ip_addr = "";
|
||||
|
||||
if (!is_array($options)) {
|
||||
|
||||
// falling back on compatibility shim
|
||||
$option_names = [ "url", "type", "login", "pass", "post_query", "timeout", "last_modified", "useragent", "encoding", "auth_type" ];
|
||||
$tmp = [];
|
||||
|
||||
for ($i = 0; $i < func_num_args(); $i++) {
|
||||
$tmp[$option_names[$i]] = func_get_arg($i);
|
||||
}
|
||||
|
||||
$options = $tmp;
|
||||
|
||||
/*$options = array(
|
||||
"url" => func_get_arg(0),
|
||||
"type" => @func_get_arg(1),
|
||||
"login" => @func_get_arg(2),
|
||||
"pass" => @func_get_arg(3),
|
||||
"post_query" => @func_get_arg(4),
|
||||
"timeout" => @func_get_arg(5),
|
||||
"timestamp" => @func_get_arg(6),
|
||||
"useragent" => @func_get_arg(7),
|
||||
"encoding" => @func_get_arg(8),
|
||||
"auth_type" => @func_get_arg(9),
|
||||
); */
|
||||
}
|
||||
|
||||
$url = $options["url"];
|
||||
$type = $options["type"] ?? false;
|
||||
$login = $options["login"] ?? false;
|
||||
$pass = $options["pass"] ?? false;
|
||||
$auth_type = $options["auth_type"] ?? "basic";
|
||||
$post_query = $options["post_query"] ?? false;
|
||||
$timeout = $options["timeout"] ?? false;
|
||||
$last_modified = $options["last_modified"] ?? "";
|
||||
$useragent = $options["useragent"] ?? false;
|
||||
$followlocation = $options["followlocation"] ?? true;
|
||||
$max_size = $options["max_size"] ?? Config::get(Config::MAX_DOWNLOAD_FILE_SIZE); // in bytes
|
||||
$http_accept = $options["http_accept"] ?? false;
|
||||
$http_referrer = $options["http_referrer"] ?? false;
|
||||
$encoding = $options["encoding"] ?? false;
|
||||
|
||||
$url = ltrim($url, ' ');
|
||||
$url = str_replace(' ', '%20', $url);
|
||||
|
||||
Debug::log("[UrlHelper] fetching: $url", Debug::LOG_EXTENDED);
|
||||
|
||||
$url = self::validate($url, true);
|
||||
|
||||
if (!$url) {
|
||||
self::$fetch_last_error = 'Requested URL failed extended validation.';
|
||||
$span->setAttribute('error', self::$fetch_last_error);
|
||||
$span->end();
|
||||
return false;
|
||||
}
|
||||
|
||||
$url_host = parse_url($url, PHP_URL_HOST);
|
||||
$ip_addr = gethostbyname($url_host);
|
||||
|
||||
if (!$ip_addr || strpos($ip_addr, '127.') === 0) {
|
||||
self::$fetch_last_error = "URL hostname failed to resolve or resolved to a loopback address ($ip_addr)";
|
||||
$span->setAttribute('error', self::$fetch_last_error);
|
||||
$span->end();
|
||||
return false;
|
||||
}
|
||||
|
||||
$req_options = [
|
||||
GuzzleHttp\RequestOptions::CONNECT_TIMEOUT => $timeout ?: Config::get(Config::FILE_FETCH_CONNECT_TIMEOUT),
|
||||
GuzzleHttp\RequestOptions::TIMEOUT => $timeout ?: Config::get(Config::FILE_FETCH_TIMEOUT),
|
||||
GuzzleHttp\RequestOptions::HEADERS => [
|
||||
'User-Agent' => $useragent ?: Config::get_user_agent(),
|
||||
],
|
||||
'curl' => [],
|
||||
];
|
||||
|
||||
if ($followlocation) {
|
||||
$req_options[GuzzleHttp\RequestOptions::ALLOW_REDIRECTS] = [
|
||||
'max' => 20,
|
||||
'track_redirects' => true,
|
||||
'on_redirect' => function(RequestInterface $request, ResponseInterface $response, UriInterface $uri) {
|
||||
if (!self::validate($uri, true)) {
|
||||
self::$fetch_effective_url = (string) $uri;
|
||||
throw new GuzzleHttp\Exception\RequestException('URL received during redirection failed extended validation.',
|
||||
$request, $response);
|
||||
}
|
||||
},
|
||||
];
|
||||
} else {
|
||||
$req_options[GuzzleHttp\RequestOptions::ALLOW_REDIRECTS] = false;
|
||||
}
|
||||
|
||||
if ($last_modified && !$post_query)
|
||||
$req_options[GuzzleHttp\RequestOptions::HEADERS]['If-Modified-Since'] = $last_modified;
|
||||
|
||||
if ($http_accept)
|
||||
$req_options[GuzzleHttp\RequestOptions::HEADERS]['Accept'] = $http_accept;
|
||||
|
||||
if ($encoding)
|
||||
$req_options[GuzzleHttp\RequestOptions::HEADERS]['Accept-Encoding'] = $encoding;
|
||||
|
||||
if ($http_referrer)
|
||||
$req_options[GuzzleHttp\RequestOptions::HEADERS]['Referer'] = $http_referrer;
|
||||
|
||||
if ($login && $pass && in_array($auth_type, ['basic', 'digest', 'ntlm'])) {
|
||||
// Let Guzzle handle the details for auth types it supports
|
||||
$req_options[GuzzleHttp\RequestOptions::AUTH] = [$login, $pass, $auth_type];
|
||||
} elseif ($auth_type === 'any') {
|
||||
// https://docs.guzzlephp.org/en/stable/faq.html#how-can-i-add-custom-curl-options
|
||||
$req_options['curl'][\CURLOPT_HTTPAUTH] = \CURLAUTH_ANY;
|
||||
if ($login && $pass)
|
||||
$req_options['curl'][\CURLOPT_USERPWD] = "$login:$pass";
|
||||
}
|
||||
|
||||
if ($post_query)
|
||||
$req_options[GuzzleHttp\RequestOptions::FORM_PARAMS] = $post_query;
|
||||
|
||||
if ($max_size) {
|
||||
$req_options[GuzzleHttp\RequestOptions::PROGRESS] = function($download_size, $downloaded, $upload_size, $uploaded) use(&$max_size, $url) {
|
||||
//Debug::log("[curl progressfunction] $downloaded $max_size", Debug::$LOG_EXTENDED);
|
||||
|
||||
if ($downloaded > $max_size) {
|
||||
Debug::log("[UrlHelper] fetch error: max size of $max_size bytes exceeded when downloading $url . Aborting.", Debug::LOG_VERBOSE);
|
||||
throw new \LengthException("Download exceeded size limit");
|
||||
}
|
||||
};
|
||||
|
||||
# Alternative/supplement to `progress` checking
|
||||
$req_options[GuzzleHttp\RequestOptions::ON_HEADERS] = function(ResponseInterface $response) use(&$max_size, $url) {
|
||||
$content_length = $response->getHeaderLine('Content-Length');
|
||||
if ($content_length > $max_size) {
|
||||
Debug::log("[UrlHelper] fetch error: server indicated (via 'Content-Length: {$content_length}') max size of $max_size bytes " .
|
||||
"would be exceeded when downloading $url . Aborting.", Debug::LOG_VERBOSE);
|
||||
throw new \LengthException("Server sent 'Content-Length' exceeding download limit");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$client = self::get_client();
|
||||
|
||||
try {
|
||||
$response = $client->request($post_query ? 'POST' : 'GET', $url, $req_options);
|
||||
} catch (\LengthException $ex) {
|
||||
// Either 'Content-Length' indicated the download limit would be exceeded, or the transfer actually exceeded the download limit.
|
||||
self::$fetch_last_error = $ex->getMessage();
|
||||
$span->setAttribute('error', self::$fetch_last_error);
|
||||
$span->end();
|
||||
return false;
|
||||
} catch (GuzzleHttp\Exception\GuzzleException $ex) {
|
||||
self::$fetch_last_error = $ex->getMessage();
|
||||
|
||||
if ($ex instanceof GuzzleHttp\Exception\RequestException) {
|
||||
if ($ex instanceof GuzzleHttp\Exception\BadResponseException) {
|
||||
// 4xx or 5xx
|
||||
self::$fetch_last_error_code = $ex->getResponse()->getStatusCode();
|
||||
|
||||
// If credentials were provided and we got a 403 back, retry once with auth type 'any'
|
||||
// to attempt compatibility with unusual configurations.
|
||||
if ($login && $pass && self::$fetch_last_error_code === 403 && $auth_type !== 'any') {
|
||||
$options['auth_type'] = 'any';
|
||||
$span->end();
|
||||
return self::fetch($options);
|
||||
}
|
||||
|
||||
self::$fetch_last_content_type = $ex->getResponse()->getHeaderLine('content-type');
|
||||
|
||||
if ($type && strpos(self::$fetch_last_content_type, "$type") === false)
|
||||
self::$fetch_last_error_content = (string) $ex->getResponse()->getBody();
|
||||
} elseif (array_key_exists('errno', $ex->getHandlerContext())) {
|
||||
$errno = (int) $ex->getHandlerContext()['errno'];
|
||||
|
||||
// By default, all supported encoding types are sent via `Accept-Encoding` and decoding of
|
||||
// responses with `Content-Encoding` is automatically attempted. If this fails, we do a
|
||||
// single retry with `Accept-Encoding: none` to try and force an unencoded response.
|
||||
if (($errno === \CURLE_WRITE_ERROR || $errno === \CURLE_BAD_CONTENT_ENCODING) &&
|
||||
$ex->getRequest()->getHeaderLine('accept-encoding') !== 'none') {
|
||||
$options['encoding'] = 'none';
|
||||
$span->end();
|
||||
return self::fetch($options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$span->setAttribute('error', self::$fetch_last_error);
|
||||
$span->end();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Keep setting expected 'fetch_last_error_code' and 'fetch_last_error' values
|
||||
self::$fetch_last_error_code = $response->getStatusCode();
|
||||
self::$fetch_last_error = "HTTP/{$response->getProtocolVersion()} {$response->getStatusCode()} {$response->getReasonPhrase()}";
|
||||
self::$fetch_last_modified = $response->getHeaderLine('last-modified');
|
||||
self::$fetch_last_content_type = $response->getHeaderLine('content-type');
|
||||
|
||||
// If a history header value doesn't exist there was no redirection and the original URL is fine.
|
||||
$history_header = $response->getHeader(GuzzleHttp\RedirectMiddleware::HISTORY_HEADER);
|
||||
self::$fetch_effective_url = $history_header ? end($history_header) : $url;
|
||||
|
||||
// This shouldn't be necessary given the checks that occur during potential redirects, but we'll do it anyway.
|
||||
if (!self::validate(self::$fetch_effective_url, true)) {
|
||||
self::$fetch_last_error = "URL received after redirection failed extended validation.";
|
||||
$span->setAttribute('error', self::$fetch_last_error);
|
||||
$span->end();
|
||||
return false;
|
||||
}
|
||||
|
||||
self::$fetch_effective_ip_addr = gethostbyname(parse_url(self::$fetch_effective_url, PHP_URL_HOST));
|
||||
|
||||
if (!self::$fetch_effective_ip_addr || strpos(self::$fetch_effective_ip_addr, '127.') === 0) {
|
||||
self::$fetch_last_error = 'URL hostname received after redirection failed to resolve or resolved to a loopback address (' .
|
||||
self::$fetch_effective_ip_addr . ')';
|
||||
$span->setAttribute('error', self::$fetch_last_error);
|
||||
$span->end();
|
||||
return false;
|
||||
}
|
||||
|
||||
$body = (string) $response->getBody();
|
||||
|
||||
if (!$body) {
|
||||
self::$fetch_last_error = 'Successful response, but no content was received.';
|
||||
$span->setAttribute('error', self::$fetch_last_error);
|
||||
$span->end();
|
||||
return false;
|
||||
}
|
||||
|
||||
$span->end();
|
||||
return $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|string false if the provided URL didn't match expected patterns, otherwise the video ID string
|
||||
*/
|
||||
public static function url_to_youtube_vid(string $url) {
|
||||
$url = str_replace("youtube.com", "youtube-nocookie.com", $url);
|
||||
|
||||
$regexps = [
|
||||
"/\/\/www\.youtube-nocookie\.com\/v\/([\w-]+)/",
|
||||
"/\/\/www\.youtube-nocookie\.com\/embed\/([\w-]+)/",
|
||||
"/\/\/www\.youtube-nocookie\.com\/watch?v=([\w-]+)/",
|
||||
"/\/\/youtu.be\/([\w-]+)/",
|
||||
];
|
||||
|
||||
foreach ($regexps as $re) {
|
||||
$matches = [];
|
||||
|
||||
if (preg_match($re, $url, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
520
classes/UserHelper.php
Normal file
520
classes/UserHelper.php
Normal file
@@ -0,0 +1,520 @@
|
||||
<?php
|
||||
use OTPHP\TOTP;
|
||||
|
||||
class UserHelper {
|
||||
|
||||
const HASH_ALGO_SSHA512 = 'SSHA-512';
|
||||
const HASH_ALGO_SSHA256 = 'SSHA-256';
|
||||
const HASH_ALGO_MODE2 = 'MODE2';
|
||||
const HASH_ALGO_SHA1X = 'SHA1X';
|
||||
const HASH_ALGO_SHA1 = 'SHA1';
|
||||
|
||||
const HASH_ALGOS = [
|
||||
self::HASH_ALGO_SSHA512,
|
||||
self::HASH_ALGO_SSHA256,
|
||||
self::HASH_ALGO_MODE2,
|
||||
self::HASH_ALGO_SHA1X,
|
||||
self::HASH_ALGO_SHA1
|
||||
];
|
||||
|
||||
const ACCESS_LEVELS = [
|
||||
self::ACCESS_LEVEL_DISABLED,
|
||||
self::ACCESS_LEVEL_READONLY,
|
||||
self::ACCESS_LEVEL_USER,
|
||||
self::ACCESS_LEVEL_POWERUSER,
|
||||
self::ACCESS_LEVEL_ADMIN,
|
||||
self::ACCESS_LEVEL_KEEP_CURRENT
|
||||
];
|
||||
|
||||
/** forbidden to login */
|
||||
const ACCESS_LEVEL_DISABLED = -2;
|
||||
|
||||
/** can't subscribe to new feeds, feeds are not updated */
|
||||
const ACCESS_LEVEL_READONLY = -1;
|
||||
|
||||
/** no restrictions, regular user */
|
||||
const ACCESS_LEVEL_USER = 0;
|
||||
|
||||
/** not used, same as regular user */
|
||||
const ACCESS_LEVEL_POWERUSER = 5;
|
||||
|
||||
/** has administrator permissions */
|
||||
const ACCESS_LEVEL_ADMIN = 10;
|
||||
|
||||
/** used by self::user_modify() to keep current access level */
|
||||
const ACCESS_LEVEL_KEEP_CURRENT = -1024;
|
||||
|
||||
/**
|
||||
* @param int $level integer loglevel value
|
||||
* @return UserHelper::ACCESS_LEVEL_* if valid, warn and return ACCESS_LEVEL_KEEP_CURRENT otherwise
|
||||
*/
|
||||
public static function map_access_level(int $level) : int {
|
||||
if (in_array($level, self::ACCESS_LEVELS)) {
|
||||
/** @phpstan-ignore-next-line */
|
||||
return $level;
|
||||
} else {
|
||||
user_error("Passed invalid user access level: $level", E_USER_WARNING);
|
||||
return self::ACCESS_LEVEL_KEEP_CURRENT;
|
||||
}
|
||||
}
|
||||
|
||||
static function authenticate(?string $login = null, ?string $password = null, bool $check_only = false, ?string $service = null): bool {
|
||||
if (!Config::get(Config::SINGLE_USER_MODE)) {
|
||||
$user_id = false;
|
||||
$auth_module = false;
|
||||
$login = mb_strtolower($login);
|
||||
|
||||
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_AUTH_USER,
|
||||
function ($result, $plugin) use (&$user_id, &$auth_module) {
|
||||
if ($result) {
|
||||
$user_id = (int)$result;
|
||||
$auth_module = strtolower(get_class($plugin));
|
||||
return true;
|
||||
}
|
||||
},
|
||||
$login, $password, $service);
|
||||
|
||||
if ($user_id && !$check_only) {
|
||||
|
||||
if (session_status() != PHP_SESSION_ACTIVE)
|
||||
session_start();
|
||||
|
||||
session_regenerate_id(true);
|
||||
|
||||
$user = ORM::for_table('ttrss_users')->find_one($user_id);
|
||||
|
||||
if ($user && $user->access_level != self::ACCESS_LEVEL_DISABLED) {
|
||||
self::set_session_for_user($user_id);
|
||||
$_SESSION["auth_module"] = $auth_module;
|
||||
$_SESSION["name"] = $user->login;
|
||||
$_SESSION["access_level"] = $user->access_level;
|
||||
$_SESSION["pwd_hash"] = $user->pwd_hash;
|
||||
|
||||
$user->last_login = Db::NOW();
|
||||
$user->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($login && $password && !$user_id && !$check_only)
|
||||
Logger::log(E_USER_WARNING, "Failed login attempt for $login (service: $service) from " . UserHelper::get_user_ip());
|
||||
|
||||
return false;
|
||||
|
||||
} else {
|
||||
self::set_session_for_user(1);
|
||||
$_SESSION["name"] = "admin";
|
||||
$_SESSION["access_level"] = self::ACCESS_LEVEL_ADMIN;
|
||||
|
||||
$_SESSION["hide_hello"] = true;
|
||||
$_SESSION["hide_logout"] = true;
|
||||
|
||||
$_SESSION["auth_module"] = false;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
static function set_session_for_user(int $owner_uid): void {
|
||||
$_SESSION["uid"] = $owner_uid;
|
||||
$_SESSION["last_login_update"] = time();
|
||||
$_SESSION["ip_address"] = UserHelper::get_user_ip();
|
||||
|
||||
if (empty($_SESSION["csrf_token"]))
|
||||
$_SESSION["csrf_token"] = bin2hex(get_random_bytes(16));
|
||||
|
||||
if (Config::get_schema_version() >= 120) {
|
||||
$_SESSION["language"] = get_pref(Prefs::USER_LANGUAGE, $owner_uid);
|
||||
}
|
||||
}
|
||||
|
||||
static function load_user_plugins(int $owner_uid, ?PluginHost $pluginhost = null): void {
|
||||
|
||||
if (!$pluginhost) $pluginhost = PluginHost::getInstance();
|
||||
|
||||
if ($owner_uid && Config::get_schema_version() >= 100 && empty($_SESSION["safe_mode"])) {
|
||||
$plugins = get_pref(Prefs::_ENABLED_PLUGINS, $owner_uid);
|
||||
|
||||
$pluginhost->load((string)$plugins, PluginHost::KIND_USER, $owner_uid);
|
||||
|
||||
/*if (get_schema_version() > 100) {
|
||||
$pluginhost->load_data();
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
static function login_sequence(): void {
|
||||
$pdo = Db::pdo();
|
||||
|
||||
if (Config::get(Config::SINGLE_USER_MODE)) {
|
||||
if (session_status() != PHP_SESSION_ACTIVE)
|
||||
session_start();
|
||||
|
||||
self::authenticate("admin", null);
|
||||
startup_gettext();
|
||||
self::load_user_plugins($_SESSION["uid"]);
|
||||
} else {
|
||||
if (!Sessions::validate_session())
|
||||
$_SESSION["uid"] = null;
|
||||
|
||||
if (empty($_SESSION["uid"])) {
|
||||
|
||||
if (Config::get(Config::AUTH_AUTO_LOGIN) && self::authenticate(null, null)) {
|
||||
$_SESSION["ref_schema_version"] = Config::get_schema_version();
|
||||
} else {
|
||||
self::authenticate(null, null, true);
|
||||
}
|
||||
|
||||
if (empty($_SESSION["uid"])) {
|
||||
UserHelper::logout();
|
||||
|
||||
Handler_Public::_render_login_form();
|
||||
exit;
|
||||
}
|
||||
|
||||
} else {
|
||||
/* bump login timestamp */
|
||||
$user = ORM::for_table('ttrss_users')->find_one($_SESSION["uid"]);
|
||||
$user->last_login = Db::NOW();
|
||||
$user->save();
|
||||
|
||||
$_SESSION["last_login_update"] = time();
|
||||
}
|
||||
|
||||
if ($_SESSION["uid"]) {
|
||||
startup_gettext();
|
||||
self::load_user_plugins($_SESSION["uid"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static function print_user_stylesheet(): void {
|
||||
$value = get_pref(Prefs::USER_STYLESHEET);
|
||||
|
||||
if ($value) {
|
||||
print "<style type='text/css' id='user_css_style'>";
|
||||
print str_replace("<br/>", "\n", $value);
|
||||
print "</style>";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static function get_user_ip(): ?string {
|
||||
foreach (["HTTP_X_REAL_IP", "REMOTE_ADDR"] as $hdr) {
|
||||
if (isset($_SERVER[$hdr]))
|
||||
return $_SERVER[$hdr];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static function get_login_by_id(int $id): ?string {
|
||||
$user = ORM::for_table('ttrss_users')
|
||||
->find_one($id);
|
||||
|
||||
if ($user)
|
||||
return $user->login;
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
static function find_user_by_login(string $login): ?int {
|
||||
$user = ORM::for_table('ttrss_users')
|
||||
->where_raw('LOWER(login) = LOWER(?)', [$login])
|
||||
->find_one();
|
||||
|
||||
if ($user)
|
||||
return $user->id;
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
static function logout(): void {
|
||||
if (session_status() === PHP_SESSION_ACTIVE)
|
||||
session_destroy();
|
||||
|
||||
if (isset($_COOKIE[session_name()])) {
|
||||
setcookie(session_name(), '', time()-42000, '/');
|
||||
|
||||
}
|
||||
session_commit();
|
||||
}
|
||||
|
||||
static function get_salt(): string {
|
||||
return substr(bin2hex(get_random_bytes(125)), 0, 250);
|
||||
}
|
||||
|
||||
/** TODO: this should invoke UserHelper::user_modify() */
|
||||
static function reset_password(int $uid, bool $format_output = false, string $new_password = ""): void {
|
||||
|
||||
$user = ORM::for_table('ttrss_users')->find_one($uid);
|
||||
|
||||
if ($user) {
|
||||
|
||||
$login = $user->login;
|
||||
|
||||
$new_salt = self::get_salt();
|
||||
$tmp_user_pwd = $new_password ? $new_password : make_password();
|
||||
|
||||
$pwd_hash = self::hash_password($tmp_user_pwd, $new_salt, self::HASH_ALGOS[0]);
|
||||
|
||||
$user->pwd_hash = $pwd_hash;
|
||||
$user->salt = $new_salt;
|
||||
$user->save();
|
||||
|
||||
$message = T_sprintf("Changed password of user %s to %s", "<strong>$login</strong>", "<strong>$tmp_user_pwd</strong>");
|
||||
} else {
|
||||
$message = __("User not found");
|
||||
}
|
||||
|
||||
if ($format_output)
|
||||
print_notice($message);
|
||||
else
|
||||
print $message;
|
||||
}
|
||||
|
||||
static function check_otp(int $owner_uid, int $otp_check) : bool {
|
||||
$otp = TOTP::create(self::get_otp_secret($owner_uid, true));
|
||||
|
||||
return $otp->now() == $otp_check;
|
||||
}
|
||||
|
||||
static function disable_otp(int $owner_uid) : bool {
|
||||
$user = ORM::for_table('ttrss_users')->find_one($owner_uid);
|
||||
|
||||
if ($user) {
|
||||
$user->otp_enabled = false;
|
||||
|
||||
// force new OTP secret when next enabled
|
||||
if (Config::get_schema_version() >= 143) {
|
||||
$user->otp_secret = null;
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static function enable_otp(int $owner_uid, int $otp_check) : bool {
|
||||
$secret = self::get_otp_secret($owner_uid);
|
||||
|
||||
if ($secret) {
|
||||
$otp = TOTP::create($secret);
|
||||
$user = ORM::for_table('ttrss_users')->find_one($owner_uid);
|
||||
|
||||
if ($otp->now() == $otp_check && $user) {
|
||||
|
||||
$user->otp_enabled = true;
|
||||
$user->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
static function is_otp_enabled(int $owner_uid) : bool {
|
||||
$user = ORM::for_table('ttrss_users')->find_one($owner_uid);
|
||||
|
||||
if ($user) {
|
||||
return $user->otp_enabled;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static function get_otp_secret(int $owner_uid, bool $show_if_enabled = false): ?string {
|
||||
$user = ORM::for_table('ttrss_users')->find_one($owner_uid);
|
||||
|
||||
if ($user) {
|
||||
|
||||
$salt_based_secret = mb_substr(sha1($user->salt), 0, 12);
|
||||
|
||||
if (Config::get_schema_version() >= 143) {
|
||||
$secret = $user->otp_secret;
|
||||
|
||||
if (empty($secret)) {
|
||||
|
||||
/* migrate secret if OTP is already enabled, otherwise make a new one */
|
||||
if ($user->otp_enabled) {
|
||||
$user->otp_secret = $salt_based_secret;
|
||||
} else {
|
||||
$user->otp_secret = bin2hex(get_random_bytes(10));
|
||||
}
|
||||
|
||||
$user->save();
|
||||
|
||||
$secret = $user->otp_secret;
|
||||
}
|
||||
} else {
|
||||
$secret = $salt_based_secret;
|
||||
}
|
||||
|
||||
if (!$user->otp_enabled || $show_if_enabled) {
|
||||
return \ParagonIE\ConstantTime\Base32::encodeUpperUnpadded($secret);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|int $owner_uid if null, checks current user via session-specific auth module, if set works on internal database only
|
||||
* @return bool
|
||||
* @throws PDOException
|
||||
* @throws Exception
|
||||
*/
|
||||
static function is_default_password(?int $owner_uid = null): bool {
|
||||
return self::user_has_password($owner_uid, 'password');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $algo should be one of UserHelper::HASH_ALGO_*
|
||||
*
|
||||
* @return false|string False if the password couldn't be hashed, otherwise the hash string.
|
||||
*/
|
||||
static function hash_password(string $pass, string $salt, string $algo = self::HASH_ALGOS[0]) {
|
||||
$pass_hash = "";
|
||||
|
||||
switch ($algo) {
|
||||
case self::HASH_ALGO_SHA1:
|
||||
$pass_hash = sha1($pass);
|
||||
break;
|
||||
case self::HASH_ALGO_SHA1X:
|
||||
$pass_hash = sha1("$salt:$pass");
|
||||
break;
|
||||
case self::HASH_ALGO_MODE2:
|
||||
case self::HASH_ALGO_SSHA256:
|
||||
$pass_hash = hash('sha256', $salt . $pass);
|
||||
break;
|
||||
case self::HASH_ALGO_SSHA512:
|
||||
$pass_hash = hash('sha512', $salt . $pass);
|
||||
break;
|
||||
default:
|
||||
user_error("hash_password: unknown hash algo: $algo", E_USER_ERROR);
|
||||
}
|
||||
|
||||
if ($pass_hash)
|
||||
return "$algo:$pass_hash";
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $login Login for new user (case-insensitive)
|
||||
* @param string $password Password for new user (may not be blank)
|
||||
* @param UserHelper::ACCESS_LEVEL_* $access_level Access level for new user
|
||||
* @return bool true if user has been created
|
||||
*/
|
||||
static function user_add(string $login, string $password, int $access_level) : bool {
|
||||
$login = clean($login);
|
||||
|
||||
if ($login &&
|
||||
$password &&
|
||||
!self::find_user_by_login($login) &&
|
||||
self::map_access_level((int)$access_level) != self::ACCESS_LEVEL_KEEP_CURRENT) {
|
||||
|
||||
$user = ORM::for_table('ttrss_users')->create();
|
||||
|
||||
$user->salt = self::get_salt();
|
||||
$user->login = mb_strtolower($login);
|
||||
$user->pwd_hash = self::hash_password($password, $user->salt);
|
||||
$user->access_level = $access_level;
|
||||
$user->created = Db::NOW();
|
||||
|
||||
return $user->save();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $uid User ID to modify
|
||||
* @param string $new_password set password to this value if its not blank
|
||||
* @param UserHelper::ACCESS_LEVEL_* $access_level set user access level to this value if it is set (default ACCESS_LEVEL_KEEP_CURRENT)
|
||||
* @return bool true if user record has been saved
|
||||
*
|
||||
* NOTE: $access_level is of mixed type because of intellephense
|
||||
*/
|
||||
static function user_modify(int $uid, string $new_password = '', $access_level = self::ACCESS_LEVEL_KEEP_CURRENT) : bool {
|
||||
$user = ORM::for_table('ttrss_users')->find_one($uid);
|
||||
|
||||
if ($user) {
|
||||
if ($new_password != '') {
|
||||
$new_salt = self::get_salt();
|
||||
$pwd_hash = self::hash_password($new_password, $new_salt, self::HASH_ALGOS[0]);
|
||||
|
||||
$user->pwd_hash = $pwd_hash;
|
||||
$user->salt = $new_salt;
|
||||
}
|
||||
|
||||
if ($access_level != self::ACCESS_LEVEL_KEEP_CURRENT) {
|
||||
$user->access_level = (int)$access_level;
|
||||
}
|
||||
|
||||
return $user->save();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $uid user ID to delete (this won't delete built-in admin user with UID 1)
|
||||
* @return bool true if user has been deleted
|
||||
*/
|
||||
static function user_delete(int $uid) : bool {
|
||||
if ($uid != 1) {
|
||||
|
||||
$user = ORM::for_table('ttrss_users')->find_one($uid);
|
||||
|
||||
if ($user) {
|
||||
// TODO: is it still necessary to split those queries?
|
||||
|
||||
ORM::for_table('ttrss_tags')
|
||||
->where('owner_uid', $uid)
|
||||
->delete_many();
|
||||
|
||||
ORM::for_table('ttrss_feeds')
|
||||
->where('owner_uid', $uid)
|
||||
->delete_many();
|
||||
|
||||
return $user->delete();
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|int $owner_uid if null, checks current user via session-specific auth module, if set works on internal database only
|
||||
* @param string $password password to compare hash against
|
||||
* @return bool
|
||||
*/
|
||||
static function user_has_password(?int $owner_uid, string $password) : bool {
|
||||
if ($owner_uid) {
|
||||
$authenticator = new Auth_Internal();
|
||||
|
||||
return $authenticator->check_password($owner_uid, $password);
|
||||
} else {
|
||||
/** @var Auth_Internal|false $authenticator -- this is only here to make check_password() visible to static analyzer */
|
||||
$authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]);
|
||||
|
||||
if ($authenticator &&
|
||||
method_exists($authenticator, "check_password") &&
|
||||
$authenticator->check_password($_SESSION["uid"], $password)) {
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
888
classes/api.php
888
classes/api.php
@@ -1,888 +0,0 @@
|
||||
<?php
|
||||
class API extends Handler {
|
||||
|
||||
const API_LEVEL = 14;
|
||||
|
||||
const STATUS_OK = 0;
|
||||
const STATUS_ERR = 1;
|
||||
|
||||
private $seq;
|
||||
|
||||
static function param_to_bool($p) {
|
||||
return $p && ($p !== "f" && $p !== "false");
|
||||
}
|
||||
|
||||
function before($method) {
|
||||
if (parent::before($method)) {
|
||||
header("Content-Type: text/json");
|
||||
|
||||
if (!$_SESSION["uid"] && $method != "login" && $method != "isloggedin") {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => 'NOT_LOGGED_IN'));
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($_SESSION["uid"] && $method != "logout" && !get_pref('ENABLE_API_ACCESS')) {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => 'API_DISABLED'));
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->seq = (int) clean($_REQUEST['seq']);
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function wrap($status, $reply) {
|
||||
print json_encode(array("seq" => $this->seq,
|
||||
"status" => $status,
|
||||
"content" => $reply));
|
||||
}
|
||||
|
||||
function getVersion() {
|
||||
$rv = array("version" => VERSION);
|
||||
$this->wrap(self::STATUS_OK, $rv);
|
||||
}
|
||||
|
||||
function getApiLevel() {
|
||||
$rv = array("level" => self::API_LEVEL);
|
||||
$this->wrap(self::STATUS_OK, $rv);
|
||||
}
|
||||
|
||||
function login() {
|
||||
@session_destroy();
|
||||
@session_start();
|
||||
|
||||
$login = clean($_REQUEST["user"]);
|
||||
$password = clean($_REQUEST["password"]);
|
||||
$password_base64 = base64_decode(clean($_REQUEST["password"]));
|
||||
|
||||
if (SINGLE_USER_MODE) $login = "admin";
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT id FROM ttrss_users WHERE login = ?");
|
||||
$sth->execute([$login]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$uid = $row["id"];
|
||||
} else {
|
||||
$uid = 0;
|
||||
}
|
||||
|
||||
if (!$uid) {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => "LOGIN_ERROR"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (get_pref("ENABLE_API_ACCESS", $uid)) {
|
||||
if (authenticate_user($login, $password)) { // try login with normal password
|
||||
$this->wrap(self::STATUS_OK, array("session_id" => session_id(),
|
||||
"api_level" => self::API_LEVEL));
|
||||
} else if (authenticate_user($login, $password_base64)) { // else try with base64_decoded password
|
||||
$this->wrap(self::STATUS_OK, array("session_id" => session_id(),
|
||||
"api_level" => self::API_LEVEL));
|
||||
} else { // else we are not logged in
|
||||
user_error("Failed login attempt for $login from {$_SERVER['REMOTE_ADDR']}", E_USER_WARNING);
|
||||
$this->wrap(self::STATUS_ERR, array("error" => "LOGIN_ERROR"));
|
||||
}
|
||||
} else {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => "API_DISABLED"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function logout() {
|
||||
logout_user();
|
||||
$this->wrap(self::STATUS_OK, array("status" => "OK"));
|
||||
}
|
||||
|
||||
function isLoggedIn() {
|
||||
$this->wrap(self::STATUS_OK, array("status" => $_SESSION["uid"] != ''));
|
||||
}
|
||||
|
||||
function getUnread() {
|
||||
$feed_id = clean($_REQUEST["feed_id"]);
|
||||
$is_cat = clean($_REQUEST["is_cat"]);
|
||||
|
||||
if ($feed_id) {
|
||||
$this->wrap(self::STATUS_OK, array("unread" => getFeedUnread($feed_id, $is_cat)));
|
||||
} else {
|
||||
$this->wrap(self::STATUS_OK, array("unread" => Feeds::getGlobalUnread()));
|
||||
}
|
||||
}
|
||||
|
||||
/* Method added for ttrss-reader for Android */
|
||||
function getCounters() {
|
||||
$this->wrap(self::STATUS_OK, Counters::getAllCounters());
|
||||
}
|
||||
|
||||
function getFeeds() {
|
||||
$cat_id = clean($_REQUEST["cat_id"]);
|
||||
$unread_only = API::param_to_bool(clean($_REQUEST["unread_only"]));
|
||||
$limit = (int) clean($_REQUEST["limit"]);
|
||||
$offset = (int) clean($_REQUEST["offset"]);
|
||||
$include_nested = API::param_to_bool(clean($_REQUEST["include_nested"]));
|
||||
|
||||
$feeds = $this->api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested);
|
||||
|
||||
$this->wrap(self::STATUS_OK, $feeds);
|
||||
}
|
||||
|
||||
function getCategories() {
|
||||
$unread_only = API::param_to_bool(clean($_REQUEST["unread_only"]));
|
||||
$enable_nested = API::param_to_bool(clean($_REQUEST["enable_nested"]));
|
||||
$include_empty = API::param_to_bool(clean($_REQUEST['include_empty']));
|
||||
|
||||
// TODO do not return empty categories, return Uncategorized and standard virtual cats
|
||||
|
||||
if ($enable_nested)
|
||||
$nested_qpart = "parent_cat IS NULL";
|
||||
else
|
||||
$nested_qpart = "true";
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT
|
||||
id, title, order_id, (SELECT COUNT(id) FROM
|
||||
ttrss_feeds WHERE
|
||||
ttrss_feed_categories.id IS NOT NULL AND cat_id = ttrss_feed_categories.id) AS num_feeds,
|
||||
(SELECT COUNT(id) FROM
|
||||
ttrss_feed_categories AS c2 WHERE
|
||||
c2.parent_cat = ttrss_feed_categories.id) AS num_cats
|
||||
FROM ttrss_feed_categories
|
||||
WHERE $nested_qpart AND owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
|
||||
$cats = array();
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
if ($include_empty || $line["num_feeds"] > 0 || $line["num_cats"] > 0) {
|
||||
$unread = getFeedUnread($line["id"], true);
|
||||
|
||||
if ($enable_nested)
|
||||
$unread += Feeds::getCategoryChildrenUnread($line["id"]);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
array_push($cats, array("id" => $line["id"],
|
||||
"title" => $line["title"],
|
||||
"unread" => $unread,
|
||||
"order_id" => (int) $line["order_id"],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array(-2,-1,0) as $cat_id) {
|
||||
if ($include_empty || !$this->isCategoryEmpty($cat_id)) {
|
||||
$unread = getFeedUnread($cat_id, true);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
array_push($cats, array("id" => $cat_id,
|
||||
"title" => Feeds::getCategoryTitle($cat_id),
|
||||
"unread" => $unread));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->wrap(self::STATUS_OK, $cats);
|
||||
}
|
||||
|
||||
function getHeadlines() {
|
||||
$feed_id = clean($_REQUEST["feed_id"]);
|
||||
if ($feed_id !== "") {
|
||||
|
||||
if (is_numeric($feed_id)) $feed_id = (int) $feed_id;
|
||||
|
||||
$limit = (int)clean($_REQUEST["limit"]);
|
||||
|
||||
if (!$limit || $limit >= 200) $limit = 200;
|
||||
|
||||
$offset = (int)clean($_REQUEST["skip"]);
|
||||
$filter = clean($_REQUEST["filter"]);
|
||||
$is_cat = API::param_to_bool(clean($_REQUEST["is_cat"]));
|
||||
$show_excerpt = API::param_to_bool(clean($_REQUEST["show_excerpt"]));
|
||||
$show_content = API::param_to_bool(clean($_REQUEST["show_content"]));
|
||||
/* all_articles, unread, adaptive, marked, updated */
|
||||
$view_mode = clean($_REQUEST["view_mode"]);
|
||||
$include_attachments = API::param_to_bool(clean($_REQUEST["include_attachments"]));
|
||||
$since_id = (int)clean($_REQUEST["since_id"]);
|
||||
$include_nested = API::param_to_bool(clean($_REQUEST["include_nested"]));
|
||||
$sanitize_content = !isset($_REQUEST["sanitize"]) ||
|
||||
API::param_to_bool($_REQUEST["sanitize"]);
|
||||
$force_update = API::param_to_bool(clean($_REQUEST["force_update"]));
|
||||
$has_sandbox = API::param_to_bool(clean($_REQUEST["has_sandbox"]));
|
||||
$excerpt_length = (int)clean($_REQUEST["excerpt_length"]);
|
||||
$check_first_id = (int)clean($_REQUEST["check_first_id"]);
|
||||
$include_header = API::param_to_bool(clean($_REQUEST["include_header"]));
|
||||
|
||||
$_SESSION['hasSandbox'] = $has_sandbox;
|
||||
|
||||
$skip_first_id_check = false;
|
||||
|
||||
$override_order = false;
|
||||
switch (clean($_REQUEST["order_by"])) {
|
||||
case "title":
|
||||
$override_order = "ttrss_entries.title, date_entered, updated";
|
||||
break;
|
||||
case "date_reverse":
|
||||
$override_order = "score DESC, date_entered, updated";
|
||||
$skip_first_id_check = true;
|
||||
break;
|
||||
case "feed_dates":
|
||||
$override_order = "updated DESC";
|
||||
break;
|
||||
}
|
||||
|
||||
/* do not rely on params below */
|
||||
|
||||
$search = clean($_REQUEST["search"]);
|
||||
|
||||
list($headlines, $headlines_header) = $this->api_get_headlines($feed_id, $limit, $offset,
|
||||
$filter, $is_cat, $show_excerpt, $show_content, $view_mode, $override_order,
|
||||
$include_attachments, $since_id, $search,
|
||||
$include_nested, $sanitize_content, $force_update, $excerpt_length, $check_first_id, $skip_first_id_check);
|
||||
|
||||
if ($include_header) {
|
||||
$this->wrap(self::STATUS_OK, array($headlines_header, $headlines));
|
||||
} else {
|
||||
$this->wrap(self::STATUS_OK, $headlines);
|
||||
}
|
||||
} else {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => 'INCORRECT_USAGE'));
|
||||
}
|
||||
}
|
||||
|
||||
function updateArticle() {
|
||||
$article_ids = explode(",", clean($_REQUEST["article_ids"]));
|
||||
$mode = (int) clean($_REQUEST["mode"]);
|
||||
$data = clean($_REQUEST["data"]);
|
||||
$field_raw = (int)clean($_REQUEST["field"]);
|
||||
|
||||
$field = "";
|
||||
$set_to = "";
|
||||
|
||||
switch ($field_raw) {
|
||||
case 0:
|
||||
$field = "marked";
|
||||
$additional_fields = ",last_marked = NOW()";
|
||||
break;
|
||||
case 1:
|
||||
$field = "published";
|
||||
$additional_fields = ",last_published = NOW()";
|
||||
break;
|
||||
case 2:
|
||||
$field = "unread";
|
||||
$additional_fields = ",last_read = NOW()";
|
||||
break;
|
||||
case 3:
|
||||
$field = "note";
|
||||
};
|
||||
|
||||
switch ($mode) {
|
||||
case 1:
|
||||
$set_to = "true";
|
||||
break;
|
||||
case 0:
|
||||
$set_to = "false";
|
||||
break;
|
||||
case 2:
|
||||
$set_to = "NOT $field";
|
||||
break;
|
||||
}
|
||||
|
||||
if ($field == "note") $set_to = $this->pdo->quote($data);
|
||||
|
||||
if ($field && $set_to && count($article_ids) > 0) {
|
||||
|
||||
$article_qmarks = arr_qmarks($article_ids);
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
$field = $set_to $additional_fields
|
||||
WHERE ref_id IN ($article_qmarks) AND owner_uid = ?");
|
||||
$sth->execute(array_merge($article_ids, [$_SESSION['uid']]));
|
||||
|
||||
$num_updated = $sth->rowCount();
|
||||
|
||||
if ($num_updated > 0 && $field == "unread") {
|
||||
$sth = $this->pdo->prepare("SELECT DISTINCT feed_id FROM ttrss_user_entries
|
||||
WHERE ref_id IN ($article_qmarks)");
|
||||
$sth->execute($article_ids);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
CCache::update($line["feed_id"], $_SESSION["uid"]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->wrap(self::STATUS_OK, array("status" => "OK",
|
||||
"updated" => $num_updated));
|
||||
|
||||
} else {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => 'INCORRECT_USAGE'));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function getArticle() {
|
||||
|
||||
$article_ids = explode(",", clean($_REQUEST["article_id"]));
|
||||
$sanitize_content = !isset($_REQUEST["sanitize"]) ||
|
||||
API::param_to_bool($_REQUEST["sanitize"]);
|
||||
|
||||
if ($article_ids) {
|
||||
|
||||
$article_qmarks = arr_qmarks($article_ids);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT id,guid,title,link,content,feed_id,comments,int_id,
|
||||
marked,unread,published,score,note,lang,
|
||||
".SUBSTRING_FOR_DATE."(updated,1,16) as updated,
|
||||
author,(SELECT title FROM ttrss_feeds WHERE id = feed_id) AS feed_title,
|
||||
(SELECT site_url FROM ttrss_feeds WHERE id = feed_id) AS site_url,
|
||||
(SELECT hide_images FROM ttrss_feeds WHERE id = feed_id) AS hide_images
|
||||
FROM ttrss_entries,ttrss_user_entries
|
||||
WHERE id IN ($article_qmarks) AND ref_id = id AND owner_uid = ?");
|
||||
|
||||
$sth->execute(array_merge($article_ids, [$_SESSION['uid']]));
|
||||
|
||||
$articles = array();
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
$attachments = Article::get_article_enclosures($line['id']);
|
||||
|
||||
$article = array(
|
||||
"id" => $line["id"],
|
||||
"guid" => $line["guid"],
|
||||
"title" => $line["title"],
|
||||
"link" => $line["link"],
|
||||
"labels" => Article::get_article_labels($line['id']),
|
||||
"unread" => API::param_to_bool($line["unread"]),
|
||||
"marked" => API::param_to_bool($line["marked"]),
|
||||
"published" => API::param_to_bool($line["published"]),
|
||||
"comments" => $line["comments"],
|
||||
"author" => $line["author"],
|
||||
"updated" => (int) strtotime($line["updated"]),
|
||||
"feed_id" => $line["feed_id"],
|
||||
"attachments" => $attachments,
|
||||
"score" => (int)$line["score"],
|
||||
"feed_title" => $line["feed_title"],
|
||||
"note" => $line["note"],
|
||||
"lang" => $line["lang"]
|
||||
);
|
||||
|
||||
if ($sanitize_content) {
|
||||
$article["content"] = sanitize(
|
||||
$line["content"],
|
||||
API::param_to_bool($line['hide_images']),
|
||||
false, $line["site_url"], false, $line["id"]);
|
||||
} else {
|
||||
$article["content"] = $line["content"];
|
||||
}
|
||||
|
||||
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_RENDER_ARTICLE_API) as $p) {
|
||||
$article = $p->hook_render_article_api(array("article" => $article));
|
||||
}
|
||||
|
||||
$article['content'] = rewrite_cached_urls($article['content']);
|
||||
|
||||
array_push($articles, $article);
|
||||
|
||||
}
|
||||
|
||||
$this->wrap(self::STATUS_OK, $articles);
|
||||
} else {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => 'INCORRECT_USAGE'));
|
||||
}
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
$config = array(
|
||||
"icons_dir" => ICONS_DIR,
|
||||
"icons_url" => ICONS_URL);
|
||||
|
||||
$config["daemon_is_running"] = file_is_locked("update_daemon.lock");
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT COUNT(*) AS cf FROM
|
||||
ttrss_feeds WHERE owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
$row = $sth->fetch();
|
||||
|
||||
$config["num_feeds"] = $row["cf"];
|
||||
|
||||
$this->wrap(self::STATUS_OK, $config);
|
||||
}
|
||||
|
||||
function updateFeed() {
|
||||
$feed_id = (int) clean($_REQUEST["feed_id"]);
|
||||
|
||||
if (!ini_get("open_basedir")) {
|
||||
RSSUtils::update_rss_feed($feed_id);
|
||||
}
|
||||
|
||||
$this->wrap(self::STATUS_OK, array("status" => "OK"));
|
||||
}
|
||||
|
||||
function catchupFeed() {
|
||||
$feed_id = clean($_REQUEST["feed_id"]);
|
||||
$is_cat = clean($_REQUEST["is_cat"]);
|
||||
|
||||
Feeds::catchup_feed($feed_id, $is_cat);
|
||||
|
||||
$this->wrap(self::STATUS_OK, array("status" => "OK"));
|
||||
}
|
||||
|
||||
function getPref() {
|
||||
$pref_name = clean($_REQUEST["pref_name"]);
|
||||
|
||||
$this->wrap(self::STATUS_OK, array("value" => get_pref($pref_name)));
|
||||
}
|
||||
|
||||
function getLabels() {
|
||||
$article_id = (int)clean($_REQUEST['article_id']);
|
||||
|
||||
$rv = array();
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT id, caption, fg_color, bg_color
|
||||
FROM ttrss_labels2
|
||||
WHERE owner_uid = ? ORDER BY caption");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
|
||||
if ($article_id)
|
||||
$article_labels = Article::get_article_labels($article_id);
|
||||
else
|
||||
$article_labels = array();
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
$checked = false;
|
||||
foreach ($article_labels as $al) {
|
||||
if (Labels::feed_to_label_id($al[0]) == $line['id']) {
|
||||
$checked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
array_push($rv, array(
|
||||
"id" => (int)Labels::label_to_feed_id($line['id']),
|
||||
"caption" => $line['caption'],
|
||||
"fg_color" => $line['fg_color'],
|
||||
"bg_color" => $line['bg_color'],
|
||||
"checked" => $checked));
|
||||
}
|
||||
|
||||
$this->wrap(self::STATUS_OK, $rv);
|
||||
}
|
||||
|
||||
function setArticleLabel() {
|
||||
|
||||
$article_ids = explode(",", clean($_REQUEST["article_ids"]));
|
||||
$label_id = (int) clean($_REQUEST['label_id']);
|
||||
$assign = API::param_to_bool(clean($_REQUEST['assign']));
|
||||
|
||||
$label = Labels::find_caption(Labels::feed_to_label_id($label_id), $_SESSION["uid"]);
|
||||
|
||||
$num_updated = 0;
|
||||
|
||||
if ($label) {
|
||||
|
||||
foreach ($article_ids as $id) {
|
||||
|
||||
if ($assign)
|
||||
Labels::add_article($id, $label, $_SESSION["uid"]);
|
||||
else
|
||||
Labels::remove_article($id, $label, $_SESSION["uid"]);
|
||||
|
||||
++$num_updated;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
$this->wrap(self::STATUS_OK, array("status" => "OK",
|
||||
"updated" => $num_updated));
|
||||
|
||||
}
|
||||
|
||||
function index($method) {
|
||||
$plugin = PluginHost::getInstance()->get_api_method(strtolower($method));
|
||||
|
||||
if ($plugin && method_exists($plugin, $method)) {
|
||||
$reply = $plugin->$method();
|
||||
|
||||
$this->wrap($reply[0], $reply[1]);
|
||||
|
||||
} else {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => 'UNKNOWN_METHOD', "method" => $method));
|
||||
}
|
||||
}
|
||||
|
||||
function shareToPublished() {
|
||||
$title = strip_tags(clean($_REQUEST["title"]));
|
||||
$url = strip_tags(clean($_REQUEST["url"]));
|
||||
$content = strip_tags(clean($_REQUEST["content"]));
|
||||
|
||||
if (Article::create_published_article($title, $url, $content, "", $_SESSION["uid"])) {
|
||||
$this->wrap(self::STATUS_OK, array("status" => 'OK'));
|
||||
} else {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => 'Publishing failed'));
|
||||
}
|
||||
}
|
||||
|
||||
static function api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested = false) {
|
||||
|
||||
$feeds = array();
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$limit = (int) $limit;
|
||||
$offset = (int) $offset;
|
||||
$cat_id = (int) $cat_id;
|
||||
|
||||
/* Labels */
|
||||
|
||||
if ($cat_id == -4 || $cat_id == -2) {
|
||||
$counters = Counters::getLabelCounters(true);
|
||||
|
||||
foreach (array_values($counters) as $cv) {
|
||||
|
||||
$unread = $cv["counter"];
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
|
||||
$row = array(
|
||||
"id" => (int) $cv["id"],
|
||||
"title" => $cv["description"],
|
||||
"unread" => $cv["counter"],
|
||||
"cat_id" => -2,
|
||||
);
|
||||
|
||||
array_push($feeds, $row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Virtual feeds */
|
||||
|
||||
if ($cat_id == -4 || $cat_id == -1) {
|
||||
foreach (array(-1, -2, -3, -4, -6, 0) as $i) {
|
||||
$unread = getFeedUnread($i);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
$title = Feeds::getFeedTitle($i);
|
||||
|
||||
$row = array(
|
||||
"id" => $i,
|
||||
"title" => $title,
|
||||
"unread" => $unread,
|
||||
"cat_id" => -1,
|
||||
);
|
||||
array_push($feeds, $row);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/* Child cats */
|
||||
|
||||
if ($include_nested && $cat_id) {
|
||||
$sth = $pdo->prepare("SELECT
|
||||
id, title, order_id FROM ttrss_feed_categories
|
||||
WHERE parent_cat = ? AND owner_uid = ? ORDER BY id, title");
|
||||
|
||||
$sth->execute([$cat_id, $_SESSION['uid']]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$unread = getFeedUnread($line["id"], true) +
|
||||
Feeds::getCategoryChildrenUnread($line["id"]);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
$row = array(
|
||||
"id" => (int) $line["id"],
|
||||
"title" => $line["title"],
|
||||
"unread" => $unread,
|
||||
"is_cat" => true,
|
||||
"order_id" => (int) $line["order_id"]
|
||||
);
|
||||
array_push($feeds, $row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Real feeds */
|
||||
|
||||
if ($limit) {
|
||||
$limit_qpart = "LIMIT $limit OFFSET $offset";
|
||||
} else {
|
||||
$limit_qpart = "";
|
||||
}
|
||||
|
||||
if ($cat_id == -4 || $cat_id == -3) {
|
||||
$sth = $pdo->prepare("SELECT
|
||||
id, feed_url, cat_id, title, order_id, ".
|
||||
SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated
|
||||
FROM ttrss_feeds WHERE owner_uid = ?
|
||||
ORDER BY cat_id, title " . $limit_qpart);
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
|
||||
} else {
|
||||
|
||||
$sth = $pdo->prepare("SELECT
|
||||
id, feed_url, cat_id, title, order_id, ".
|
||||
SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated
|
||||
FROM ttrss_feeds WHERE
|
||||
(cat_id = :cat OR (:cat = 0 AND cat_id IS NULL))
|
||||
AND owner_uid = :uid
|
||||
ORDER BY cat_id, title " . $limit_qpart);
|
||||
$sth->execute([":uid" => $_SESSION['uid'], ":cat" => $cat_id]);
|
||||
}
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
$unread = getFeedUnread($line["id"]);
|
||||
|
||||
$has_icon = Feeds::feedHasIcon($line['id']);
|
||||
|
||||
if ($unread || !$unread_only) {
|
||||
|
||||
$row = array(
|
||||
"feed_url" => $line["feed_url"],
|
||||
"title" => $line["title"],
|
||||
"id" => (int)$line["id"],
|
||||
"unread" => (int)$unread,
|
||||
"has_icon" => $has_icon,
|
||||
"cat_id" => (int)$line["cat_id"],
|
||||
"last_updated" => (int) strtotime($line["last_updated"]),
|
||||
"order_id" => (int) $line["order_id"],
|
||||
);
|
||||
|
||||
array_push($feeds, $row);
|
||||
}
|
||||
}
|
||||
|
||||
return $feeds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
|
||||
*/
|
||||
static function api_get_headlines($feed_id, $limit, $offset,
|
||||
$filter, $is_cat, $show_excerpt, $show_content, $view_mode, $order,
|
||||
$include_attachments, $since_id,
|
||||
$search = "", $include_nested = false, $sanitize_content = true,
|
||||
$force_update = false, $excerpt_length = 100, $check_first_id = false, $skip_first_id_check = false) {
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
if ($force_update && $feed_id > 0 && is_numeric($feed_id)) {
|
||||
// Update the feed if required with some basic flood control
|
||||
|
||||
$sth = $pdo->prepare(
|
||||
"SELECT cache_images,".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated
|
||||
FROM ttrss_feeds WHERE id = ?");
|
||||
$sth->execute([$feed_id]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$last_updated = strtotime($row["last_updated"]);
|
||||
$cache_images = API::param_to_bool($row["cache_images"]);
|
||||
|
||||
if (!$cache_images && time() - $last_updated > 120) {
|
||||
RSSUtils::update_rss_feed($feed_id, true);
|
||||
} else {
|
||||
$sth = $pdo->prepare("UPDATE ttrss_feeds SET last_updated = '1970-01-01', last_update_started = '1970-01-01'
|
||||
WHERE id = ?");
|
||||
$sth->execute([$feed_id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$params = array(
|
||||
"feed" => $feed_id,
|
||||
"limit" => $limit,
|
||||
"view_mode" => $view_mode,
|
||||
"cat_view" => $is_cat,
|
||||
"search" => $search,
|
||||
"override_order" => $order,
|
||||
"offset" => $offset,
|
||||
"since_id" => $since_id,
|
||||
"include_children" => $include_nested,
|
||||
"check_first_id" => $check_first_id,
|
||||
"skip_first_id_check" => $skip_first_id_check
|
||||
);
|
||||
|
||||
$qfh_ret = Feeds::queryFeedHeadlines($params);
|
||||
|
||||
$result = $qfh_ret[0];
|
||||
$feed_title = $qfh_ret[1];
|
||||
$first_id = $qfh_ret[6];
|
||||
|
||||
$headlines = array();
|
||||
|
||||
$headlines_header = array(
|
||||
'id' => $feed_id,
|
||||
'first_id' => $first_id,
|
||||
'is_cat' => $is_cat);
|
||||
|
||||
if (!is_numeric($result)) {
|
||||
while ($line = $result->fetch()) {
|
||||
$line["content_preview"] = truncate_string(strip_tags($line["content"]), $excerpt_length);
|
||||
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_QUERY_HEADLINES) as $p) {
|
||||
$line = $p->hook_query_headlines($line, $excerpt_length, true);
|
||||
}
|
||||
|
||||
$is_updated = ($line["last_read"] == "" &&
|
||||
($line["unread"] != "t" && $line["unread"] != "1"));
|
||||
|
||||
$tags = explode(",", $line["tag_cache"]);
|
||||
|
||||
$label_cache = $line["label_cache"];
|
||||
$labels = false;
|
||||
|
||||
if ($label_cache) {
|
||||
$label_cache = json_decode($label_cache, true);
|
||||
|
||||
if ($label_cache) {
|
||||
if ($label_cache["no-labels"] == 1)
|
||||
$labels = array();
|
||||
else
|
||||
$labels = $label_cache;
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_array($labels)) $labels = Article::get_article_labels($line["id"]);
|
||||
|
||||
$headline_row = array(
|
||||
"id" => (int)$line["id"],
|
||||
"guid" => $line["guid"],
|
||||
"unread" => API::param_to_bool($line["unread"]),
|
||||
"marked" => API::param_to_bool($line["marked"]),
|
||||
"published" => API::param_to_bool($line["published"]),
|
||||
"updated" => (int)strtotime($line["updated"]),
|
||||
"is_updated" => $is_updated,
|
||||
"title" => $line["title"],
|
||||
"link" => $line["link"],
|
||||
"feed_id" => $line["feed_id"] ? $line['feed_id'] : 0,
|
||||
"tags" => $tags,
|
||||
);
|
||||
|
||||
if ($include_attachments)
|
||||
$headline_row['attachments'] = Article::get_article_enclosures(
|
||||
$line['id']);
|
||||
|
||||
if ($show_excerpt)
|
||||
$headline_row["excerpt"] = $line["content_preview"];
|
||||
|
||||
if ($show_content) {
|
||||
|
||||
if ($sanitize_content) {
|
||||
$headline_row["content"] = sanitize(
|
||||
$line["content"],
|
||||
API::param_to_bool($line['hide_images']),
|
||||
false, $line["site_url"], false, $line["id"]);
|
||||
} else {
|
||||
$headline_row["content"] = $line["content"];
|
||||
}
|
||||
}
|
||||
|
||||
// unify label output to ease parsing
|
||||
if ($labels["no-labels"] == 1) $labels = array();
|
||||
|
||||
$headline_row["labels"] = $labels;
|
||||
|
||||
$headline_row["feed_title"] = $line["feed_title"] ? $line["feed_title"] :
|
||||
$feed_title;
|
||||
|
||||
$headline_row["comments_count"] = (int)$line["num_comments"];
|
||||
$headline_row["comments_link"] = $line["comments"];
|
||||
|
||||
$headline_row["always_display_attachments"] = API::param_to_bool($line["always_display_enclosures"]);
|
||||
|
||||
$headline_row["author"] = $line["author"];
|
||||
|
||||
$headline_row["score"] = (int)$line["score"];
|
||||
$headline_row["note"] = $line["note"];
|
||||
$headline_row["lang"] = $line["lang"];
|
||||
|
||||
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_RENDER_ARTICLE_API) as $p) {
|
||||
$headline_row = $p->hook_render_article_api(array("headline" => $headline_row));
|
||||
}
|
||||
|
||||
$headline_row['content'] = rewrite_cached_urls($headline_row['content']);
|
||||
|
||||
array_push($headlines, $headline_row);
|
||||
}
|
||||
} else if (is_numeric($result) && $result == -1) {
|
||||
$headlines_header['first_id_changed'] = true;
|
||||
}
|
||||
|
||||
return array($headlines, $headlines_header);
|
||||
}
|
||||
|
||||
function unsubscribeFeed() {
|
||||
$feed_id = (int) clean($_REQUEST["feed_id"]);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE
|
||||
id = ? AND owner_uid = ?");
|
||||
$sth->execute([$feed_id, $_SESSION['uid']]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
Pref_Feeds::remove_feed($feed_id, $_SESSION["uid"]);
|
||||
$this->wrap(self::STATUS_OK, array("status" => "OK"));
|
||||
} else {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => "FEED_NOT_FOUND"));
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeToFeed() {
|
||||
$feed_url = clean($_REQUEST["feed_url"]);
|
||||
$category_id = (int) clean($_REQUEST["category_id"]);
|
||||
$login = clean($_REQUEST["login"]);
|
||||
$password = clean($_REQUEST["password"]);
|
||||
|
||||
if ($feed_url) {
|
||||
$rc = Feeds::subscribe_to_feed($feed_url, $category_id, $login, $password);
|
||||
|
||||
$this->wrap(self::STATUS_OK, array("status" => $rc));
|
||||
} else {
|
||||
$this->wrap(self::STATUS_ERR, array("error" => 'INCORRECT_USAGE'));
|
||||
}
|
||||
}
|
||||
|
||||
function getFeedTree() {
|
||||
$include_empty = API::param_to_bool(clean($_REQUEST['include_empty']));
|
||||
|
||||
$pf = new Pref_Feeds($_REQUEST);
|
||||
|
||||
$_REQUEST['mode'] = 2;
|
||||
$_REQUEST['force_show_empty'] = $include_empty;
|
||||
|
||||
if ($pf){
|
||||
$data = $pf->makefeedtree();
|
||||
$this->wrap(self::STATUS_OK, array("categories" => $data));
|
||||
} else {
|
||||
$this->wrap(self::STATUS_ERR, array("error" =>
|
||||
'UNABLE_TO_INSTANTIATE_OBJECT'));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// only works for labels or uncategorized for the time being
|
||||
private function isCategoryEmpty($id) {
|
||||
|
||||
if ($id == -2) {
|
||||
$sth = $this->pdo->prepare("SELECT COUNT(id) AS count FROM ttrss_labels2
|
||||
WHERE owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
$row = $sth->fetch();
|
||||
|
||||
return $row["count"] == 0;
|
||||
|
||||
} else if ($id == 0) {
|
||||
$sth = $this->pdo->prepare("SELECT COUNT(id) AS count FROM ttrss_feeds
|
||||
WHERE cat_id IS NULL AND owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
$row = $sth->fetch();
|
||||
|
||||
return $row["count"] == 0;
|
||||
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,824 +0,0 @@
|
||||
<?php
|
||||
class Article extends Handler_Protected {
|
||||
|
||||
function csrf_ignore($method) {
|
||||
$csrf_ignored = array("redirect", "editarticletags");
|
||||
|
||||
return array_search($method, $csrf_ignored) !== false;
|
||||
}
|
||||
|
||||
function redirect() {
|
||||
$id = clean($_REQUEST['id']);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT link FROM ttrss_entries, ttrss_user_entries
|
||||
WHERE id = ? AND id = ref_id AND owner_uid = ?
|
||||
LIMIT 1");
|
||||
$sth->execute([$id, $_SESSION['uid']]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$article_url = $row['link'];
|
||||
$article_url = str_replace("\n", "", $article_url);
|
||||
|
||||
header("Location: $article_url");
|
||||
return;
|
||||
|
||||
} else {
|
||||
print_error(__("Article not found."));
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
function view() {
|
||||
$id = clean($_REQUEST["id"]);
|
||||
$cids = explode(",", clean($_REQUEST["cids"]));
|
||||
$mode = clean($_REQUEST["mode"]);
|
||||
|
||||
// in prefetch mode we only output requested cids, main article
|
||||
// just gets marked as read (it already exists in client cache)
|
||||
|
||||
$articles = array();
|
||||
|
||||
if ($mode == "") {
|
||||
array_push($articles, $this->format_article($id, false));
|
||||
} else if ($mode == "zoom") {
|
||||
array_push($articles, $this->format_article($id, true, true));
|
||||
} else if ($mode == "raw") {
|
||||
if (isset($_REQUEST['html'])) {
|
||||
header("Content-Type: text/html");
|
||||
print '<link rel="stylesheet" type="text/css" href="css/default.css"/>';
|
||||
}
|
||||
|
||||
$article = $this->format_article($id, false, isset($_REQUEST["zoom"]));
|
||||
print $article['content'];
|
||||
return;
|
||||
}
|
||||
|
||||
$this->catchupArticleById($id, 0);
|
||||
|
||||
if (!$_SESSION["bw_limit"]) {
|
||||
foreach ($cids as $cid) {
|
||||
if ($cid) {
|
||||
array_push($articles, $this->format_article($cid, false, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print json_encode($articles);
|
||||
} */
|
||||
|
||||
/*
|
||||
private function catchupArticleById($id, $cmode) {
|
||||
|
||||
if ($cmode == 0) {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
unread = false,last_read = NOW()
|
||||
WHERE ref_id = ? AND owner_uid = ?");
|
||||
} else if ($cmode == 1) {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
unread = true
|
||||
WHERE ref_id = ? AND owner_uid = ?");
|
||||
} else {
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
unread = NOT unread,last_read = NOW()
|
||||
WHERE ref_id = ? AND owner_uid = ?");
|
||||
}
|
||||
|
||||
$sth->execute([$id, $_SESSION['uid']]);
|
||||
|
||||
$feed_id = $this->getArticleFeed($id);
|
||||
CCache::update($feed_id, $_SESSION["uid"]);
|
||||
}
|
||||
*/
|
||||
|
||||
static function create_published_article($title, $url, $content, $labels_str,
|
||||
$owner_uid) {
|
||||
|
||||
$guid = 'SHA1:' . sha1("ttshared:" . $url . $owner_uid); // include owner_uid to prevent global GUID clash
|
||||
|
||||
if (!$content) {
|
||||
$pluginhost = new PluginHost();
|
||||
$pluginhost->load_all(PluginHost::KIND_ALL, $owner_uid);
|
||||
$pluginhost->load_data();
|
||||
|
||||
foreach ($pluginhost->get_hooks(PluginHost::HOOK_GET_FULL_TEXT) as $p) {
|
||||
$extracted_content = $p->hook_get_full_text($url);
|
||||
|
||||
if ($extracted_content) {
|
||||
$content = $extracted_content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$content_hash = sha1($content);
|
||||
|
||||
if ($labels_str != "") {
|
||||
$labels = explode(",", $labels_str);
|
||||
} else {
|
||||
$labels = array();
|
||||
}
|
||||
|
||||
$rc = false;
|
||||
|
||||
if (!$title) $title = $url;
|
||||
if (!$title && !$url) return false;
|
||||
|
||||
if (filter_var($url, FILTER_VALIDATE_URL) === FALSE) return false;
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// only check for our user data here, others might have shared this with different content etc
|
||||
$sth = $pdo->prepare("SELECT id FROM ttrss_entries, ttrss_user_entries WHERE
|
||||
guid = ? AND ref_id = id AND owner_uid = ? LIMIT 1");
|
||||
$sth->execute([$guid, $owner_uid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$ref_id = $row['id'];
|
||||
|
||||
$sth = $pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE
|
||||
ref_id = ? AND owner_uid = ? LIMIT 1");
|
||||
$sth->execute([$ref_id, $owner_uid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$int_id = $row['int_id'];
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_entries SET
|
||||
content = ?, content_hash = ? WHERE id = ?");
|
||||
$sth->execute([$content, $content_hash, $ref_id]);
|
||||
|
||||
if (DB_TYPE == "pgsql"){
|
||||
$sth = $pdo->prepare("UPDATE ttrss_entries
|
||||
SET tsvector_combined = to_tsvector( :ts_content)
|
||||
WHERE id = :id");
|
||||
$params = [
|
||||
":ts_content" => mb_substr(strip_tags($content ), 0, 900000),
|
||||
":id" => $ref_id];
|
||||
$sth->execute($params);
|
||||
}
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET published = true,
|
||||
last_published = NOW() WHERE
|
||||
int_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$int_id, $owner_uid]);
|
||||
|
||||
} else {
|
||||
|
||||
$sth = $pdo->prepare("INSERT INTO ttrss_user_entries
|
||||
(ref_id, uuid, feed_id, orig_feed_id, owner_uid, published, tag_cache, label_cache,
|
||||
last_read, note, unread, last_published)
|
||||
VALUES
|
||||
(?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())");
|
||||
$sth->execute([$ref_id, $owner_uid]);
|
||||
}
|
||||
|
||||
if (count($labels) != 0) {
|
||||
foreach ($labels as $label) {
|
||||
Labels::add_article($ref_id, trim($label), $owner_uid);
|
||||
}
|
||||
}
|
||||
|
||||
$rc = true;
|
||||
|
||||
} else {
|
||||
$sth = $pdo->prepare("INSERT INTO ttrss_entries
|
||||
(title, guid, link, updated, content, content_hash, date_entered, date_updated)
|
||||
VALUES
|
||||
(?, ?, ?, NOW(), ?, ?, NOW(), NOW())");
|
||||
$sth->execute([$title, $guid, $url, $content, $content_hash]);
|
||||
|
||||
$sth = $pdo->prepare("SELECT id FROM ttrss_entries WHERE guid = ?");
|
||||
$sth->execute([$guid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$ref_id = $row["id"];
|
||||
if (DB_TYPE == "pgsql"){
|
||||
$sth = $pdo->prepare("UPDATE ttrss_entries
|
||||
SET tsvector_combined = to_tsvector( :ts_content)
|
||||
WHERE id = :id");
|
||||
$params = [
|
||||
":ts_content" => mb_substr(strip_tags($content ), 0, 900000),
|
||||
":id" => $ref_id];
|
||||
$sth->execute($params);
|
||||
}
|
||||
$sth = $pdo->prepare("INSERT INTO ttrss_user_entries
|
||||
(ref_id, uuid, feed_id, orig_feed_id, owner_uid, published, tag_cache, label_cache,
|
||||
last_read, note, unread, last_published)
|
||||
VALUES
|
||||
(?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())");
|
||||
$sth->execute([$ref_id, $owner_uid]);
|
||||
|
||||
if (count($labels) != 0) {
|
||||
foreach ($labels as $label) {
|
||||
Labels::add_article($ref_id, trim($label), $owner_uid);
|
||||
}
|
||||
}
|
||||
|
||||
$rc = true;
|
||||
}
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
return $rc;
|
||||
}
|
||||
|
||||
function editArticleTags() {
|
||||
|
||||
$param = clean($_REQUEST['param']);
|
||||
|
||||
$tags = Article::get_article_tags($param);
|
||||
|
||||
$tags_str = join(", ", $tags);
|
||||
|
||||
print_hidden("id", "$param");
|
||||
print_hidden("op", "article");
|
||||
print_hidden("method", "setArticleTags");
|
||||
|
||||
print "<header class='horizontal'>" . __("Tags for this article (separated by commas):")."</header>";
|
||||
|
||||
print "<section>";
|
||||
print "<textarea dojoType='dijit.form.SimpleTextarea' rows='4'
|
||||
style='height : 100px; font-size : 12px; width : 98%' id='tags_str'
|
||||
name='tags_str'>$tags_str</textarea>
|
||||
<div class='autocomplete' id='tags_choices'
|
||||
style='display:none'></div>";
|
||||
print "</section>";
|
||||
|
||||
print "<footer>";
|
||||
print "<button dojoType='dijit.form.Button'
|
||||
type='submit' class='alt-primary' onclick=\"dijit.byId('editTagsDlg').execute()\">".__('Save')."</button> ";
|
||||
print "<button dojoType='dijit.form.Button'
|
||||
onclick=\"dijit.byId('editTagsDlg').hide()\">".__('Cancel')."</button>";
|
||||
print "</footer>";
|
||||
|
||||
}
|
||||
|
||||
function setScore() {
|
||||
$ids = explode(",", clean($_REQUEST['id']));
|
||||
$score = (int)clean($_REQUEST['score']);
|
||||
|
||||
$ids_qmarks = arr_qmarks($ids);
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
score = ? WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
|
||||
$sth->execute(array_merge([$score], $ids, [$_SESSION['uid']]));
|
||||
|
||||
print json_encode(["id" => $ids, "score" => (int)$score]);
|
||||
}
|
||||
|
||||
function getScore() {
|
||||
$id = clean($_REQUEST['id']);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT score FROM ttrss_user_entries WHERE ref_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$id, $_SESSION['uid']]);
|
||||
$row = $sth->fetch();
|
||||
|
||||
$score = $row['score'];
|
||||
|
||||
print json_encode(["id" => $id, "score" => (int)$score]);
|
||||
}
|
||||
|
||||
|
||||
function setArticleTags() {
|
||||
|
||||
$id = clean($_REQUEST["id"]);
|
||||
|
||||
$tags_str = clean($_REQUEST["tags_str"]);
|
||||
$tags = array_unique(trim_array(explode(",", $tags_str)));
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE
|
||||
ref_id = ? AND owner_uid = ? LIMIT 1");
|
||||
$sth->execute([$id, $_SESSION['uid']]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
|
||||
$tags_to_cache = array();
|
||||
|
||||
$int_id = $row['int_id'];
|
||||
|
||||
$sth = $this->pdo->prepare("DELETE FROM ttrss_tags WHERE
|
||||
post_int_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$int_id, $_SESSION['uid']]);
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
$tag = Article::sanitize_tag($tag);
|
||||
|
||||
if (!Article::tag_is_valid($tag)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match("/^[0-9]*$/", $tag)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// print "<!-- $id : $int_id : $tag -->";
|
||||
|
||||
if ($tag != '') {
|
||||
$sth = $this->pdo->prepare("INSERT INTO ttrss_tags
|
||||
(post_int_id, owner_uid, tag_name)
|
||||
VALUES (?, ?, ?)");
|
||||
|
||||
$sth->execute([$int_id, $_SESSION['uid'], $tag]);
|
||||
}
|
||||
|
||||
array_push($tags_to_cache, $tag);
|
||||
}
|
||||
|
||||
/* update tag cache */
|
||||
|
||||
sort($tags_to_cache);
|
||||
$tags_str = join(",", $tags_to_cache);
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries
|
||||
SET tag_cache = ? WHERE ref_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$tags_str, $id, $_SESSION['uid']]);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
|
||||
$tags = Article::get_article_tags($id);
|
||||
$tags_str = $this->format_tags_string($tags, $id);
|
||||
$tags_str_full = join(", ", $tags);
|
||||
|
||||
if (!$tags_str_full) $tags_str_full = __("no tags");
|
||||
|
||||
print json_encode(array("id" => (int)$id,
|
||||
"content" => $tags_str, "content_full" => $tags_str_full));
|
||||
}
|
||||
|
||||
|
||||
function completeTags() {
|
||||
$search = clean($_REQUEST["search"]);
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT DISTINCT tag_name FROM ttrss_tags
|
||||
WHERE owner_uid = ? AND
|
||||
tag_name LIKE ? ORDER BY tag_name
|
||||
LIMIT 10");
|
||||
|
||||
$sth->execute([$_SESSION['uid'], "$search%"]);
|
||||
|
||||
print "<ul>";
|
||||
while ($line = $sth->fetch()) {
|
||||
print "<li>" . $line["tag_name"] . "</li>";
|
||||
}
|
||||
print "</ul>";
|
||||
}
|
||||
|
||||
function assigntolabel() {
|
||||
return $this->labelops(true);
|
||||
}
|
||||
|
||||
function removefromlabel() {
|
||||
return $this->labelops(false);
|
||||
}
|
||||
|
||||
private function labelops($assign) {
|
||||
$reply = array();
|
||||
|
||||
$ids = explode(",", clean($_REQUEST["ids"]));
|
||||
$label_id = clean($_REQUEST["lid"]);
|
||||
|
||||
$label = Labels::find_caption($label_id, $_SESSION["uid"]);
|
||||
|
||||
$reply["info-for-headlines"] = array();
|
||||
|
||||
if ($label) {
|
||||
|
||||
foreach ($ids as $id) {
|
||||
|
||||
if ($assign)
|
||||
Labels::add_article($id, $label, $_SESSION["uid"]);
|
||||
else
|
||||
Labels::remove_article($id, $label, $_SESSION["uid"]);
|
||||
|
||||
$labels = $this->get_article_labels($id, $_SESSION["uid"]);
|
||||
|
||||
array_push($reply["info-for-headlines"],
|
||||
array("id" => $id, "labels" => $this->format_article_labels($labels)));
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
$reply["message"] = "UPDATE_COUNTERS";
|
||||
|
||||
print json_encode($reply);
|
||||
}
|
||||
|
||||
function getArticleFeed($id) {
|
||||
$sth = $this->pdo->prepare("SELECT feed_id FROM ttrss_user_entries
|
||||
WHERE ref_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$id, $_SESSION['uid']]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
return $row["feed_id"];
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static function format_article_enclosures($id, $always_display_enclosures,
|
||||
$article_content, $hide_images = false) {
|
||||
|
||||
$result = Article::get_article_enclosures($id);
|
||||
$rv = '';
|
||||
|
||||
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_FORMAT_ENCLOSURES) as $plugin) {
|
||||
$retval = $plugin->hook_format_enclosures($rv, $result, $id, $always_display_enclosures, $article_content, $hide_images);
|
||||
if (is_array($retval)) {
|
||||
$rv = $retval[0];
|
||||
$result = $retval[1];
|
||||
} else {
|
||||
$rv = $retval;
|
||||
}
|
||||
}
|
||||
unset($retval); // Unset to prevent breaking render if there are no HOOK_RENDER_ENCLOSURE hooks below.
|
||||
|
||||
if ($rv === '' && !empty($result)) {
|
||||
$entries_html = array();
|
||||
$entries = array();
|
||||
$entries_inline = array();
|
||||
|
||||
foreach ($result as $line) {
|
||||
|
||||
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_ENCLOSURE_ENTRY) as $plugin) {
|
||||
$line = $plugin->hook_enclosure_entry($line);
|
||||
}
|
||||
|
||||
$url = $line["content_url"];
|
||||
$ctype = $line["content_type"];
|
||||
$title = $line["title"];
|
||||
$width = $line["width"];
|
||||
$height = $line["height"];
|
||||
|
||||
if (!$ctype) $ctype = __("unknown type");
|
||||
|
||||
//$filename = substr($url, strrpos($url, "/")+1);
|
||||
$filename = basename($url);
|
||||
|
||||
$player = format_inline_player($url, $ctype);
|
||||
|
||||
if ($player) array_push($entries_inline, $player);
|
||||
|
||||
# $entry .= " <a target=\"_blank\" href=\"" . htmlspecialchars($url) . "\" rel=\"noopener noreferrer\">" .
|
||||
# $filename . " (" . $ctype . ")" . "</a>";
|
||||
|
||||
$entry = "<div onclick=\"popupOpenUrl('".htmlspecialchars($url)."')\"
|
||||
dojoType=\"dijit.MenuItem\">$filename ($ctype)</div>";
|
||||
|
||||
array_push($entries_html, $entry);
|
||||
|
||||
$entry = array();
|
||||
|
||||
$entry["type"] = $ctype;
|
||||
$entry["filename"] = $filename;
|
||||
$entry["url"] = $url;
|
||||
$entry["title"] = $title;
|
||||
$entry["width"] = $width;
|
||||
$entry["height"] = $height;
|
||||
|
||||
array_push($entries, $entry);
|
||||
}
|
||||
|
||||
if ($_SESSION['uid'] && !get_pref("STRIP_IMAGES") && !$_SESSION["bw_limit"]) {
|
||||
if ($always_display_enclosures ||
|
||||
!preg_match("/<img/i", $article_content)) {
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
|
||||
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_RENDER_ENCLOSURE) as $plugin)
|
||||
$retval = $plugin->hook_render_enclosure($entry, $hide_images);
|
||||
|
||||
|
||||
if ($retval) {
|
||||
$rv .= $retval;
|
||||
} else {
|
||||
|
||||
if (preg_match("/image/", $entry["type"])) {
|
||||
|
||||
if (!$hide_images) {
|
||||
$encsize = '';
|
||||
if ($entry['height'] > 0)
|
||||
$encsize .= ' height="' . intval($entry['height']) . '"';
|
||||
if ($entry['width'] > 0)
|
||||
$encsize .= ' width="' . intval($entry['width']) . '"';
|
||||
$rv .= "<p><img
|
||||
alt=\"".htmlspecialchars($entry["filename"])."\"
|
||||
src=\"" .htmlspecialchars($entry["url"]) . "\"
|
||||
" . $encsize . " /></p>";
|
||||
} else {
|
||||
$rv .= "<p><a target=\"_blank\" rel=\"noopener noreferrer\"
|
||||
href=\"".htmlspecialchars($entry["url"])."\"
|
||||
>" .htmlspecialchars($entry["url"]) . "</a></p>";
|
||||
}
|
||||
|
||||
if ($entry['title']) {
|
||||
$rv.= "<div class=\"enclosure_title\">${entry['title']}</div>";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count($entries_inline) > 0) {
|
||||
//$rv .= "<hr clear='both'/>";
|
||||
foreach ($entries_inline as $entry) { $rv .= $entry; };
|
||||
$rv .= "<br clear='both'/>";
|
||||
}
|
||||
|
||||
$rv .= "<div class=\"attachments\" dojoType=\"fox.form.DropDownButton\">".
|
||||
"<span>" . __('Attachments')."</span>";
|
||||
|
||||
$rv .= "<div dojoType=\"dijit.Menu\" style=\"display: none;\">";
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry["title"])
|
||||
$title = " — " . truncate_string($entry["title"], 30);
|
||||
else
|
||||
$title = "";
|
||||
|
||||
if ($entry["filename"])
|
||||
$filename = truncate_middle(htmlspecialchars($entry["filename"]), 60);
|
||||
else
|
||||
$filename = "";
|
||||
|
||||
$rv .= "<div onclick='popupOpenUrl(\"".htmlspecialchars($entry["url"])."\")'
|
||||
dojoType=\"dijit.MenuItem\">".$filename . $title."</div>";
|
||||
|
||||
};
|
||||
|
||||
$rv .= "</div>";
|
||||
$rv .= "</div>";
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
static function get_article_tags($id, $owner_uid = 0, $tag_cache = false) {
|
||||
|
||||
$a_id = $id;
|
||||
|
||||
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT DISTINCT tag_name,
|
||||
owner_uid as owner FROM ttrss_tags
|
||||
WHERE post_int_id = (SELECT int_id FROM ttrss_user_entries WHERE
|
||||
ref_id = ? AND owner_uid = ? LIMIT 1) ORDER BY tag_name");
|
||||
|
||||
$tags = array();
|
||||
|
||||
/* check cache first */
|
||||
|
||||
if ($tag_cache === false) {
|
||||
$csth = $pdo->prepare("SELECT tag_cache FROM ttrss_user_entries
|
||||
WHERE ref_id = ? AND owner_uid = ?");
|
||||
$csth->execute([$id, $owner_uid]);
|
||||
|
||||
if ($row = $csth->fetch()) $tag_cache = $row["tag_cache"];
|
||||
}
|
||||
|
||||
if ($tag_cache) {
|
||||
$tags = explode(",", $tag_cache);
|
||||
} else {
|
||||
|
||||
/* do it the hard way */
|
||||
|
||||
$sth->execute([$a_id, $owner_uid]);
|
||||
|
||||
while ($tmp_line = $sth->fetch()) {
|
||||
array_push($tags, $tmp_line["tag_name"]);
|
||||
}
|
||||
|
||||
/* update the cache */
|
||||
|
||||
$tags_str = join(",", $tags);
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries
|
||||
SET tag_cache = ? WHERE ref_id = ?
|
||||
AND owner_uid = ?");
|
||||
$sth->execute([$tags_str, $id, $owner_uid]);
|
||||
}
|
||||
|
||||
return $tags;
|
||||
}
|
||||
|
||||
static function format_tags_string($tags) {
|
||||
if (!is_array($tags) || count($tags) == 0) {
|
||||
return __("no tags");
|
||||
} else {
|
||||
$maxtags = min(5, count($tags));
|
||||
$tags_str = "";
|
||||
|
||||
for ($i = 0; $i < $maxtags; $i++) {
|
||||
$tags_str .= "<a class=\"tag\" href=\"#\" onclick=\"Feeds.open({feed:'".$tags[$i]."'})\">" . $tags[$i] . "</a>, ";
|
||||
}
|
||||
|
||||
$tags_str = mb_substr($tags_str, 0, mb_strlen($tags_str)-2);
|
||||
|
||||
if (count($tags) > $maxtags)
|
||||
$tags_str .= ", …";
|
||||
|
||||
return $tags_str;
|
||||
}
|
||||
}
|
||||
|
||||
static function format_article_labels($labels) {
|
||||
|
||||
if (!is_array($labels)) return '';
|
||||
|
||||
$labels_str = "";
|
||||
|
||||
foreach ($labels as $l) {
|
||||
$labels_str .= sprintf("<div class='label'
|
||||
style='color : %s; background-color : %s'>%s</div>",
|
||||
$l[2], $l[3], $l[1]);
|
||||
}
|
||||
|
||||
return $labels_str;
|
||||
|
||||
}
|
||||
|
||||
static function format_article_note($id, $note, $allow_edit = true) {
|
||||
|
||||
if ($allow_edit) {
|
||||
$onclick = "onclick='Plugins.Note.edit($id)'";
|
||||
$note_class = 'editable';
|
||||
} else {
|
||||
$onclick = '';
|
||||
$note_class = '';
|
||||
}
|
||||
|
||||
return "<div class='article-note $note_class'>
|
||||
<i class='material-icons'>note</i>
|
||||
<div $onclick class='body'>$note</div>
|
||||
</div>";
|
||||
|
||||
return $str;
|
||||
}
|
||||
|
||||
static function get_article_enclosures($id) {
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT * FROM ttrss_enclosures
|
||||
WHERE post_id = ? AND content_url != ''");
|
||||
$sth->execute([$id]);
|
||||
|
||||
$rv = array();
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
if (file_exists(CACHE_DIR . '/images/' . sha1($line["content_url"]))) {
|
||||
$line["content_url"] = get_self_url_prefix() . '/public.php?op=cached_url&hash=' . sha1($line["content_url"]);
|
||||
}
|
||||
|
||||
array_push($rv, $line);
|
||||
}
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
static function purge_orphans() {
|
||||
|
||||
// purge orphaned posts in main content table
|
||||
|
||||
if (DB_TYPE == "mysql")
|
||||
$limit_qpart = "LIMIT 5000";
|
||||
else
|
||||
$limit_qpart = "";
|
||||
|
||||
$pdo = Db::pdo();
|
||||
$res = $pdo->query("DELETE FROM ttrss_entries WHERE
|
||||
NOT EXISTS (SELECT ref_id FROM ttrss_user_entries WHERE ref_id = id) $limit_qpart");
|
||||
|
||||
if (Debug::enabled()) {
|
||||
$rows = $res->rowCount();
|
||||
Debug::log("Purged $rows orphaned posts.");
|
||||
}
|
||||
}
|
||||
|
||||
static function catchupArticlesById($ids, $cmode, $owner_uid = false) {
|
||||
|
||||
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$ids_qmarks = arr_qmarks($ids);
|
||||
|
||||
if ($cmode == 1) {
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
unread = true
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
} else if ($cmode == 2) {
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
unread = NOT unread,last_read = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
} else {
|
||||
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
|
||||
unread = false,last_read = NOW()
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
}
|
||||
|
||||
$sth->execute(array_merge($ids, [$owner_uid]));
|
||||
|
||||
/* update ccache */
|
||||
|
||||
$sth = $pdo->prepare("SELECT DISTINCT feed_id FROM ttrss_user_entries
|
||||
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
|
||||
$sth->execute(array_merge($ids, [$owner_uid]));
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
CCache::update($line["feed_id"], $owner_uid);
|
||||
}
|
||||
}
|
||||
|
||||
static function getLastArticleId() {
|
||||
$pdo = DB::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT ref_id AS id FROM ttrss_user_entries
|
||||
WHERE owner_uid = ? ORDER BY ref_id DESC LIMIT 1");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
return $row['id'];
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
static function get_article_labels($id, $owner_uid = false) {
|
||||
$rv = array();
|
||||
|
||||
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT label_cache FROM
|
||||
ttrss_user_entries WHERE ref_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$id, $owner_uid]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$label_cache = $row["label_cache"];
|
||||
|
||||
if ($label_cache) {
|
||||
$tmp = json_decode($label_cache, true);
|
||||
|
||||
if (!$tmp || $tmp["no-labels"] == 1)
|
||||
return $rv;
|
||||
else
|
||||
return $tmp;
|
||||
}
|
||||
}
|
||||
|
||||
$sth = $pdo->prepare("SELECT DISTINCT label_id,caption,fg_color,bg_color
|
||||
FROM ttrss_labels2, ttrss_user_labels2
|
||||
WHERE id = label_id
|
||||
AND article_id = ?
|
||||
AND owner_uid = ?
|
||||
ORDER BY caption");
|
||||
$sth->execute([$id, $owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$rk = array(Labels::label_to_feed_id($line["label_id"]),
|
||||
$line["caption"], $line["fg_color"],
|
||||
$line["bg_color"]);
|
||||
array_push($rv, $rk);
|
||||
}
|
||||
|
||||
if (count($rv) > 0)
|
||||
Labels::update_cache($owner_uid, $id, $rv);
|
||||
else
|
||||
Labels::update_cache($owner_uid, $id, array("no-labels" => 1));
|
||||
|
||||
return $rv;
|
||||
}
|
||||
|
||||
static function sanitize_tag($tag) {
|
||||
$tag = trim($tag);
|
||||
|
||||
$tag = mb_strtolower($tag, 'utf-8');
|
||||
|
||||
$tag = preg_replace('/[,\'\"\+\>\<]/', "", $tag);
|
||||
|
||||
if (DB_TYPE == "mysql") {
|
||||
$tag = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $tag);
|
||||
}
|
||||
|
||||
return $tag;
|
||||
}
|
||||
|
||||
static function tag_is_valid($tag) {
|
||||
if (!$tag || is_numeric($tag) || mb_strlen($tag) > 250)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
class Auth_Base {
|
||||
private $pdo;
|
||||
|
||||
function __construct() {
|
||||
$this->pdo = Db::pdo();
|
||||
}
|
||||
|
||||
/**
|
||||
* @SuppressWarnings(unused)
|
||||
*/
|
||||
function check_password($owner_uid, $password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @SuppressWarnings(unused)
|
||||
*/
|
||||
function authenticate($login, $password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Auto-creates specified user if allowed by system configuration
|
||||
// Can be used instead of find_user_by_login() by external auth modules
|
||||
function auto_create_user($login, $password = false) {
|
||||
if ($login && defined('AUTH_AUTO_CREATE') && AUTH_AUTO_CREATE) {
|
||||
$user_id = $this->find_user_by_login($login);
|
||||
|
||||
if (!$password) $password = make_password();
|
||||
|
||||
if (!$user_id) {
|
||||
$salt = substr(bin2hex(get_random_bytes(125)), 0, 250);
|
||||
$pwd_hash = encrypt_password($password, $salt, true);
|
||||
|
||||
$sth = $this->pdo->prepare("INSERT INTO ttrss_users
|
||||
(login,access_level,last_login,created,pwd_hash,salt)
|
||||
VALUES (?, 0, null, NOW(), ?,?)");
|
||||
$sth->execute([$login, $pwd_hash, $salt]);
|
||||
|
||||
return $this->find_user_by_login($login);
|
||||
|
||||
} else {
|
||||
return $user_id;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->find_user_by_login($login);
|
||||
}
|
||||
|
||||
function find_user_by_login($login) {
|
||||
$sth = $this->pdo->prepare("SELECT id FROM ttrss_users WHERE
|
||||
login = ?");
|
||||
$sth->execute([$login]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
return $row["id"];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
<?php
|
||||
class Backend extends Handler {
|
||||
function loading() {
|
||||
header("Content-type: text/html");
|
||||
print __("Loading, please wait...") . " " .
|
||||
"<img src='images/indicator_tiny.gif'>";
|
||||
}
|
||||
|
||||
function digestTest() {
|
||||
if (isset($_SESSION['uid'])) {
|
||||
header("Content-type: text/html");
|
||||
|
||||
$rv = Digest::prepare_headlines_digest($_SESSION['uid'], 1, 1000);
|
||||
|
||||
print "<h1>HTML</h1>";
|
||||
print $rv[0];
|
||||
print "<h1>Plain text</h1>";
|
||||
print "<pre>".$rv[3]."</pre>";
|
||||
} else {
|
||||
print error_json(6);
|
||||
}
|
||||
}
|
||||
|
||||
private function display_main_help() {
|
||||
$info = get_hotkeys_info();
|
||||
$imap = get_hotkeys_map();
|
||||
$omap = array();
|
||||
|
||||
foreach ($imap[1] as $sequence => $action) {
|
||||
if (!isset($omap[$action])) $omap[$action] = array();
|
||||
|
||||
array_push($omap[$action], $sequence);
|
||||
}
|
||||
|
||||
print "<ul class='panel panel-scrollable hotkeys-help' style='height : 300px'>";
|
||||
|
||||
print "<h2>" . __("Keyboard Shortcuts") . "</h2>";
|
||||
|
||||
foreach ($info as $section => $hotkeys) {
|
||||
|
||||
print "<li><hr></li>";
|
||||
print "<li><h3>" . $section . "</h3></li>";
|
||||
|
||||
foreach ($hotkeys as $action => $description) {
|
||||
|
||||
if (is_array($omap[$action])) {
|
||||
foreach ($omap[$action] as $sequence) {
|
||||
if (strpos($sequence, "|") !== FALSE) {
|
||||
$sequence = substr($sequence,
|
||||
strpos($sequence, "|")+1,
|
||||
strlen($sequence));
|
||||
} else {
|
||||
$keys = explode(" ", $sequence);
|
||||
|
||||
for ($i = 0; $i < count($keys); $i++) {
|
||||
if (strlen($keys[$i]) > 1) {
|
||||
$tmp = '';
|
||||
foreach (str_split($keys[$i]) as $c) {
|
||||
switch ($c) {
|
||||
case '*':
|
||||
$tmp .= __('Shift') . '+';
|
||||
break;
|
||||
case '^':
|
||||
$tmp .= __('Ctrl') . '+';
|
||||
break;
|
||||
default:
|
||||
$tmp .= $c;
|
||||
}
|
||||
}
|
||||
$keys[$i] = $tmp;
|
||||
}
|
||||
}
|
||||
$sequence = join(" ", $keys);
|
||||
}
|
||||
|
||||
print "<li>";
|
||||
print "<div class='hk'><code>$sequence</code></div>";
|
||||
print "<div class='desc'>$description</div>";
|
||||
print "</li>";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print "</ul>";
|
||||
|
||||
|
||||
}
|
||||
|
||||
function help() {
|
||||
$topic = basename(clean($_REQUEST["topic"])); // only one for now
|
||||
|
||||
if ($topic == "main") {
|
||||
$info = get_hotkeys_info();
|
||||
$imap = get_hotkeys_map();
|
||||
$omap = array();
|
||||
|
||||
foreach ($imap[1] as $sequence => $action) {
|
||||
if (!isset($omap[$action])) $omap[$action] = array();
|
||||
|
||||
array_push($omap[$action], $sequence);
|
||||
}
|
||||
|
||||
print "<ul class='panel panel-scrollable hotkeys-help' style='height : 300px'>";
|
||||
|
||||
$cur_section = "";
|
||||
foreach ($info as $section => $hotkeys) {
|
||||
|
||||
if ($cur_section) print "<li> </li>";
|
||||
print "<li><h3>" . $section . "</h3></li>";
|
||||
$cur_section = $section;
|
||||
|
||||
foreach ($hotkeys as $action => $description) {
|
||||
|
||||
if (is_array($omap[$action])) {
|
||||
foreach ($omap[$action] as $sequence) {
|
||||
if (strpos($sequence, "|") !== FALSE) {
|
||||
$sequence = substr($sequence,
|
||||
strpos($sequence, "|")+1,
|
||||
strlen($sequence));
|
||||
} else {
|
||||
$keys = explode(" ", $sequence);
|
||||
|
||||
for ($i = 0; $i < count($keys); $i++) {
|
||||
if (strlen($keys[$i]) > 1) {
|
||||
$tmp = '';
|
||||
foreach (str_split($keys[$i]) as $c) {
|
||||
switch ($c) {
|
||||
case '*':
|
||||
$tmp .= __('Shift') . '+';
|
||||
break;
|
||||
case '^':
|
||||
$tmp .= __('Ctrl') . '+';
|
||||
break;
|
||||
default:
|
||||
$tmp .= $c;
|
||||
}
|
||||
}
|
||||
$keys[$i] = $tmp;
|
||||
}
|
||||
}
|
||||
$sequence = join(" ", $keys);
|
||||
}
|
||||
|
||||
print "<li>";
|
||||
print "<div class='hk'><code>$sequence</code></div>";
|
||||
print "<div class='desc'>$description</div>";
|
||||
print "</li>";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print "</ul>";
|
||||
}
|
||||
|
||||
print "<footer class='text-center'>";
|
||||
print "<button dojoType='dijit.form.Button'
|
||||
onclick=\"return dijit.byId('helpDlg').hide()\">".__('Close this window')."</button>";
|
||||
print "</footer>";
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
<?php
|
||||
class CCache {
|
||||
static function zero_all($owner_uid) {
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_counters_cache SET
|
||||
value = 0 WHERE owner_uid = ?");
|
||||
$sth->execute([$owner_uid]);
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_cat_counters_cache SET
|
||||
value = 0 WHERE owner_uid = ?");
|
||||
$sth->execute([$owner_uid]);
|
||||
}
|
||||
|
||||
static function remove($feed_id, $owner_uid, $is_cat = false) {
|
||||
|
||||
$feed_id = (int) $feed_id;
|
||||
|
||||
if (!$is_cat) {
|
||||
$table = "ttrss_counters_cache";
|
||||
} else {
|
||||
$table = "ttrss_cat_counters_cache";
|
||||
}
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("DELETE FROM $table WHERE
|
||||
feed_id = ? AND owner_uid = ?");
|
||||
$sth->execute([$feed_id, $owner_uid]);
|
||||
|
||||
}
|
||||
|
||||
static function update_all($owner_uid) {
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
if (get_pref('ENABLE_FEED_CATS', $owner_uid)) {
|
||||
|
||||
$sth = $pdo->prepare("SELECT feed_id FROM ttrss_cat_counters_cache
|
||||
WHERE feed_id > 0 AND owner_uid = ?");
|
||||
$sth->execute([$owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
CCache::update($line["feed_id"], $owner_uid, true);
|
||||
}
|
||||
|
||||
/* We have to manually include category 0 */
|
||||
|
||||
CCache::update(0, $owner_uid, true);
|
||||
|
||||
} else {
|
||||
$sth = $pdo->prepare("SELECT feed_id FROM ttrss_counters_cache
|
||||
WHERE feed_id > 0 AND owner_uid = ?");
|
||||
$sth->execute([$owner_uid]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
print CCache::update($line["feed_id"], $owner_uid);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
static function find($feed_id, $owner_uid, $is_cat = false,
|
||||
$no_update = false) {
|
||||
|
||||
// "" (null) is valid and should be cast to 0 (uncategorized)
|
||||
// everything else i.e. tags are not
|
||||
if (!is_numeric($feed_id) && $feed_id)
|
||||
return;
|
||||
|
||||
$feed_id = (int) $feed_id;
|
||||
|
||||
if (!$is_cat) {
|
||||
$table = "ttrss_counters_cache";
|
||||
} else {
|
||||
$table = "ttrss_cat_counters_cache";
|
||||
}
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT value FROM $table
|
||||
WHERE owner_uid = ? AND feed_id = ?
|
||||
LIMIT 1");
|
||||
|
||||
$sth->execute([$owner_uid, $feed_id]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
return $row["value"];
|
||||
} else {
|
||||
if ($no_update) {
|
||||
return -1;
|
||||
} else {
|
||||
return CCache::update($feed_id, $owner_uid, $is_cat);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static function update($feed_id, $owner_uid, $is_cat = false,
|
||||
$update_pcat = true, $pcat_fast = false) {
|
||||
|
||||
// "" (null) is valid and should be cast to 0 (uncategorized)
|
||||
// everything else i.e. tags are not
|
||||
if (!is_numeric($feed_id) && $feed_id)
|
||||
return;
|
||||
|
||||
$feed_id = (int) $feed_id;
|
||||
|
||||
$prev_unread = CCache::find($feed_id, $owner_uid, $is_cat, true);
|
||||
|
||||
/* When updating a label, all we need to do is recalculate feed counters
|
||||
* because labels are not cached */
|
||||
|
||||
if ($feed_id < 0) {
|
||||
CCache::update_all($owner_uid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$is_cat) {
|
||||
$table = "ttrss_counters_cache";
|
||||
} else {
|
||||
$table = "ttrss_cat_counters_cache";
|
||||
}
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
if ($is_cat && $feed_id >= 0) {
|
||||
/* Recalculate counters for child feeds */
|
||||
|
||||
if (!$pcat_fast) {
|
||||
$sth = $pdo->prepare("SELECT id FROM ttrss_feeds
|
||||
WHERE owner_uid = :uid AND
|
||||
(cat_id = :cat OR (:cat = 0 AND cat_id IS NULL))");
|
||||
$sth->execute([":uid" => $owner_uid, ":cat" => $feed_id]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
CCache::update((int)$line["id"], $owner_uid, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
$sth = $pdo->prepare("SELECT SUM(value) AS sv
|
||||
FROM ttrss_counters_cache, ttrss_feeds
|
||||
WHERE ttrss_feeds.id = feed_id AND
|
||||
(cat_id = :cat OR (:cat = 0 AND cat_id IS NULL)) AND
|
||||
ttrss_counters_cache.owner_uid = :uid AND
|
||||
ttrss_feeds.owner_uid = :uid");
|
||||
$sth->execute([":uid" => $owner_uid, ":cat" => $feed_id]);
|
||||
$row = $sth->fetch();
|
||||
|
||||
$unread = (int) $row["sv"];
|
||||
|
||||
} else {
|
||||
$unread = (int) Feeds::getFeedArticles($feed_id, $is_cat, true, $owner_uid);
|
||||
}
|
||||
|
||||
$tr_in_progress = false;
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
} catch (Exception $e) {
|
||||
$tr_in_progress = true;
|
||||
}
|
||||
|
||||
$sth = $pdo->prepare("SELECT feed_id FROM $table
|
||||
WHERE owner_uid = ? AND feed_id = ? LIMIT 1");
|
||||
$sth->execute([$owner_uid, $feed_id]);
|
||||
|
||||
if ($sth->fetch()) {
|
||||
|
||||
$sth = $pdo->prepare("UPDATE $table SET
|
||||
value = ?, updated = NOW() WHERE
|
||||
feed_id = ? AND owner_uid = ?");
|
||||
|
||||
$sth->execute([$unread, $feed_id, $owner_uid]);
|
||||
|
||||
} else {
|
||||
$sth = $pdo->prepare("INSERT INTO $table
|
||||
(feed_id, value, owner_uid, updated)
|
||||
VALUES
|
||||
(?, ?, ?, NOW())");
|
||||
$sth->execute([$feed_id, $unread, $owner_uid]);
|
||||
}
|
||||
|
||||
if (!$tr_in_progress) $pdo->commit();
|
||||
|
||||
if ($feed_id > 0 && $prev_unread != $unread) {
|
||||
|
||||
if (!$is_cat) {
|
||||
|
||||
/* Update parent category */
|
||||
|
||||
if ($update_pcat) {
|
||||
|
||||
$sth = $pdo->prepare("SELECT cat_id FROM ttrss_feeds
|
||||
WHERE owner_uid = ? AND id = ?");
|
||||
$sth->execute([$owner_uid, $feed_id]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
CCache::update((int)$row["cat_id"], $owner_uid, true, true, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ($feed_id < 0) {
|
||||
CCache::update_all($owner_uid);
|
||||
}
|
||||
|
||||
return $unread;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
<?php
|
||||
class Counters {
|
||||
|
||||
static function getAllCounters() {
|
||||
$data = Counters::getGlobalCounters();
|
||||
|
||||
$data = array_merge($data, Counters::getVirtCounters());
|
||||
$data = array_merge($data, Counters::getLabelCounters());
|
||||
$data = array_merge($data, Counters::getFeedCounters());
|
||||
$data = array_merge($data, Counters::getCategoryCounters());
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
static function getCategoryCounters() {
|
||||
$ret_arr = array();
|
||||
|
||||
/* Labels category */
|
||||
|
||||
$cv = array("id" => -2, "kind" => "cat",
|
||||
"counter" => Feeds::getCategoryUnread(-2));
|
||||
|
||||
array_push($ret_arr, $cv);
|
||||
|
||||
$pdo = DB::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT ttrss_feed_categories.id AS cat_id, value AS unread,
|
||||
(SELECT COUNT(id) FROM ttrss_feed_categories AS c2
|
||||
WHERE c2.parent_cat = ttrss_feed_categories.id) AS num_children
|
||||
FROM ttrss_feed_categories, ttrss_cat_counters_cache
|
||||
WHERE ttrss_cat_counters_cache.feed_id = ttrss_feed_categories.id AND
|
||||
ttrss_cat_counters_cache.owner_uid = ttrss_feed_categories.owner_uid AND
|
||||
ttrss_feed_categories.owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
$line["cat_id"] = (int) $line["cat_id"];
|
||||
|
||||
if ($line["num_children"] > 0) {
|
||||
$child_counter = Feeds::getCategoryChildrenUnread($line["cat_id"], $_SESSION["uid"]);
|
||||
} else {
|
||||
$child_counter = 0;
|
||||
}
|
||||
|
||||
$cv = array("id" => $line["cat_id"], "kind" => "cat",
|
||||
"counter" => $line["unread"] + $child_counter);
|
||||
|
||||
array_push($ret_arr, $cv);
|
||||
}
|
||||
|
||||
/* Special case: NULL category doesn't actually exist in the DB */
|
||||
|
||||
$cv = array("id" => 0, "kind" => "cat",
|
||||
"counter" => (int) CCache::find(0, $_SESSION["uid"], true));
|
||||
|
||||
array_push($ret_arr, $cv);
|
||||
|
||||
return $ret_arr;
|
||||
}
|
||||
|
||||
static function getGlobalCounters($global_unread = -1) {
|
||||
$ret_arr = array();
|
||||
|
||||
if ($global_unread == -1) {
|
||||
$global_unread = Feeds::getGlobalUnread();
|
||||
}
|
||||
|
||||
$cv = array("id" => "global-unread",
|
||||
"counter" => (int) $global_unread);
|
||||
|
||||
array_push($ret_arr, $cv);
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT COUNT(id) AS fn FROM
|
||||
ttrss_feeds WHERE owner_uid = ?");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
$row = $sth->fetch();
|
||||
|
||||
$subscribed_feeds = $row["fn"];
|
||||
|
||||
$cv = array("id" => "subscribed-feeds",
|
||||
"counter" => (int) $subscribed_feeds);
|
||||
|
||||
array_push($ret_arr, $cv);
|
||||
|
||||
return $ret_arr;
|
||||
}
|
||||
|
||||
static function getVirtCounters() {
|
||||
|
||||
$ret_arr = array();
|
||||
|
||||
for ($i = 0; $i >= -4; $i--) {
|
||||
|
||||
$count = getFeedUnread($i);
|
||||
|
||||
if ($i == 0 || $i == -1 || $i == -2)
|
||||
$auxctr = Feeds::getFeedArticles($i, false);
|
||||
else
|
||||
$auxctr = 0;
|
||||
|
||||
$cv = array("id" => $i,
|
||||
"counter" => (int) $count,
|
||||
"auxcounter" => (int) $auxctr);
|
||||
|
||||
// if (get_pref('EXTENDED_FEEDLIST'))
|
||||
// $cv["xmsg"] = getFeedArticles($i)." ".__("total");
|
||||
|
||||
array_push($ret_arr, $cv);
|
||||
}
|
||||
|
||||
$feeds = PluginHost::getInstance()->get_feeds(-1);
|
||||
|
||||
if (is_array($feeds)) {
|
||||
foreach ($feeds as $feed) {
|
||||
$cv = array("id" => PluginHost::pfeed_to_feed_id($feed['id']),
|
||||
"counter" => $feed['sender']->get_unread($feed['id']));
|
||||
|
||||
if (method_exists($feed['sender'], 'get_total'))
|
||||
$cv["auxcounter"] = $feed['sender']->get_total($feed['id']);
|
||||
|
||||
array_push($ret_arr, $cv);
|
||||
}
|
||||
}
|
||||
|
||||
return $ret_arr;
|
||||
}
|
||||
|
||||
static function getLabelCounters($descriptions = false) {
|
||||
|
||||
$ret_arr = array();
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT id,caption,SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS unread, COUNT(u1.unread) AS total
|
||||
FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON
|
||||
(ttrss_labels2.id = label_id)
|
||||
LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id
|
||||
WHERE ttrss_labels2.owner_uid = :uid AND u1.owner_uid = :uid
|
||||
GROUP BY ttrss_labels2.id,
|
||||
ttrss_labels2.caption");
|
||||
$sth->execute([":uid" => $_SESSION['uid']]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
$id = Labels::label_to_feed_id($line["id"]);
|
||||
|
||||
$cv = array("id" => $id,
|
||||
"counter" => (int) $line["unread"],
|
||||
"auxcounter" => (int) $line["total"]);
|
||||
|
||||
if ($descriptions)
|
||||
$cv["description"] = $line["caption"];
|
||||
|
||||
array_push($ret_arr, $cv);
|
||||
}
|
||||
|
||||
return $ret_arr;
|
||||
}
|
||||
|
||||
static function getFeedCounters($active_feed = false) {
|
||||
|
||||
$ret_arr = array();
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT ttrss_feeds.id,
|
||||
ttrss_feeds.title,
|
||||
".SUBSTRING_FOR_DATE."(ttrss_feeds.last_updated,1,19) AS last_updated,
|
||||
last_error, value AS count
|
||||
FROM ttrss_feeds, ttrss_counters_cache
|
||||
WHERE ttrss_feeds.owner_uid = ?
|
||||
AND ttrss_counters_cache.owner_uid = ttrss_feeds.owner_uid
|
||||
AND ttrss_counters_cache.feed_id = ttrss_feeds.id");
|
||||
$sth->execute([$_SESSION['uid']]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
|
||||
$id = $line["id"];
|
||||
$count = $line["count"];
|
||||
$last_error = htmlspecialchars($line["last_error"]);
|
||||
|
||||
$last_updated = make_local_datetime($line['last_updated'], false);
|
||||
|
||||
if (Feeds::feedHasIcon($id)) {
|
||||
$has_img = filemtime(Feeds::getIconFile($id));
|
||||
} else {
|
||||
$has_img = false;
|
||||
}
|
||||
|
||||
if (date('Y') - date('Y', strtotime($line['last_updated'])) > 2)
|
||||
$last_updated = '';
|
||||
|
||||
$cv = array("id" => $id,
|
||||
"updated" => $last_updated,
|
||||
"counter" => (int) $count,
|
||||
"has_img" => (int) $has_img);
|
||||
|
||||
if ($last_error)
|
||||
$cv["error"] = $last_error;
|
||||
|
||||
// if (get_pref('EXTENDED_FEEDLIST'))
|
||||
// $cv["xmsg"] = getFeedArticles($id)." ".__("total");
|
||||
|
||||
if ($active_feed && $id == $active_feed)
|
||||
$cv["title"] = truncate_string($line["title"], 30);
|
||||
|
||||
array_push($ret_arr, $cv);
|
||||
|
||||
}
|
||||
|
||||
return $ret_arr;
|
||||
}
|
||||
|
||||
}
|
||||
116
classes/db.php
116
classes/db.php
@@ -1,116 +0,0 @@
|
||||
<?php
|
||||
class Db
|
||||
{
|
||||
|
||||
/* @var Db $instance */
|
||||
private static $instance;
|
||||
|
||||
/* @var IDb $adapter */
|
||||
private $adapter;
|
||||
|
||||
private $link;
|
||||
|
||||
/* @var PDO $pdo */
|
||||
private $pdo;
|
||||
|
||||
private function __clone() {
|
||||
//
|
||||
}
|
||||
|
||||
private function legacy_connect() {
|
||||
|
||||
user_error("Legacy connect requested to " . DB_TYPE, E_USER_NOTICE);
|
||||
|
||||
$er = error_reporting(E_ALL);
|
||||
|
||||
switch (DB_TYPE) {
|
||||
case "mysql":
|
||||
$this->adapter = new Db_Mysqli();
|
||||
break;
|
||||
case "pgsql":
|
||||
$this->adapter = new Db_Pgsql();
|
||||
break;
|
||||
default:
|
||||
die("Unknown DB_TYPE: " . DB_TYPE);
|
||||
}
|
||||
|
||||
if (!$this->adapter) {
|
||||
print("Error initializing database adapter for " . DB_TYPE);
|
||||
exit(100);
|
||||
}
|
||||
|
||||
$this->link = $this->adapter->connect(DB_HOST, DB_USER, DB_PASS, DB_NAME, defined('DB_PORT') ? DB_PORT : "");
|
||||
|
||||
if (!$this->link) {
|
||||
print("Error connecting through adapter: " . $this->adapter->last_error());
|
||||
exit(101);
|
||||
}
|
||||
|
||||
error_reporting($er);
|
||||
}
|
||||
|
||||
// this really shouldn't be used unless a separate PDO connection is needed
|
||||
// normal usage is Db::pdo()->prepare(...) etc
|
||||
public function pdo_connect() {
|
||||
|
||||
$db_port = defined('DB_PORT') && DB_PORT ? ';port=' . DB_PORT : '';
|
||||
$db_host = defined('DB_HOST') && DB_HOST ? ';host=' . DB_HOST : '';
|
||||
|
||||
try {
|
||||
$pdo = new PDO(DB_TYPE . ':dbname=' . DB_NAME . $db_host . $db_port,
|
||||
DB_USER,
|
||||
DB_PASS);
|
||||
} catch (Exception $e) {
|
||||
print "<pre>Exception while creating PDO object:" . $e->getMessage() . "</pre>";
|
||||
exit(101);
|
||||
}
|
||||
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
if (DB_TYPE == "pgsql") {
|
||||
|
||||
$pdo->query("set client_encoding = 'UTF-8'");
|
||||
$pdo->query("set datestyle = 'ISO, european'");
|
||||
$pdo->query("set TIME ZONE 0");
|
||||
$pdo->query("set cpu_tuple_cost = 0.5");
|
||||
|
||||
} else if (DB_TYPE == "mysql") {
|
||||
$pdo->query("SET time_zone = '+0:0'");
|
||||
|
||||
if (defined('MYSQL_CHARSET') && MYSQL_CHARSET) {
|
||||
$pdo->query("SET NAMES " . MYSQL_CHARSET);
|
||||
}
|
||||
}
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
public static function instance() {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public static function get() {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
if (!self::$instance->adapter) {
|
||||
self::$instance->legacy_connect();
|
||||
}
|
||||
|
||||
return self::$instance->adapter;
|
||||
}
|
||||
|
||||
public static function pdo() {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
if (!self::$instance->pdo) {
|
||||
self::$instance->pdo = self::$instance->pdo_connect();
|
||||
}
|
||||
|
||||
return self::$instance->pdo;
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
class Db_Mysqli implements IDb {
|
||||
private $link;
|
||||
private $last_error;
|
||||
|
||||
function connect($host, $user, $pass, $db, $port) {
|
||||
if ($port)
|
||||
$this->link = mysqli_connect($host, $user, $pass, $db, $port);
|
||||
else
|
||||
$this->link = mysqli_connect($host, $user, $pass, $db);
|
||||
|
||||
if ($this->link) {
|
||||
$this->init();
|
||||
|
||||
return $this->link;
|
||||
} else {
|
||||
print("Unable to connect to database (as $user to $host, database $db): " . mysqli_connect_error());
|
||||
exit(102);
|
||||
}
|
||||
}
|
||||
|
||||
function escape_string($s, $strip_tags = true) {
|
||||
if ($strip_tags) $s = strip_tags($s);
|
||||
|
||||
return mysqli_real_escape_string($this->link, $s);
|
||||
}
|
||||
|
||||
function query($query, $die_on_error = true) {
|
||||
$result = @mysqli_query($this->link, $query);
|
||||
if (!$result) {
|
||||
$this->last_error = @mysqli_error($this->link);
|
||||
|
||||
@mysqli_query($this->link, "ROLLBACK");
|
||||
user_error("Query $query failed: " . ($this->link ? $this->last_error : "No connection"),
|
||||
$die_on_error ? E_USER_ERROR : E_USER_WARNING);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
function fetch_assoc($result) {
|
||||
return mysqli_fetch_assoc($result);
|
||||
}
|
||||
|
||||
|
||||
function num_rows($result) {
|
||||
return mysqli_num_rows($result);
|
||||
}
|
||||
|
||||
function fetch_result($result, $row, $param) {
|
||||
if (mysqli_data_seek($result, $row)) {
|
||||
$line = mysqli_fetch_assoc($result);
|
||||
return $line[$param];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
return mysqli_close($this->link);
|
||||
}
|
||||
|
||||
function affected_rows($result) {
|
||||
return mysqli_affected_rows($this->link);
|
||||
}
|
||||
|
||||
function last_error() {
|
||||
return mysqli_error($this->link);
|
||||
}
|
||||
|
||||
function last_query_error() {
|
||||
return $this->last_error;
|
||||
}
|
||||
|
||||
function init() {
|
||||
$this->query("SET time_zone = '+0:0'");
|
||||
|
||||
if (defined('MYSQL_CHARSET') && MYSQL_CHARSET) {
|
||||
mysqli_set_charset($this->link, MYSQL_CHARSET);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
<?php
|
||||
class Db_Pgsql implements IDb {
|
||||
private $link;
|
||||
private $last_error;
|
||||
|
||||
function connect($host, $user, $pass, $db, $port) {
|
||||
$string = "dbname=$db user=$user";
|
||||
|
||||
if ($pass) {
|
||||
$string .= " password=$pass";
|
||||
}
|
||||
|
||||
if ($host) {
|
||||
$string .= " host=$host";
|
||||
}
|
||||
|
||||
if (is_numeric($port) && $port > 0) {
|
||||
$string = "$string port=" . $port;
|
||||
}
|
||||
|
||||
$this->link = pg_connect($string);
|
||||
|
||||
if (!$this->link) {
|
||||
print("Unable to connect to database (as $user to $host, database $db):" . pg_last_error());
|
||||
exit(102);
|
||||
}
|
||||
|
||||
$this->init();
|
||||
|
||||
return $this->link;
|
||||
}
|
||||
|
||||
function escape_string($s, $strip_tags = true) {
|
||||
if ($strip_tags) $s = strip_tags($s);
|
||||
|
||||
return pg_escape_string($s);
|
||||
}
|
||||
|
||||
function query($query, $die_on_error = true) {
|
||||
$result = @pg_query($this->link, $query);
|
||||
|
||||
if (!$result) {
|
||||
$this->last_error = @pg_last_error($this->link);
|
||||
|
||||
@pg_query($this->link, "ROLLBACK");
|
||||
$query = htmlspecialchars($query); // just in case
|
||||
user_error("Query $query failed: " . ($this->link ? $this->last_error : "No connection"),
|
||||
$die_on_error ? E_USER_ERROR : E_USER_WARNING);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
function fetch_assoc($result) {
|
||||
return pg_fetch_assoc($result);
|
||||
}
|
||||
|
||||
|
||||
function num_rows($result) {
|
||||
return pg_num_rows($result);
|
||||
}
|
||||
|
||||
function fetch_result($result, $row, $param) {
|
||||
return pg_fetch_result($result, $row, $param);
|
||||
}
|
||||
|
||||
function close() {
|
||||
return pg_close($this->link);
|
||||
}
|
||||
|
||||
function affected_rows($result) {
|
||||
return pg_affected_rows($result);
|
||||
}
|
||||
|
||||
function last_error() {
|
||||
return pg_last_error($this->link);
|
||||
}
|
||||
|
||||
function last_query_error() {
|
||||
return $this->last_error;
|
||||
}
|
||||
|
||||
function init() {
|
||||
$this->query("set client_encoding = 'UTF-8'");
|
||||
pg_set_client_encoding("UNICODE");
|
||||
$this->query("set datestyle = 'ISO, european'");
|
||||
$this->query("set TIME ZONE 0");
|
||||
$this->query("set cpu_tuple_cost = 0.5");
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
<?php
|
||||
class Db_Prefs {
|
||||
private $pdo;
|
||||
private static $instance;
|
||||
private $cache;
|
||||
|
||||
function __construct() {
|
||||
$this->pdo = Db::pdo();
|
||||
$this->cache = array();
|
||||
|
||||
if ($_SESSION["uid"]) $this->cache();
|
||||
}
|
||||
|
||||
private function __clone() {
|
||||
//
|
||||
}
|
||||
|
||||
public static function get() {
|
||||
if (self::$instance == null)
|
||||
self::$instance = new self();
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
function cache() {
|
||||
$user_id = $_SESSION["uid"];
|
||||
@$profile = $_SESSION["profile"];
|
||||
|
||||
if (!is_numeric($profile) || !$profile || get_schema_version() < 63) $profile = null;
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT
|
||||
value,ttrss_prefs_types.type_name as type_name,ttrss_prefs.pref_name AS pref_name
|
||||
FROM
|
||||
ttrss_user_prefs,ttrss_prefs,ttrss_prefs_types
|
||||
WHERE
|
||||
(profile = :profile OR (:profile IS NULL AND profile IS NULL)) AND
|
||||
ttrss_prefs.pref_name NOT LIKE '_MOBILE%' AND
|
||||
ttrss_prefs_types.id = type_id AND
|
||||
owner_uid = :uid AND
|
||||
ttrss_user_prefs.pref_name = ttrss_prefs.pref_name");
|
||||
|
||||
$sth->execute([":profile" => $profile, ":uid" => $user_id]);
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
if ($user_id == $_SESSION["uid"]) {
|
||||
$pref_name = $line["pref_name"];
|
||||
|
||||
$this->cache[$pref_name]["type"] = $line["type_name"];
|
||||
$this->cache[$pref_name]["value"] = $line["value"];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function read($pref_name, $user_id = false, $die_on_error = false) {
|
||||
|
||||
if (!$user_id) {
|
||||
$user_id = $_SESSION["uid"];
|
||||
@$profile = $_SESSION["profile"];
|
||||
} else {
|
||||
$profile = false;
|
||||
}
|
||||
|
||||
if ($user_id == $_SESSION['uid'] && isset($this->cache[$pref_name])) {
|
||||
$tuple = $this->cache[$pref_name];
|
||||
return $this->convert($tuple["value"], $tuple["type"]);
|
||||
}
|
||||
|
||||
if (!is_numeric($profile) || !$profile || get_schema_version() < 63) $profile = null;
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT
|
||||
value,ttrss_prefs_types.type_name as type_name
|
||||
FROM
|
||||
ttrss_user_prefs,ttrss_prefs,ttrss_prefs_types
|
||||
WHERE
|
||||
(profile = :profile OR (:profile IS NULL AND profile IS NULL)) AND
|
||||
ttrss_user_prefs.pref_name = :pref_name AND
|
||||
ttrss_prefs_types.id = type_id AND
|
||||
owner_uid = :uid AND
|
||||
ttrss_user_prefs.pref_name = ttrss_prefs.pref_name");
|
||||
$sth->execute([":uid" => $user_id, ":profile" => $profile, ":pref_name" => $pref_name]);
|
||||
|
||||
if ($row = $sth->fetch()) {
|
||||
$value = $row["value"];
|
||||
$type_name = $row["type_name"];
|
||||
|
||||
if ($user_id == $_SESSION["uid"]) {
|
||||
$this->cache[$pref_name]["type"] = $type_name;
|
||||
$this->cache[$pref_name]["value"] = $value;
|
||||
}
|
||||
|
||||
return $this->convert($value, $type_name);
|
||||
|
||||
} else if ($die_on_error) {
|
||||
user_error("Fatal error, unknown preferences key: $pref_name (owner: $user_id)", E_USER_ERROR);
|
||||
return null;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function convert($value, $type_name) {
|
||||
if ($type_name == "bool") {
|
||||
return $value == "true";
|
||||
} else if ($type_name == "integer") {
|
||||
return (int)$value;
|
||||
} else {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
function write($pref_name, $value, $user_id = false, $strip_tags = true) {
|
||||
if ($strip_tags) $value = strip_tags($value);
|
||||
|
||||
if (!$user_id) {
|
||||
$user_id = $_SESSION["uid"];
|
||||
@$profile = $_SESSION["profile"];
|
||||
} else {
|
||||
$profile = null;
|
||||
}
|
||||
|
||||
if (!is_numeric($profile) || !$profile || get_schema_version() < 63) $profile = null;
|
||||
|
||||
$type_name = "";
|
||||
$current_value = "";
|
||||
|
||||
if (isset($this->cache[$pref_name])) {
|
||||
$type_name = $this->cache[$pref_name]["type"];
|
||||
$current_value = $this->cache[$pref_name]["value"];
|
||||
}
|
||||
|
||||
if (!$type_name) {
|
||||
$sth = $this->pdo->prepare("SELECT type_name
|
||||
FROM ttrss_prefs,ttrss_prefs_types
|
||||
WHERE pref_name = ? AND type_id = ttrss_prefs_types.id");
|
||||
$sth->execute([$pref_name]);
|
||||
|
||||
if ($row = $sth->fetch())
|
||||
$type_name = $row["type_name"];
|
||||
|
||||
} else if ($current_value == $value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($type_name) {
|
||||
if ($type_name == "bool") {
|
||||
if ($value == "1" || $value == "true") {
|
||||
$value = "true";
|
||||
} else {
|
||||
$value = "false";
|
||||
}
|
||||
} else if ($type_name == "integer") {
|
||||
$value = (int)$value;
|
||||
}
|
||||
|
||||
if ($pref_name == 'USER_TIMEZONE' && $value == '') {
|
||||
$value = 'UTC';
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare("UPDATE ttrss_user_prefs SET
|
||||
value = :value WHERE pref_name = :pref_name
|
||||
AND (profile = :profile OR (:profile IS NULL AND profile IS NULL))
|
||||
AND owner_uid = :uid");
|
||||
|
||||
$sth->execute([":pref_name" => $pref_name, ":value" => $value, ":uid" => $user_id, ":profile" => $profile]);
|
||||
|
||||
if ($user_id == $_SESSION["uid"]) {
|
||||
$this->cache[$pref_name]["type"] = $type_name;
|
||||
$this->cache[$pref_name]["value"] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
<?php
|
||||
class DbUpdater {
|
||||
|
||||
private $pdo;
|
||||
private $db_type;
|
||||
private $need_version;
|
||||
|
||||
function __construct($pdo, $db_type, $need_version) {
|
||||
$this->pdo = Db::pdo(); //$pdo;
|
||||
$this->db_type = $db_type;
|
||||
$this->need_version = (int) $need_version;
|
||||
}
|
||||
|
||||
function getSchemaVersion() {
|
||||
$row = $this->pdo->query("SELECT schema_version FROM ttrss_version")->fetch();
|
||||
return (int) $row['schema_version'];
|
||||
}
|
||||
|
||||
function isUpdateRequired() {
|
||||
return $this->getSchemaVersion() < $this->need_version;
|
||||
}
|
||||
|
||||
function getSchemaLines($version) {
|
||||
$filename = "schema/versions/".$this->db_type."/$version.sql";
|
||||
|
||||
if (file_exists($filename)) {
|
||||
return explode(";", preg_replace("/[\r\n]/", "", file_get_contents($filename)));
|
||||
} else {
|
||||
user_error("DB Updater: schema file for version $version is not found.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function performUpdateTo($version, $html_output = true) {
|
||||
if ($this->getSchemaVersion() == $version - 1) {
|
||||
|
||||
$lines = $this->getSchemaLines($version);
|
||||
|
||||
if (is_array($lines)) {
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (strpos($line, "--") !== 0 && $line) {
|
||||
|
||||
if ($html_output)
|
||||
print "<pre>$line</pre>";
|
||||
else
|
||||
Debug::log("> $line");
|
||||
|
||||
try {
|
||||
$this->pdo->query($line); // PDO returns errors as exceptions now
|
||||
} catch (PDOException $e) {
|
||||
if ($html_output) {
|
||||
print "<div class='text-error'>Error: " . $e->getMessage() . "</div>";
|
||||
} else {
|
||||
Debug::log("Error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
$this->pdo->rollBack();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$db_version = $this->getSchemaVersion();
|
||||
|
||||
if ($db_version == $version) {
|
||||
$this->pdo->commit();
|
||||
return true;
|
||||
} else {
|
||||
$this->pdo->rollBack();
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
class Debug {
|
||||
public static $LOG_DISABLED = -1;
|
||||
public static $LOG_NORMAL = 0;
|
||||
public static $LOG_VERBOSE = 1;
|
||||
public static $LOG_EXTENDED = 2;
|
||||
|
||||
private static $enabled = false;
|
||||
private static $quiet = false;
|
||||
private static $logfile = false;
|
||||
private static $loglevel = 0;
|
||||
|
||||
public static function set_logfile($logfile) {
|
||||
Debug::$logfile = $logfile;
|
||||
}
|
||||
|
||||
public static function enabled() {
|
||||
return Debug::$enabled;
|
||||
}
|
||||
|
||||
public static function set_enabled($enable) {
|
||||
Debug::$enabled = $enable;
|
||||
}
|
||||
|
||||
public static function set_quiet($quiet) {
|
||||
Debug::$quiet = $quiet;
|
||||
}
|
||||
|
||||
public static function set_loglevel($level) {
|
||||
Debug::$loglevel = $level;
|
||||
}
|
||||
|
||||
public static function get_loglevel() {
|
||||
return Debug::$loglevel;
|
||||
}
|
||||
|
||||
public static function log($message, $level = 0) {
|
||||
|
||||
if (!Debug::$enabled || Debug::$loglevel < $level) return false;
|
||||
|
||||
$ts = strftime("%H:%M:%S", time());
|
||||
if (function_exists('posix_getpid')) {
|
||||
$ts = "$ts/" . posix_getpid();
|
||||
}
|
||||
|
||||
if (Debug::$logfile) {
|
||||
$fp = fopen(Debug::$logfile, 'a+');
|
||||
|
||||
if ($fp) {
|
||||
$locked = false;
|
||||
|
||||
if (function_exists("flock")) {
|
||||
$tries = 0;
|
||||
|
||||
// try to lock logfile for writing
|
||||
while ($tries < 5 && !$locked = flock($fp, LOCK_EX | LOCK_NB)) {
|
||||
sleep(1);
|
||||
++$tries;
|
||||
}
|
||||
|
||||
if (!$locked) {
|
||||
fclose($fp);
|
||||
user_error("Unable to lock debugging log file: " . Debug::$logfile, E_USER_WARNING);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fputs($fp, "[$ts] $message\n");
|
||||
|
||||
if (function_exists("flock")) {
|
||||
flock($fp, LOCK_UN);
|
||||
}
|
||||
|
||||
fclose($fp);
|
||||
|
||||
if (Debug::$quiet)
|
||||
return;
|
||||
|
||||
} else {
|
||||
user_error("Unable to open debugging log file: " . Debug::$logfile, E_USER_WARNING);
|
||||
}
|
||||
}
|
||||
|
||||
print "[$ts] $message\n";
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
<?php
|
||||
class Digest
|
||||
{
|
||||
|
||||
/**
|
||||
* Send by mail a digest of last articles.
|
||||
*
|
||||
* @param mixed $link The database connection.
|
||||
* @param integer $limit The maximum number of articles by digest.
|
||||
* @return boolean Return false if digests are not enabled.
|
||||
*/
|
||||
static function send_headlines_digests() {
|
||||
|
||||
$user_limit = 15; // amount of users to process (e.g. emails to send out)
|
||||
$limit = 1000; // maximum amount of headlines to include
|
||||
|
||||
Debug::log("Sending digests, batch of max $user_limit users, headline limit = $limit");
|
||||
|
||||
if (DB_TYPE == "pgsql") {
|
||||
$interval_qpart = "last_digest_sent < NOW() - INTERVAL '1 days'";
|
||||
} else if (DB_TYPE == "mysql") {
|
||||
$interval_qpart = "last_digest_sent < DATE_SUB(NOW(), INTERVAL 1 DAY)";
|
||||
}
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$res = $pdo->query("SELECT id,email FROM ttrss_users
|
||||
WHERE email != '' AND (last_digest_sent IS NULL OR $interval_qpart)");
|
||||
|
||||
while ($line = $res->fetch()) {
|
||||
|
||||
if (@get_pref('DIGEST_ENABLE', $line['id'], false)) {
|
||||
$preferred_ts = strtotime(get_pref('DIGEST_PREFERRED_TIME', $line['id'], '00:00'));
|
||||
|
||||
// try to send digests within 2 hours of preferred time
|
||||
if ($preferred_ts && time() >= $preferred_ts &&
|
||||
time() - $preferred_ts <= 7200
|
||||
) {
|
||||
|
||||
Debug::log("Sending digest for UID:" . $line['id'] . " - " . $line["email"]);
|
||||
|
||||
$do_catchup = get_pref('DIGEST_CATCHUP', $line['id'], false);
|
||||
|
||||
global $tz_offset;
|
||||
|
||||
// reset tz_offset global to prevent tz cache clash between users
|
||||
$tz_offset = -1;
|
||||
|
||||
$tuple = Digest::prepare_headlines_digest($line["id"], 1, $limit);
|
||||
$digest = $tuple[0];
|
||||
$headlines_count = $tuple[1];
|
||||
$affected_ids = $tuple[2];
|
||||
$digest_text = $tuple[3];
|
||||
|
||||
if ($headlines_count > 0) {
|
||||
|
||||
$mailer = new Mailer();
|
||||
|
||||
//$rc = $mail->quickMail($line["email"], $line["login"], DIGEST_SUBJECT, $digest, $digest_text);
|
||||
|
||||
$rc = $mailer->mail(["to_name" => $line["login"],
|
||||
"to_address" => $line["email"],
|
||||
"subject" => DIGEST_SUBJECT,
|
||||
"message" => $digest_text,
|
||||
"message_html" => $digest]);
|
||||
|
||||
//if (!$rc && $debug) Debug::log("ERROR: " . $mailer->lastError());
|
||||
|
||||
Debug::log("RC=$rc");
|
||||
|
||||
if ($rc && $do_catchup) {
|
||||
Debug::log("Marking affected articles as read...");
|
||||
Article::catchupArticlesById($affected_ids, 0, $line["id"]);
|
||||
}
|
||||
} else {
|
||||
Debug::log("No headlines");
|
||||
}
|
||||
|
||||
$sth = $pdo->prepare("UPDATE ttrss_users SET last_digest_sent = NOW()
|
||||
WHERE id = ?");
|
||||
$sth->execute([$line["id"]]);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Debug::log("All done.");
|
||||
|
||||
}
|
||||
|
||||
static function prepare_headlines_digest($user_id, $days = 1, $limit = 1000) {
|
||||
|
||||
require_once "lib/MiniTemplator.class.php";
|
||||
|
||||
$tpl = new MiniTemplator;
|
||||
$tpl_t = new MiniTemplator;
|
||||
|
||||
$tpl->readTemplateFromFile("templates/digest_template_html.txt");
|
||||
$tpl_t->readTemplateFromFile("templates/digest_template.txt");
|
||||
|
||||
$user_tz_string = get_pref('USER_TIMEZONE', $user_id);
|
||||
$local_ts = convert_timestamp(time(), 'UTC', $user_tz_string);
|
||||
|
||||
$tpl->setVariable('CUR_DATE', date('Y/m/d', $local_ts));
|
||||
$tpl->setVariable('CUR_TIME', date('G:i', $local_ts));
|
||||
|
||||
$tpl_t->setVariable('CUR_DATE', date('Y/m/d', $local_ts));
|
||||
$tpl_t->setVariable('CUR_TIME', date('G:i', $local_ts));
|
||||
|
||||
$affected_ids = array();
|
||||
|
||||
$days = (int) $days;
|
||||
|
||||
if (DB_TYPE == "pgsql") {
|
||||
$interval_qpart = "ttrss_entries.date_updated > NOW() - INTERVAL '$days days'";
|
||||
} else if (DB_TYPE == "mysql") {
|
||||
$interval_qpart = "ttrss_entries.date_updated > DATE_SUB(NOW(), INTERVAL $days DAY)";
|
||||
}
|
||||
|
||||
$pdo = Db::pdo();
|
||||
|
||||
$sth = $pdo->prepare("SELECT ttrss_entries.title,
|
||||
ttrss_feeds.title AS feed_title,
|
||||
COALESCE(ttrss_feed_categories.title, '" . __('Uncategorized') . "') AS cat_title,
|
||||
date_updated,
|
||||
ttrss_user_entries.ref_id,
|
||||
link,
|
||||
score,
|
||||
content,
|
||||
" . SUBSTRING_FOR_DATE . "(last_updated,1,19) AS last_updated
|
||||
FROM
|
||||
ttrss_user_entries,ttrss_entries,ttrss_feeds
|
||||
LEFT JOIN
|
||||
ttrss_feed_categories ON (cat_id = ttrss_feed_categories.id)
|
||||
WHERE
|
||||
ref_id = ttrss_entries.id AND feed_id = ttrss_feeds.id
|
||||
AND include_in_digest = true
|
||||
AND $interval_qpart
|
||||
AND ttrss_user_entries.owner_uid = :user_id
|
||||
AND unread = true
|
||||
AND score >= 0
|
||||
ORDER BY ttrss_feed_categories.title, ttrss_feeds.title, score DESC, date_updated DESC
|
||||
LIMIT :limit");
|
||||
$sth->bindParam(':user_id', intval($user_id, 10), PDO::PARAM_INT);
|
||||
$sth->bindParam(':limit', intval($limit, 10), PDO::PARAM_INT);
|
||||
$sth->execute();
|
||||
|
||||
$headlines_count = 0;
|
||||
$headlines = array();
|
||||
|
||||
while ($line = $sth->fetch()) {
|
||||
array_push($headlines, $line);
|
||||
$headlines_count++;
|
||||
}
|
||||
|
||||
for ($i = 0; $i < sizeof($headlines); $i++) {
|
||||
|
||||
$line = $headlines[$i];
|
||||
|
||||
array_push($affected_ids, $line["ref_id"]);
|
||||
|
||||
$updated = make_local_datetime($line['last_updated'], false,
|
||||
$user_id);
|
||||
|
||||
if (get_pref('ENABLE_FEED_CATS', $user_id)) {
|
||||
$line['feed_title'] = $line['cat_title'] . " / " . $line['feed_title'];
|
||||
}
|
||||
|
||||
$article_labels = Article::get_article_labels($line["ref_id"], $user_id);
|
||||
$article_labels_formatted = "";
|
||||
|
||||
if (is_array($article_labels) && count($article_labels) > 0) {
|
||||
$article_labels_formatted = implode(", ", array_map(function($a) {
|
||||
return $a[1];
|
||||
}, $article_labels));
|
||||
}
|
||||
|
||||
$tpl->setVariable('FEED_TITLE', $line["feed_title"]);
|
||||
$tpl->setVariable('ARTICLE_TITLE', $line["title"]);
|
||||
$tpl->setVariable('ARTICLE_LINK', $line["link"]);
|
||||
$tpl->setVariable('ARTICLE_UPDATED', $updated);
|
||||
$tpl->setVariable('ARTICLE_EXCERPT',
|
||||
truncate_string(strip_tags($line["content"]), 300));
|
||||
// $tpl->setVariable('ARTICLE_CONTENT',
|
||||
// strip_tags($article_content));
|
||||
$tpl->setVariable('ARTICLE_LABELS', $article_labels_formatted, true);
|
||||
|
||||
$tpl->addBlock('article');
|
||||
|
||||
$tpl_t->setVariable('FEED_TITLE', $line["feed_title"]);
|
||||
$tpl_t->setVariable('ARTICLE_TITLE', $line["title"]);
|
||||
$tpl_t->setVariable('ARTICLE_LINK', $line["link"]);
|
||||
$tpl_t->setVariable('ARTICLE_UPDATED', $updated);
|
||||
$tpl_t->setVariable('ARTICLE_LABELS', $article_labels_formatted, true);
|
||||
$tpl_t->setVariable('ARTICLE_EXCERPT',
|
||||
truncate_string(strip_tags($line["content"]), 300, "..."), true);
|
||||
|
||||
$tpl_t->addBlock('article');
|
||||
|
||||
if ($headlines[$i]['feed_title'] != $headlines[$i + 1]['feed_title']) {
|
||||
$tpl->addBlock('feed');
|
||||
$tpl_t->addBlock('feed');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$tpl->addBlock('digest');
|
||||
$tpl->generateOutputToString($tmp);
|
||||
|
||||
$tpl_t->addBlock('digest');
|
||||
$tpl_t->generateOutputToString($tmp_t);
|
||||
|
||||
return array($tmp, $headlines_count, $affected_ids, $tmp_t);
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user