Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f2500f16f | ||
|
|
4b855ceddd | ||
|
|
e30d4da455 | ||
|
|
26e6e57949 | ||
|
|
b572966bc4 | ||
|
|
bbfa4f1043 | ||
|
|
9ad4f0b75e | ||
|
|
2093f4ece2 | ||
|
|
7bc7703dc2 | ||
|
|
0ed7452e77 | ||
|
|
366cc19c1b | ||
|
|
c72f30eabb | ||
|
|
235f0dade0 | ||
|
|
9c718cbc5f | ||
|
|
02de92afc7 | ||
|
|
add01455fb | ||
|
|
497f3ce47f | ||
|
|
64188e5713 | ||
|
|
33a6d6da97 | ||
|
|
6410e3be85 | ||
|
|
136d7e8215 | ||
|
|
d3fdd7b0d8 | ||
|
|
329fcc204c | ||
|
|
ba2c7894a8 | ||
|
|
07667c8881 | ||
|
|
68504a70a0 | ||
|
|
32073b3d66 | ||
|
|
d1c1664a38 | ||
|
|
8cbcff98ec | ||
|
|
26f82ad95c | ||
|
|
1b90604b2d | ||
|
|
a03c002df4 | ||
|
|
1ce9415d34 | ||
|
|
cc842901b3 | ||
|
|
23d7e4cdbb | ||
|
|
b48f8fca31 | ||
|
|
a4da93326e | ||
|
|
8bd5c0d163 | ||
|
|
972ab87247 | ||
|
|
d1ea664b41 | ||
|
|
a55fadd53a | ||
|
|
c70a66a1c5 | ||
|
|
8357846818 | ||
|
|
31ea91afb8 | ||
|
|
6616541f65 | ||
|
|
01695822bb | ||
|
|
f0186d1aab | ||
|
|
41a3359085 | ||
|
|
d6cce8df2c | ||
|
|
596861a594 | ||
|
|
385ea800a4 | ||
|
|
27fc339e36 | ||
|
|
61e2173f25 | ||
|
|
13a3fc3d1b | ||
|
|
6a1b515ea9 | ||
|
|
82f27e6b63 | ||
|
|
6ac75031ad | ||
|
|
8b7f1331ee | ||
|
|
099aad8475 | ||
|
|
c1b3fba344 | ||
|
|
d7e9be99f9 | ||
|
|
067a8bcb21 | ||
|
|
f5569a62f1 | ||
|
|
1ab1ed8a82 | ||
|
|
8d95baa70f | ||
|
|
050bce163a | ||
|
|
68b84683d0 | ||
|
|
6fe373d540 | ||
|
|
ba19bcc729 | ||
|
|
f1b65db2c7 | ||
|
|
f5f287ef06 | ||
|
|
f161e6033c | ||
|
|
c86bff8811 | ||
|
|
596b1045bd | ||
|
|
907ca2b590 | ||
|
|
f1b5469b83 | ||
|
|
984fbde540 | ||
|
|
2bdc05dd47 | ||
|
|
d2ea572abf | ||
|
|
8d5c4cd578 | ||
|
|
dd1ba9b518 | ||
|
|
cebe929118 | ||
|
|
3eccf01f1f | ||
|
|
050f1b7941 | ||
|
|
53cc370727 | ||
|
|
363e05d580 | ||
|
|
2ecc80e18c | ||
|
|
0728b28856 | ||
|
|
a6a9270f63 | ||
|
|
372429a9f5 | ||
|
|
4733ad8eb1 | ||
|
|
8e96aa26b4 | ||
|
|
c0e8520d31 | ||
|
|
345f80d90d | ||
|
|
ffcef2fe99 | ||
|
|
e77dc33333 | ||
|
|
5fee19f6c7 | ||
|
|
659c623240 | ||
|
|
695d0ea13a | ||
|
|
0f7ac514fb | ||
|
|
9ad3be2e97 | ||
|
|
2c36fab8aa | ||
|
|
3b7a4b6e52 | ||
|
|
499302c48e | ||
|
|
28fbe76547 | ||
|
|
fabb31275d | ||
|
|
96b75af027 | ||
|
|
dc048ef047 | ||
|
|
d6b2960a4a | ||
|
|
b47b6c4f56 | ||
|
|
75427df7de | ||
|
|
cb98885e1f | ||
|
|
7e0445cc94 | ||
|
|
a9836aaea0 | ||
|
|
27b28dba0d | ||
|
|
86d059bf99 | ||
|
|
76f5ce32ca | ||
|
|
ff9eab998a | ||
|
|
ac3a36eb13 | ||
|
|
4b4c749756 | ||
|
|
4777f4059f | ||
|
|
ad5d555ac8 | ||
|
|
fc7540fc9b | ||
|
|
63a2bdea48 | ||
|
|
41d47fdb3e | ||
|
|
86dae37c5d | ||
|
|
2a97d9379e | ||
|
|
ba0060316c | ||
|
|
41f18a23e5 | ||
|
|
bf26ad4acc | ||
|
|
acc56ad42b | ||
|
|
922e779118 | ||
|
|
f104a6e1b6 | ||
|
|
0aa6052eba | ||
|
|
f25b34d5e2 | ||
|
|
5e5b3e0830 | ||
|
|
f1163139d7 | ||
|
|
5db6e4364a | ||
|
|
3d1a3fd7cf | ||
|
|
6625face50 | ||
|
|
d9764c251d | ||
|
|
243c8356e7 | ||
|
|
2d2f2ab02a | ||
|
|
50785e0577 | ||
|
|
9c1a827685 | ||
|
|
8c658b7eda | ||
|
|
26d8d3daee | ||
|
|
26476db95d | ||
|
|
3c90f6ae61 | ||
|
|
4889afdf0c | ||
|
|
2e777ea752 | ||
|
|
fddf1e21f3 | ||
|
|
d6013b10e6 | ||
|
|
aac6af40cc | ||
|
|
1f96a0e4d3 | ||
|
|
99716984ad | ||
|
|
24e9404a6f | ||
|
|
1f1adba5fd | ||
|
|
b907354746 | ||
|
|
9a08d4a412 | ||
|
|
3a08e6dd55 | ||
|
|
02f782a5ab | ||
|
|
30d68b2d1e | ||
|
|
8974cde582 | ||
|
|
5f3206a4e8 | ||
|
|
40e38d225e | ||
|
|
a73072c8fb | ||
|
|
6a5c5a4c08 | ||
|
|
fa7d5cee87 | ||
|
|
fe4db30e6d | ||
|
|
6936268fd2 | ||
|
|
e55f6ffdf3 | ||
|
|
e182e3c776 | ||
|
|
6309443d1f | ||
|
|
6f81bf00e8 | ||
|
|
86a1f5ee5d | ||
|
|
b3e460eebd | ||
|
|
5121c48c31 | ||
|
|
5f8e16cbe2 | ||
|
|
12e4f283fe | ||
|
|
20ca0896a6 | ||
|
|
3b5f44da46 | ||
|
|
9049d7a616 | ||
|
|
adb77d9a11 | ||
|
|
18cc34535f | ||
|
|
9053668cfe | ||
|
|
b82ed825f7 | ||
|
|
ac492180e8 | ||
|
|
168a6d36c0 | ||
|
|
7cdb006f81 | ||
|
|
5a6ca7a82b | ||
|
|
a980bdbad2 | ||
|
|
0d9c80aec7 | ||
|
|
59fb68ab2d | ||
|
|
ad0ac693de | ||
|
|
0560a893cf | ||
|
|
598d767791 | ||
|
|
0c3053d4ad | ||
|
|
91b9324080 | ||
|
|
ba86db1263 | ||
|
|
21d0339d3d | ||
|
|
9e30e3bd2c | ||
|
|
bf6a08bdcd | ||
|
|
3f3bfaffc6 | ||
|
|
9efcc9aba3 | ||
|
|
2a7966b9e6 | ||
|
|
9ab27da343 | ||
|
|
f4856c86d7 | ||
|
|
53df70bcef | ||
|
|
2d3677d629 | ||
|
|
58b0841360 | ||
|
|
cd7a2a3c73 | ||
|
|
c58e6471ff | ||
|
|
a840dd9081 | ||
|
|
0e4ae6e050 | ||
|
|
a290d906ee | ||
|
|
000d64d78f | ||
|
|
c987513886 | ||
|
|
001b994e73 | ||
|
|
5ed9e9d41b | ||
|
|
5de9544337 | ||
|
|
a320ae03ae | ||
|
|
fe6b119254 | ||
|
|
145b4c474c | ||
|
|
f7bddd757d | ||
|
|
d89c9a1d1c | ||
|
|
0eac76ce6c | ||
|
|
977e5f018b | ||
|
|
38a680ec4c | ||
|
|
7af30852db | ||
|
|
0047fa057f | ||
|
|
7d4ba01409 | ||
|
|
65e3a82d8b | ||
|
|
89dc8ac6dd | ||
|
|
5ed7f27d5e | ||
|
|
4e0c41cf86 | ||
|
|
86b0e8d57c | ||
|
|
d3fe35bdf2 | ||
|
|
39c141c98d | ||
|
|
d46c5c0c24 | ||
|
|
0ba8ecdf82 | ||
|
|
08903ac6f6 | ||
|
|
932c0e8af0 | ||
|
|
d47133c004 | ||
|
|
cfb7361adf | ||
|
|
d4481d145f | ||
|
|
2ef1cfe1ea | ||
|
|
9216d4189e | ||
|
|
286e4c8a43 | ||
|
|
f03fcf3fcf | ||
|
|
f448208475 | ||
|
|
214e183b07 | ||
|
|
338059e507 | ||
|
|
188e3974bc | ||
|
|
b5f34b8777 | ||
|
|
9d8985e616 | ||
|
|
9bae155e79 | ||
|
|
7b47bd2f05 | ||
|
|
74b47d3a8a | ||
|
|
6fd730655a | ||
|
|
c9b1fb4737 | ||
|
|
38414c4628 | ||
|
|
b97b1f9462 | ||
|
|
7395198843 | ||
|
|
988391bc9d | ||
|
|
5c91b8764e | ||
|
|
7c6b44a5bb | ||
|
|
0f4ac6348c | ||
|
|
0ea91bb99f | ||
|
|
3e12ca4074 | ||
|
|
69b110e36b | ||
|
|
9dbc36e426 | ||
|
|
e0980d4700 | ||
|
|
f58073fa57 | ||
|
|
6fe83fb6b8 | ||
|
|
49d2552a78 | ||
|
|
78be64900b | ||
|
|
603decabf6 | ||
|
|
8d42d0714f | ||
|
|
3a9806a368 | ||
|
|
31af19e456 | ||
|
|
8689b11e7c | ||
|
|
ffa0d34ed1 | ||
|
|
dc9411e2b9 | ||
|
|
bd42c75a84 | ||
|
|
6af938051a | ||
|
|
33b96a4263 | ||
|
|
2d033de224 | ||
|
|
c5d56a89cd | ||
|
|
e8bd5dfc6e | ||
|
|
077b2d297c | ||
|
|
45c633776f | ||
|
|
ddba4059b0 | ||
|
|
434f0f5f3d | ||
|
|
aabee8312d | ||
|
|
5d1f021667 | ||
|
|
8ef308e6a2 | ||
|
|
a5d1be80d7 | ||
|
|
dd06500573 | ||
|
|
249dd5d2b2 | ||
|
|
ac610db7e3 | ||
|
|
a748e26166 | ||
|
|
0521c7024c | ||
|
|
c3b39b41ae | ||
|
|
f6772a7253 | ||
|
|
ab0c29cb0c | ||
|
|
f4ef0a7e78 | ||
|
|
78e2be8ea4 | ||
|
|
6ec10c877b | ||
|
|
19121ff0b7 | ||
|
|
250f730d5e | ||
|
|
0bf5a91091 | ||
|
|
cd391a8b0e | ||
|
|
53f02ab4cd | ||
|
|
151913f371 | ||
|
|
e341e25f0f | ||
|
|
4f672cdec5 | ||
|
|
11c8bf6228 | ||
|
|
f43dfd57e0 | ||
|
|
e1d629191e | ||
|
|
27888eb331 | ||
|
|
fcb13e2016 | ||
|
|
5cb7a3f64b | ||
|
|
f621555ef4 | ||
|
|
c8361a3844 | ||
|
|
a1050472c4 | ||
|
|
90e537358d | ||
|
|
8e77acd908 | ||
|
|
f03f99b5b4 | ||
|
|
2387518831 | ||
|
|
473e3328e1 | ||
|
|
832f4fd858 | ||
|
|
be3643ed10 | ||
|
|
c751fbaf37 | ||
|
|
90ec2ff907 | ||
|
|
19a4eada25 | ||
|
|
c027dcd765 | ||
|
|
e1a2374a2d | ||
|
|
6449b7d92b | ||
|
|
c852ea4834 | ||
|
|
8818faf189 | ||
|
|
4d03c20be6 | ||
|
|
b3d28f97ae | ||
|
|
8fc9141f19 | ||
|
|
ca7cbedea4 | ||
|
|
498073c509 | ||
|
|
f817fd38ed | ||
|
|
ad51e087b1 | ||
|
|
ad5aed9787 | ||
|
|
27ee16180b | ||
|
|
f104c3822f | ||
|
|
eb941fc095 | ||
|
|
aa34dc8384 | ||
|
|
c222b8a2ba | ||
|
|
032f88c61e | ||
|
|
3d06a04acd | ||
|
|
2889cd0b40 | ||
|
|
edb45a61fc | ||
|
|
bec8079af9 | ||
|
|
4793a31075 | ||
|
|
d0d4f06bad | ||
|
|
7b1de77dc2 | ||
|
|
73f7e203b6 | ||
|
|
df0d4d27fa | ||
|
|
0c069c1e8f | ||
|
|
24f989109a | ||
|
|
106fb1ff38 | ||
|
|
6d81eaefd1 | ||
|
|
509d29dea6 | ||
|
|
e341227cae | ||
|
|
5abeab4008 | ||
|
|
7f575c4dd7 | ||
|
|
ab3a6f7fe6 | ||
|
|
198759ca1b | ||
|
|
337b4d633e | ||
|
|
65479f8207 | ||
|
|
8a640b98f6 | ||
|
|
9fbf095225 | ||
|
|
3f0ede560c | ||
|
|
5c59e87f94 | ||
|
|
2443b9d0a2 | ||
|
|
fa6c0e0d5e | ||
|
|
987a066a80 | ||
|
|
c0dc64f0a0 | ||
|
|
3d3162de41 | ||
|
|
bb3c86281c | ||
|
|
91441d1160 | ||
|
|
72bdf1b176 | ||
|
|
e90dae2b94 | ||
|
|
a7b44eee1a | ||
|
|
8754add874 | ||
|
|
be46e849a4 | ||
|
|
9f6c766919 | ||
|
|
6f3fa4084c | ||
|
|
de0656d6a9 | ||
|
|
4556133371 | ||
|
|
f92b49d3d0 | ||
|
|
02f671b891 | ||
|
|
5c083e7e0a | ||
|
|
61230646b3 | ||
|
|
5718a699cf | ||
|
|
fd5df39fe2 | ||
|
|
f0ae4b6de8 | ||
|
|
de4ab97c9a | ||
|
|
58409ae05c | ||
|
|
193b107590 | ||
|
|
059d6f0455 | ||
|
|
6f70823df8 | ||
|
|
7945240436 | ||
|
|
29ff5aee94 | ||
|
|
f5ffb208b1 | ||
|
|
f2c13d3126 | ||
|
|
3f7fbb4b31 | ||
|
|
b769fe45bd | ||
|
|
ff9359aa14 | ||
|
|
184ac7efb6 | ||
|
|
79386a8b14 | ||
|
|
712728f554 | ||
|
|
67ab44dd82 | ||
|
|
5668614945 | ||
|
|
57af9e7a66 | ||
|
|
7f0a01a6ca | ||
|
|
878d5c6cf3 | ||
|
|
cc686557ef | ||
|
|
a752f14ecb | ||
|
|
7b0da93f64 | ||
|
|
8729ff81cd | ||
|
|
f7d099d8fc | ||
|
|
94f6ff0aef | ||
|
|
cac1eb2601 | ||
|
|
3216641cbc | ||
|
|
3314ed30a8 | ||
|
|
e5e9d5a10e | ||
|
|
76244fca88 | ||
|
|
a733733e69 | ||
|
|
b47174af4a | ||
|
|
718ad691c0 | ||
|
|
7317d46699 | ||
|
|
a04204b3d5 | ||
|
|
be41016266 | ||
|
|
c6fcb06086 | ||
|
|
484e547c79 | ||
|
|
3a20bfcbb2 | ||
|
|
628dd898b6 | ||
|
|
9df8de511a | ||
|
|
7b28cb9fa9 | ||
|
|
a45c1d1994 | ||
|
|
ad1f95dc74 | ||
|
|
c5e3ea898d | ||
|
|
8d33265487 | ||
|
|
36840ed240 | ||
|
|
b27a09fb3f | ||
|
|
2285bae782 | ||
|
|
0352d047fa | ||
|
|
d7936a6a29 | ||
|
|
0efc04e5cd | ||
|
|
4c2f2aa097 | ||
|
|
4d1d32016e | ||
|
|
deb7f7c209 | ||
|
|
6b9770b8b6 | ||
|
|
07b6454b79 | ||
|
|
5abdaaf793 | ||
|
|
b66c94e002 | ||
|
|
2d32765453 | ||
|
|
2c8556ff2f | ||
|
|
ea644a1a31 | ||
|
|
50b3776d2a | ||
|
|
f093d6a07a | ||
|
|
c15a280181 | ||
|
|
ad54a9450b | ||
|
|
b8050f817c | ||
|
|
70245504f5 | ||
|
|
05a85b6bfd | ||
|
|
83ae758573 | ||
|
|
200733ed03 | ||
|
|
7f081e1d2c | ||
|
|
936ccd825f | ||
|
|
68aba33765 | ||
|
|
5aa24f95fc | ||
|
|
2b15e9291f | ||
|
|
98bfc7f5f5 | ||
|
|
1847184960 | ||
|
|
ef6979e7d6 | ||
|
|
b96f29aa26 | ||
|
|
dfcf728e6f | ||
|
|
5ce89c6c29 | ||
|
|
de6e541604 | ||
|
|
fd4c6ceb18 | ||
|
|
d6167980f4 | ||
|
|
ea15b28bf6 | ||
|
|
4f0002abdb | ||
|
|
be60c7a481 | ||
|
|
c22d371157 | ||
|
|
91deae576b | ||
|
|
3068dfe637 | ||
|
|
c0b392ad4c | ||
|
|
97934e23f9 | ||
|
|
79eab8ccde | ||
|
|
c6e2c38e34 | ||
|
|
93a75d698e | ||
|
|
7b580cec15 | ||
|
|
1cff4eb847 | ||
|
|
b13fa228b6 | ||
|
|
8d8c6ee93d | ||
|
|
9fa23cc537 | ||
|
|
86b2648d66 | ||
|
|
0e550e83fa | ||
|
|
82387729a4 | ||
|
|
4f2db3cdf6 | ||
|
|
6a54ed86c7 | ||
|
|
b856b6d3af | ||
|
|
fcaf568fcc | ||
|
|
3a8750bd58 | ||
|
|
6f10a5a4a3 | ||
|
|
29ac37c8ea | ||
|
|
63ae392927 | ||
|
|
4d011aa5f3 | ||
|
|
75196d185d | ||
|
|
93b29e934b | ||
|
|
f9eb39db03 | ||
|
|
df547cc815 | ||
|
|
ef1dd8162b | ||
|
|
717c4aa638 | ||
|
|
cbdf5f1e47 | ||
|
|
ba5c74a069 | ||
|
|
fe716b0a71 | ||
|
|
992b843cf5 | ||
|
|
d1ef3ff247 | ||
|
|
63220f330b | ||
|
|
57fc382956 | ||
|
|
c318e2068e | ||
|
|
470956bbc8 | ||
|
|
52fbac904e | ||
|
|
6d4be02dab | ||
|
|
19ba43b843 | ||
|
|
c055749dae | ||
|
|
4a2ae04571 | ||
|
|
eb7b34b6b2 | ||
|
|
5bdd1a5c72 | ||
|
|
9838aaac70 | ||
|
|
048712bcee | ||
|
|
cb40bae89b | ||
|
|
d8058c6576 | ||
|
|
123cd53c09 | ||
|
|
317d932b1f | ||
|
|
58d8fe0f1c | ||
|
|
3c6df9e17e | ||
|
|
b3beca1559 | ||
|
|
1ee571a7f5 | ||
|
|
9f98b46351 | ||
|
|
928be7d688 | ||
|
|
5782495b8e | ||
|
|
3ad7ba2c09 | ||
|
|
4e0a4e52bd | ||
|
|
e841bc27fb | ||
|
|
d361564815 | ||
|
|
d3bd966df8 | ||
|
|
82f8f6c93e | ||
|
|
f4b6d73a4a | ||
|
|
488518cd72 | ||
|
|
692a9bdc01 | ||
|
|
d1405ac479 | ||
|
|
01359a4fa9 | ||
|
|
4bae705f5c | ||
|
|
ef089df96c | ||
|
|
b066c929fa | ||
|
|
4dd04d95ee | ||
|
|
f4e5ee06ab | ||
|
|
b748771fed | ||
|
|
a4f2542924 | ||
|
|
befb079207 | ||
|
|
627ae58ecc | ||
|
|
d5246c9689 | ||
|
|
abd628e1dc | ||
|
|
52084e0510 | ||
|
|
df4b0c80ac | ||
|
|
c9841b81e4 | ||
|
|
dfed7e16ba | ||
|
|
6cf2849e70 | ||
|
|
c9ce2bbf8c | ||
|
|
adb443571f | ||
|
|
8cbdf36bdd | ||
|
|
65fca4c7e5 | ||
|
|
41e07c2238 | ||
|
|
2f03f11c7e | ||
|
|
7153af90d0 | ||
|
|
961b8aacd9 | ||
|
|
8ef87f400a | ||
|
|
0b13e8a8d2 | ||
|
|
5fabbe33d9 | ||
|
|
70833feb50 | ||
|
|
3a62e9e93b | ||
|
|
8a3f434bc1 | ||
|
|
aa14069f8b | ||
|
|
ddbceb5375 | ||
|
|
e72f594134 | ||
|
|
47fee90c60 | ||
|
|
5ed4c7100c | ||
|
|
e762ea7b91 | ||
|
|
6f35928396 | ||
|
|
d624526473 | ||
|
|
2238ec88f0 | ||
|
|
e7950fcb22 | ||
|
|
4254169636 | ||
|
|
682758a58e | ||
|
|
4325a3c249 | ||
|
|
78c582493c | ||
|
|
ea4636afbb | ||
|
|
345ee88333 | ||
|
|
4bea9f1d37 | ||
|
|
f922a51a28 | ||
|
|
63218ac51c | ||
|
|
553bcf2fd5 | ||
|
|
71be684739 | ||
|
|
bd6ecedacb | ||
|
|
a2ecfa65d0 | ||
|
|
ec62b1a572 | ||
|
|
23b5a0c6bb | ||
|
|
719b07ec6c | ||
|
|
0a4c8dbe45 | ||
|
|
982226e48b | ||
|
|
313e34a0b5 | ||
|
|
4a43092fb5 | ||
|
|
b184bb5b7b | ||
|
|
d6019e55b0 | ||
|
|
5ec871fd37 | ||
|
|
111b7a86eb | ||
|
|
a38fd1fdc3 | ||
|
|
807160dcf6 | ||
|
|
a50adc823b | ||
|
|
c4832ff47a | ||
|
|
f4f4764644 | ||
|
|
0496c6bb0e | ||
|
|
fb23cd6cf7 | ||
|
|
1f755e07a4 | ||
|
|
008788ad89 | ||
|
|
3628467367 | ||
|
|
2e43f09636 | ||
|
|
f515676a72 | ||
|
|
a89a06a583 | ||
|
|
656ab84381 | ||
|
|
807249db5d | ||
|
|
4ab2ea9246 | ||
|
|
6d0b18f688 | ||
|
|
068eaea86a | ||
|
|
a1cf289845 | ||
|
|
30394b0760 | ||
|
|
4d075f4f7a | ||
|
|
8572f809cc | ||
|
|
f31146a266 | ||
|
|
49b70b7b04 | ||
|
|
8161182e7d | ||
|
|
2fae0c29d2 | ||
|
|
77825ce947 | ||
|
|
e1e8da4b2a | ||
|
|
be2f6d0a20 | ||
|
|
d40e354265 | ||
|
|
1048355b7b | ||
|
|
5296074dae | ||
|
|
89a4677391 | ||
|
|
849c133082 | ||
|
|
ad25f83954 | ||
|
|
6d525e4820 | ||
|
|
12b0fbe75d | ||
|
|
0dd3cd93ee | ||
|
|
758c6bb1b2 | ||
|
|
477ee60068 | ||
|
|
8b53c68cc3 | ||
|
|
fa22e32757 | ||
|
|
aff79a6fa9 | ||
|
|
7f7973a78f | ||
|
|
fa2433ee0d | ||
|
|
0808599442 | ||
|
|
3961226e3c | ||
|
|
277ebf17f3 | ||
|
|
3e0f233c5a | ||
|
|
eb6ef87d42 | ||
|
|
df92500bb4 | ||
|
|
e429936831 | ||
|
|
43935bbd38 | ||
|
|
f332ab194d | ||
|
|
42cc226b16 | ||
|
|
bb5907d21f | ||
|
|
e77ffef4f4 | ||
|
|
ecc480f51c | ||
|
|
27e0fa0701 | ||
|
|
1052898589 | ||
|
|
b72f4d57ea | ||
|
|
6474821271 | ||
|
|
b53d4a455b | ||
|
|
3bd1bb1c89 | ||
|
|
a144dd00e3 | ||
|
|
9518d1d83d | ||
|
|
67188880a7 | ||
|
|
fb60840f9f | ||
|
|
5825a55b69 | ||
|
|
919fb7af62 | ||
|
|
ed3cd197dd | ||
|
|
a0b2bbf1ed | ||
|
|
2a02a946e7 | ||
|
|
1e96f9a5b0 | ||
|
|
0cb0232fa1 | ||
|
|
eb21a05b7b | ||
|
|
b15f07b5f7 | ||
|
|
81acaef8a4 | ||
|
|
c538068d8b | ||
|
|
9b0fee0737 | ||
|
|
d27a22a6d6 | ||
|
|
84d06ca392 | ||
|
|
104ee308c6 | ||
|
|
8306b528e0 | ||
|
|
2a9b59ebf7 | ||
|
|
097788a772 | ||
|
|
d0d09604e1 | ||
|
|
3111c0e722 | ||
|
|
b426055ad0 | ||
|
|
4f4c302a9d | ||
|
|
809a7c7dbd | ||
|
|
6426ea154e | ||
|
|
5b4e492c1b | ||
|
|
e935fedd60 | ||
|
|
2df51fe111 | ||
|
|
60f554f2fa | ||
|
|
abe3b0d47a | ||
|
|
06c3406e30 | ||
|
|
0d42761a12 | ||
|
|
2a48cc9682 | ||
|
|
aa048a0db9 | ||
|
|
d87135a80a | ||
|
|
e6aa4736ea | ||
|
|
f4aa7332d5 | ||
|
|
4da83d9231 | ||
|
|
18ba05cea0 | ||
|
|
a8752bb41c | ||
|
|
3396c56f2e | ||
|
|
f281faa0da | ||
|
|
b88f99e099 | ||
|
|
b04eba2d91 | ||
|
|
48ed23e70d | ||
|
|
55a8eb10e7 | ||
|
|
b447863542 | ||
|
|
4ba32141fa | ||
|
|
1ce3cf3d1f | ||
|
|
91580194e9 | ||
|
|
6f122f93d1 | ||
|
|
ced40b525a | ||
|
|
bcc2fc7a9e | ||
|
|
c9b395f60d | ||
|
|
1980f30666 | ||
|
|
b4f4c5e777 | ||
|
|
3e37434483 | ||
|
|
7b1ecccb4b | ||
|
|
fc9882098d | ||
|
|
e52499316e | ||
|
|
bec6bcee0d | ||
|
|
36dce6c2ac | ||
|
|
593ca0387f | ||
|
|
a1fcef670e | ||
|
|
ecb146bf20 | ||
|
|
c02590665f | ||
|
|
1cb724a264 | ||
|
|
82de2a1d8f | ||
|
|
b974fd9cc4 | ||
|
|
4f135fe91b | ||
|
|
f72603d967 | ||
|
|
0ba8faefc9 | ||
|
|
63b879ac45 | ||
|
|
07efd657af | ||
|
|
dfd66b95b0 | ||
|
|
295191955a | ||
|
|
14bcbfa901 | ||
|
|
5c23206d00 | ||
|
|
c9800a3799 | ||
|
|
a59be983f2 | ||
|
|
71e0e98e05 | ||
|
|
11f222c6e3 | ||
|
|
1eedcc0eb3 | ||
|
|
5d6729ddd5 | ||
|
|
d1268bbe8c | ||
|
|
424016e994 | ||
|
|
729b6d2d08 | ||
|
|
0f9c41cfc6 | ||
|
|
8b77ff953e | ||
|
|
69a81042db | ||
|
|
09f06f587c | ||
|
|
d7c9aecf03 | ||
|
|
9957ab642b | ||
|
|
1f9566bb47 | ||
|
|
9510962e25 | ||
|
|
ebf379f4d6 | ||
|
|
df9c05b490 | ||
|
|
42d0c67cb3 | ||
|
|
46f3ced83d | ||
|
|
4d0f909f0e | ||
|
|
1e60cb813a | ||
|
|
dc7bb5810f | ||
|
|
80f4862c9c | ||
|
|
55dd5bd25b | ||
|
|
890d7154e7 | ||
|
|
0d67f7e53d | ||
|
|
13220c044c | ||
|
|
5c36aca9ec | ||
|
|
2f20b64c0f | ||
|
|
87b44fa74c | ||
|
|
07a9f84c49 | ||
|
|
80854f9d40 | ||
|
|
4682694092 | ||
|
|
0bb2877b07 | ||
|
|
d3b58c0795 | ||
|
|
d8ce444df0 | ||
|
|
7edf8e0b23 | ||
|
|
c10354584d | ||
|
|
ef8407b23e | ||
|
|
11fde80d27 | ||
|
|
6bee151b25 | ||
|
|
0895de2f7e | ||
|
|
b90d076fdf | ||
|
|
f5fcd5b254 | ||
|
|
a8f3e1730b | ||
|
|
bbfeaeb3d0 | ||
|
|
5afa74eec1 | ||
|
|
d111cbd3e1 | ||
|
|
e922fc31a2 | ||
|
|
71dcb13a71 | ||
|
|
5579bd1a88 | ||
|
|
b71e0be095 | ||
|
|
4b26b8e259 | ||
|
|
1a2cceba4a | ||
|
|
42509cf0dd | ||
|
|
d3c77d0c4c | ||
|
|
8cb274934e | ||
|
|
5ae6dcf652 | ||
|
|
c7f73a6c84 | ||
|
|
cebc40f9ce | ||
|
|
3a0529e51b | ||
|
|
318feb8ed2 | ||
|
|
b4a6cc5473 | ||
|
|
cb54d0ee76 | ||
|
|
84e68b9bc8 | ||
|
|
645b2390c2 | ||
|
|
513c8f353d | ||
|
|
2acb00d424 | ||
|
|
36cee9cc66 | ||
|
|
7115329060 | ||
|
|
2f966d09ca | ||
|
|
5fc67f1c12 | ||
|
|
b5e31f3081 | ||
|
|
381ca75126 | ||
|
|
4d988c427a | ||
|
|
7cebb9e4ee | ||
|
|
c3896934c9 | ||
|
|
1c28f44f08 | ||
|
|
78125ed975 | ||
|
|
e5df2a0f52 | ||
|
|
e552c78491 | ||
|
|
3cb07b9cd6 | ||
|
|
e94af6a059 | ||
|
|
828669ac95 | ||
|
|
fab32ace10 | ||
|
|
4605d567ae | ||
|
|
7151698b18 | ||
|
|
afc94adc1b | ||
|
|
360f47e16b | ||
|
|
ee559e7deb | ||
|
|
5e71613d4f | ||
|
|
9459a2a2f5 | ||
|
|
200f464925 | ||
|
|
51fc0f7a92 | ||
|
|
d7442ef1a9 | ||
|
|
d6c7e151a0 | ||
|
|
b39874fe08 | ||
|
|
7ebea0ca9d | ||
|
|
cdda2d0319 | ||
|
|
8d02ad432c | ||
|
|
7d4b6d22f0 | ||
|
|
af38a64f5c | ||
|
|
a15acaf293 | ||
|
|
dba77d403d | ||
|
|
40eb39f8f0 | ||
|
|
c291dd491d | ||
|
|
78f1f39f33 | ||
|
|
d5ef0db394 | ||
|
|
82cbb5a836 | ||
|
|
4b3e59f8d0 | ||
|
|
fc63493e7e | ||
|
|
7614edf167 | ||
|
|
a133ab31fa | ||
|
|
449e375cbb | ||
|
|
97d7467033 | ||
|
|
053bea18cd | ||
|
|
49dd5bdf21 | ||
|
|
2e47fde46e | ||
|
|
1e67850e87 | ||
|
|
f350e26740 | ||
|
|
c76117d5c6 | ||
|
|
9660e2f6c2 | ||
|
|
d336ab5053 | ||
|
|
6b2bc11710 | ||
|
|
aecad2fce9 | ||
|
|
923f182be5 | ||
|
|
b53f1872bb | ||
|
|
01befe0c29 | ||
|
|
ddafb40b2d | ||
|
|
2a1690afcf | ||
|
|
97763c681f | ||
|
|
48aff63eca | ||
|
|
a4e262e9e6 | ||
|
|
c7e5059341 | ||
|
|
1bb8fa4b61 | ||
|
|
879e5c8383 | ||
|
|
467496f34c | ||
|
|
a0d68b12ea | ||
|
|
9f12cc9f5f | ||
|
|
cadd1e8a7c | ||
|
|
73adc23176 | ||
|
|
cab3b8a831 | ||
|
|
04d3541de1 | ||
|
|
e377bceeb4 | ||
|
|
726a4d7fc3 | ||
|
|
079721298e | ||
|
|
dfeff72d3e | ||
|
|
78a5bf15b5 | ||
|
|
a404b8a87a | ||
|
|
ca9586491b | ||
|
|
40db689b27 | ||
|
|
106218df19 | ||
|
|
0355ba4ff7 | ||
|
|
ce92f63215 | ||
|
|
b6ef4f393a | ||
|
|
98b77f076c | ||
|
|
7de82e0fef | ||
|
|
11c7f4ff55 | ||
|
|
509b4b66bd | ||
|
|
e8f1e2d6b0 | ||
|
|
be8706efcf | ||
|
|
1be319b4b9 | ||
|
|
0b53c4aeaa | ||
|
|
b713d0d3f1 | ||
|
|
cede669c20 | ||
|
|
197eb0c9a0 | ||
|
|
10c1aa939f | ||
|
|
d9f913dbb0 | ||
|
|
4539b8ffb4 | ||
|
|
e1d502ecf8 | ||
|
|
33dde7d4dd | ||
|
|
ef6634e9b3 | ||
|
|
d714e32668 | ||
|
|
fa4e779fa0 | ||
|
|
7ce400a5e5 | ||
|
|
e7dfd1efbb | ||
|
|
c9a23698ba | ||
|
|
a883052e03 | ||
|
|
1ba5e7a67e | ||
|
|
1c5928ad0b | ||
|
|
79e1009b19 | ||
|
|
61b78a1beb | ||
|
|
0363855de8 | ||
|
|
24f0c19378 | ||
|
|
156f8e446f | ||
|
|
fe4b46f924 | ||
|
|
0725013481 | ||
|
|
779839aeb4 | ||
|
|
c9485fab3f | ||
|
|
93fd445bd4 | ||
|
|
d85e185aa1 | ||
|
|
7497561d9c | ||
|
|
827bc7dc40 | ||
|
|
85c71716f9 | ||
|
|
30f3ffe517 | ||
|
|
ea4e95c710 | ||
|
|
f0eeb37dc0 | ||
|
|
2287e2a889 | ||
|
|
75663ee227 | ||
|
|
f6e73299cd | ||
|
|
6aeb72c3d9 | ||
|
|
be6aa8314b | ||
|
|
b22cd5f76a | ||
|
|
5039e8d277 | ||
|
|
ffb41abfd4 | ||
|
|
390ecbe8d9 | ||
|
|
11327cee5b | ||
|
|
1334048d89 | ||
|
|
75ff933add | ||
|
|
90bd79abbb | ||
|
|
d081bf4a24 | ||
|
|
1e548eb85f | ||
|
|
b566d7f844 | ||
|
|
62c43cce77 | ||
|
|
d22e364d1b | ||
|
|
019fa9e5ac | ||
|
|
be3e5f0246 | ||
|
|
22a88c579f | ||
|
|
dcab5a1622 | ||
|
|
5ecea4f80c | ||
|
|
1804571d4d | ||
|
|
45f929868a |
+18
-3
@@ -1,15 +1,30 @@
|
||||
/logs/
|
||||
/target/
|
||||
/var/
|
||||
/frontend/node_modules/
|
||||
/frontend/public/
|
||||
/.vscode/
|
||||
/.idea/
|
||||
/bin/
|
||||
/.git/
|
||||
|
||||
# frontend files not needed in docker image
|
||||
/frontend/node_modules/
|
||||
/frontend/apps/remark42/node_modules/
|
||||
/frontend/apps/remark42/public/
|
||||
# e2e tests arficats
|
||||
/frontend/e2e/playwright-report/
|
||||
/frontend/e2e/playwright/.cache/
|
||||
/frontend/e2e/test-results/
|
||||
|
||||
# source files
|
||||
docker-compose.yml
|
||||
compose-dev-backend.yml
|
||||
compose-dev-frontend.yml
|
||||
compose-private-backend.yml
|
||||
compose-private-frontend.yml
|
||||
compose-e2e-test.yml
|
||||
compose-private.yml
|
||||
rest-client.env.json
|
||||
Makefile
|
||||
|
||||
# generated files
|
||||
*.cov
|
||||
@@ -21,4 +36,4 @@ debug.test
|
||||
*.test
|
||||
remark42
|
||||
/backend/var/
|
||||
compose-private-backend.yml
|
||||
/playwright-report/
|
||||
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
kind: pipeline
|
||||
name: default
|
||||
type: docker
|
||||
|
||||
steps:
|
||||
|
||||
- name: build server
|
||||
image: umputun/baseimage:buildgo-latest
|
||||
commands:
|
||||
- cd backend/app
|
||||
- go build -mod=vendor
|
||||
- echo "build completed"
|
||||
|
||||
- name: docker master
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: umputun/remark42
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
build_args:
|
||||
- DRONE=${DRONE}
|
||||
- DRONE_TAG=${DRONE_TAG}
|
||||
- DRONE_COMMIT=${DRONE_COMMIT}
|
||||
- DRONE_BRANCH=${DRONE_BRANCH}
|
||||
tags:
|
||||
- ${DRONE_COMMIT_BRANCH/\//-}
|
||||
when:
|
||||
branch: [master]
|
||||
event: push
|
||||
|
||||
- name: docker tag
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: umputun/remark42
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
build_args:
|
||||
- DRONE=${DRONE}
|
||||
- DRONE_TAG=${DRONE_TAG}
|
||||
- DRONE_COMMIT=${DRONE_COMMIT}
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
when:
|
||||
event: tag
|
||||
|
||||
- name: docker branch
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: umputun/remark42
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
build_args:
|
||||
- DRONE=${DRONE}
|
||||
- DRONE_COMMIT=${DRONE_COMMIT}
|
||||
- DRONE_BRANCH=${DRONE_BRANCH}
|
||||
tags:
|
||||
- ${DRONE_COMMIT_BRANCH/\//-}
|
||||
dry_run: true
|
||||
when:
|
||||
branch:
|
||||
exclude: [master, release/*]
|
||||
event: push
|
||||
|
||||
- name: artifacts tag
|
||||
image: plugins/docker
|
||||
settings:
|
||||
dockerfile: Dockerfile.artifacts
|
||||
build_args:
|
||||
- DRONE=${DRONE}
|
||||
- DRONE_TAG=${DRONE_TAG}
|
||||
- DRONE_COMMIT=${DRONE_COMMIT}
|
||||
- GITHUB_TOKEN=${GITHUB_TOKEN}
|
||||
when:
|
||||
event: tag
|
||||
|
||||
- name: deploy
|
||||
image: docker.umputun.com/system/deploy-ci:master
|
||||
commands:
|
||||
- ssh umputun@remark42.com "cd /srv/remark && docker-compose pull"
|
||||
- ssh umputun@remark42.com "cd /srv/remark && docker-compose up -d"
|
||||
when:
|
||||
branch: master
|
||||
event: push
|
||||
|
||||
- name: notify
|
||||
image: drillster/drone-email
|
||||
settings:
|
||||
host: smtp.mailgun.org
|
||||
username:
|
||||
from_secret: email_username
|
||||
password:
|
||||
from_secret: email_password
|
||||
from: drone@mg.umputun.dev
|
||||
recipients: [ sys@umputun.dev ]
|
||||
when:
|
||||
status: [ changed, failure ]
|
||||
@@ -0,0 +1,13 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = tab
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
indent_style = space
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,json}]
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
+2
-1
@@ -2,4 +2,5 @@
|
||||
# Unless a later match takes precedence, @umputun will be requested for
|
||||
# review when someone opens a pull request.
|
||||
|
||||
* @umputun
|
||||
* @umputun
|
||||
frontend/* @akellbl4 @Mavrin
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [umputun]
|
||||
#patreon: remark42
|
||||
patreon: remark42
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
name: backend
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
tags:
|
||||
paths:
|
||||
- ".github/workflows/ci-test-backend.yml"
|
||||
- "backend/**"
|
||||
- "!backend/scripts/**"
|
||||
- "!**.md"
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
paths:
|
||||
- ".github/workflows/ci-test-backend.yml"
|
||||
- "backend/**"
|
||||
- "!backend/scripts/**"
|
||||
- "!**.md"
|
||||
jobs:
|
||||
test:
|
||||
name: Test & Coverage
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: debug if needed
|
||||
run: if [[ "$DEBUG" == "true" ]]; then env; fi
|
||||
env:
|
||||
DEBUG: ${{secrets.DEBUG}}
|
||||
|
||||
- name: install go
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "1.20"
|
||||
|
||||
- name: test and build backend
|
||||
run: |
|
||||
go test -race -timeout=60s -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./...
|
||||
cat $GITHUB_WORKSPACE/profile.cov_tmp | grep -v "_mock.go" > $GITHUB_WORKSPACE/profile.cov
|
||||
go build -race ./...
|
||||
working-directory: backend/app
|
||||
env:
|
||||
TZ: "America/Chicago"
|
||||
|
||||
- name: test examples
|
||||
run: |
|
||||
go test -race ./...
|
||||
go build -race ./...
|
||||
working-directory: backend/_example/memory_store
|
||||
env:
|
||||
TZ: "America/Chicago"
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
version: latest
|
||||
working-directory: backend/app
|
||||
|
||||
- name: golangci-lint on example directory
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
version: latest
|
||||
args: --config ../../.golangci.yml
|
||||
working-directory: backend/_example/memory_store
|
||||
|
||||
- name: submit coverage
|
||||
run: |
|
||||
go install github.com/mattn/goveralls@latest
|
||||
goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov
|
||||
working-directory: backend
|
||||
env:
|
||||
COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -5,27 +5,97 @@ on:
|
||||
branches:
|
||||
tags:
|
||||
paths:
|
||||
- '.github/workflows/ci-build.yml'
|
||||
- 'backend/**'
|
||||
- 'frontend/**'
|
||||
- '.dockerignore'
|
||||
- 'docker-init.sh'
|
||||
- 'Dockerfile'
|
||||
- ".github/workflows/ci-build.yml"
|
||||
- "backend/**"
|
||||
- "frontend/apps/**"
|
||||
- ".dockerignore"
|
||||
- "docker-init.sh"
|
||||
- "Dockerfile"
|
||||
- "!**.md"
|
||||
- "!frontend/packages/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/ci-build.yml'
|
||||
- 'backend/**'
|
||||
- 'frontend/**'
|
||||
- '.dockerignore'
|
||||
- 'docker-init.sh'
|
||||
- 'Dockerfile'
|
||||
- ".github/workflows/ci-build.yml"
|
||||
- "backend/**"
|
||||
- "frontend/apps/**"
|
||||
- ".dockerignore"
|
||||
- "docker-init.sh"
|
||||
- "Dockerfile"
|
||||
- "!**.md"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
build-images:
|
||||
name: Build Docker images
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: build docker image
|
||||
run: docker build --build-arg SKIP_BACKEND_TEST=true --build-arg SKIP_FRONTEND_TEST=true --build-arg CI=github .
|
||||
- name: set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: available platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
|
||||
- name: build docker image without pushing (only outside master)
|
||||
if: ${{ github.ref != 'refs/heads/master' }}
|
||||
run: |
|
||||
docker buildx build \
|
||||
--build-arg SKIP_BACKEND_TEST=true --build-arg SKIP_FRONTEND_TEST=true \
|
||||
--platform linux/amd64 .
|
||||
|
||||
- name: build example docker image without pushing (only outside master)
|
||||
if: ${{ github.ref != 'refs/heads/master' }}
|
||||
run: |
|
||||
docker buildx build \
|
||||
--build-arg SKIP_BACKEND_TEST=true --build-arg SKIP_FRONTEND_TEST=true \
|
||||
--platform linux/amd64 -f backend/_example/memory_store/Dockerfile .
|
||||
|
||||
- name: build and deploy master image to ghcr.io and dockerhub
|
||||
if: ${{ github.ref == 'refs/heads/master' }}
|
||||
env:
|
||||
GITHUB_PACKAGE_TOKEN: ${{ secrets.PKG_TOKEN }}
|
||||
DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
USERNAME: ${{ github.actor }}
|
||||
GITHUB_SHA: ${{ github.sha}}
|
||||
GITHUB_REF: ${{ github.ref}}
|
||||
run: |
|
||||
ref="$(echo ${GITHUB_REF} | cut -d'/' -f3)"
|
||||
echo "GITHUB_REF=${GITHUB_REF}, GITHUB_SHA=${GITHUB_SHA}, GIT_BRANCH=${ref}"
|
||||
echo ${GITHUB_PACKAGE_TOKEN} | docker login ghcr.io -u ${USERNAME} --password-stdin
|
||||
echo ${DOCKER_HUB_TOKEN} | docker login -u umputun --password-stdin
|
||||
docker buildx build --push \
|
||||
--build-arg SKIP_BACKEND_TEST=true --build-arg SKIP_FRONTEND_TEST=true --build-arg CI=github \
|
||||
--build-arg GITHUB_SHA=${GITHUB_SHA} --build-arg GIT_BRANCH=${ref} --build-arg GITHUB_REF=${GITHUB_REF} \
|
||||
--platform linux/amd64,linux/arm/v7,linux/arm64 \
|
||||
-t ghcr.io/umputun/remark42:${ref} -t umputun/remark42:${ref} .
|
||||
|
||||
- name: deploy tagged (latest) to ghcr.io and dockerhub
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
env:
|
||||
GITHUB_PACKAGE_TOKEN: ${{ secrets.PKG_TOKEN }}
|
||||
DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
USERNAME: ${{ github.actor }}
|
||||
GITHUB_SHA: ${{ github.sha}}
|
||||
GITHUB_REF: ${{ github.ref}}
|
||||
run: |
|
||||
ref="$(echo ${GITHUB_REF} | cut -d'/' -f3)"
|
||||
echo "GITHUB_REF=${GITHUB_REF}, GITHUB_SHA=${GITHUB_SHA}, GIT_BRANCH=${ref}"
|
||||
echo ${GITHUB_PACKAGE_TOKEN} | docker login ghcr.io -u ${USERNAME} --password-stdin
|
||||
echo ${DOCKER_HUB_TOKEN} | docker login -u umputun --password-stdin
|
||||
docker buildx build --push \
|
||||
--build-arg SKIP_BACKEND_TEST=true --build-arg SKIP_FRONTEND_TEST=true --build-arg CI=github \
|
||||
--build-arg GITHUB_SHA=${GITHUB_SHA} --build-arg GIT_BRANCH=${ref} --build-arg GITHUB_REF=${GITHUB_REF} \
|
||||
--platform linux/amd64,linux/arm/v7,linux/arm64 \
|
||||
-t ghcr.io/umputun/remark42:${ref} -t ghcr.io/umputun/remark42:latest \
|
||||
-t umputun/remark42:${ref} -t umputun/remark42:latest .
|
||||
|
||||
- name: remote deployment to remark42.com from master
|
||||
if: ${{ github.ref == 'refs/heads/master' }}
|
||||
env:
|
||||
UPDATER_KEY: ${{ secrets.UPDATER_KEY }}
|
||||
run: curl -s https://jess.umputun.com/update/remark42-core/${UPDATER_KEY}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
name: "@remark42/api"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- ".github/workflows/ci-frontend-api.yml"
|
||||
- "frontend/packages/**"
|
||||
- "!**.md"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/ci-frontend-api.yml"
|
||||
- "frontend/packages/**"
|
||||
- "!**.md"
|
||||
|
||||
jobs:
|
||||
type-check:
|
||||
name: Type check
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16.15.1]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.0.1
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 7
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Run type check
|
||||
run: pnpm type-check:api
|
||||
working-directory: ./frontend
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16.15.1]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.0.1
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 7
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint:api
|
||||
working-directory: ./frontend/
|
||||
|
||||
test:
|
||||
name: Tests & Coverage
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16.15.1]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.0.1
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 7
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Test & Coverage
|
||||
run: pnpm coverage:api
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Submit coverage
|
||||
run: ${{ github.workspace }}/frontend/apps/remark42/node_modules/.bin/codecov
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -0,0 +1,221 @@
|
||||
name: frontend
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- ".github/workflows/ci-frontend.yml"
|
||||
- "frontend/apps/remark42/**"
|
||||
- "!**.md"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/ci-frontend.yml"
|
||||
- "frontend/apps/remark42/**"
|
||||
- "!**.md"
|
||||
|
||||
jobs:
|
||||
translations-check:
|
||||
name: Translations check
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16.15.1]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.0.1
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 7
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Translations check
|
||||
run: pnpm translation-check
|
||||
working-directory: ./frontend/apps/remark42
|
||||
|
||||
type-check:
|
||||
name: Type check
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16.15.1]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.0.1
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 7
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Run type check
|
||||
run: pnpm type-check
|
||||
working-directory: ./frontend/apps/remark42
|
||||
|
||||
lint:
|
||||
name: Eslint & Stylelint
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16.15.1]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.0.1
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 7
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Run linters
|
||||
run: pnpm lint
|
||||
working-directory: ./frontend/apps/remark42
|
||||
|
||||
size-limit:
|
||||
name: Size limit
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
CI_JOB_NUMBER: 1
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.0.1
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 7
|
||||
run_install: false
|
||||
|
||||
- name: Check bundle size
|
||||
uses: andresz1/size-limit-action@dd31dce7dcc72a041fd3e49abf0502b13fc4ce05
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
directory: ./frontend/apps/remark42
|
||||
package_manager: pnpm
|
||||
|
||||
test:
|
||||
name: Tests & Coverage
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: [16.15.1]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.0.1
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 7
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Test & Coverage
|
||||
run: pnpm coverage
|
||||
working-directory: ./frontend/apps/remark42
|
||||
|
||||
- name: Submit coverage
|
||||
run: ${{ github.workspace }}/frontend/apps/remark42/node_modules/.bin/codecov
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -0,0 +1,69 @@
|
||||
name: site
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
paths:
|
||||
- ".github/workflows/ci-site.yml"
|
||||
- "site/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/ci-site.yml"
|
||||
- "site/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: available platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
|
||||
- name: build and deploy master image to ghcr.io and dockerhub
|
||||
if: ${{ github.ref == 'refs/heads/master' }}
|
||||
env:
|
||||
GITHUB_PACKAGE_TOKEN: ${{ secrets.PKG_TOKEN }}
|
||||
USERNAME: ${{ github.actor }}
|
||||
GITHUB_SHA: ${{ github.sha}}
|
||||
GITHUB_REF: ${{ github.ref}}
|
||||
working-directory: ./site
|
||||
run: |
|
||||
ref="$(echo ${GITHUB_REF} | cut -d'/' -f3)"
|
||||
echo GITHUB_REF - $ref
|
||||
echo ${GITHUB_PACKAGE_TOKEN} | docker login ghcr.io -u ${USERNAME} --password-stdin
|
||||
docker buildx build --push --no-cache --platform linux/amd64,linux/arm/v7,linux/arm64 \
|
||||
-t ghcr.io/umputun/remark42-site:${ref} .
|
||||
|
||||
- name: deploy tagged (latest) to ghcr.io and dockerhub
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
env:
|
||||
GITHUB_PACKAGE_TOKEN: ${{ secrets.PKG_TOKEN }}
|
||||
USERNAME: ${{ github.actor }}
|
||||
GITHUB_SHA: ${{ github.sha}}
|
||||
GITHUB_REF: ${{ github.ref}}
|
||||
working-directory: ./site
|
||||
run: |
|
||||
ref="$(echo ${GITHUB_REF} | cut -d'/' -f3)"
|
||||
echo "GITHUB_REF=$ref, GITHUB_SHA=${GITHUB_SHA}"
|
||||
echo ${GITHUB_PACKAGE_TOKEN} | docker login ghcr.io -u ${USERNAME} --password-stdin
|
||||
docker buildx build --push --no-cache --platform linux/amd64,linux/arm/v7,linux/arm64 \
|
||||
-t ghcr.io/umputun/remark42-site:${ref} -t ghcr.io/umputun/remark42-site:latest .
|
||||
|
||||
- name: remote site deployment from master
|
||||
if: ${{ github.ref == 'refs/heads/master' }}
|
||||
env:
|
||||
UPDATER_KEY: ${{ secrets.UPDATER_KEY }}
|
||||
run: curl https://jess.umputun.com/update/remark42-site/${UPDATER_KEY}
|
||||
@@ -1,67 +0,0 @@
|
||||
name: test_backend
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
tags:
|
||||
paths:
|
||||
- '.github/workflows/ci-test-backend.yml'
|
||||
- 'backend/**'
|
||||
- '!backend/scripts/**'
|
||||
- '.golangci.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/ci-test-backend.yml'
|
||||
- 'backend/**'
|
||||
- '!backend/scripts/**'
|
||||
- '.golangci.yml'
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: debug if needed
|
||||
run: |
|
||||
export DEBUG=${DEBUG:-false}
|
||||
if [[ "$DEBUG" == "true" ]]; then
|
||||
env
|
||||
fi
|
||||
env:
|
||||
DEBUG: ${{secrets.DEBUG}}
|
||||
|
||||
- name: install go
|
||||
uses: actions/setup-go@v1
|
||||
with:
|
||||
go-version: 1.13
|
||||
|
||||
- name: test backend
|
||||
run: |
|
||||
export TZ="America/Chicago"
|
||||
date
|
||||
cd backend/app
|
||||
go test -mod=vendor -timeout=60s -covermode=count -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./...
|
||||
cat $GITHUB_WORKSPACE/profile.cov_tmp | grep -v "_mock.go" > $GITHUB_WORKSPACE/profile.cov
|
||||
cd ../_example/memory_store
|
||||
go test -race ./...
|
||||
|
||||
- name: install golangci-lint and goveralls
|
||||
run: |
|
||||
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $GITHUB_WORKSPACE v1.20.0
|
||||
go get -u github.com/mattn/goveralls
|
||||
|
||||
- name: run backend linters
|
||||
run: $GITHUB_WORKSPACE/golangci-lint run --config .golangci.yml ./...
|
||||
working-directory: backend
|
||||
|
||||
- name: run linters for examples
|
||||
run: $GITHUB_WORKSPACE/golangci-lint run --config ${GITHUB_WORKSPACE}/backend/.golangci.yml ./...
|
||||
working-directory: backend/_example/memory_store
|
||||
|
||||
- name: submit coverage
|
||||
run: $(go env GOPATH)/bin/goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov
|
||||
working-directory: backend
|
||||
env:
|
||||
COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,28 +0,0 @@
|
||||
name: test_frontend
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
tags:
|
||||
paths:
|
||||
- '.github/workflows/ci-test-frontend.yml'
|
||||
- 'frontend/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/ci-test-frontend.yml'
|
||||
- 'frontend/**'
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- uses: actions/setup-node@v1
|
||||
|
||||
- run: npm ci
|
||||
working-directory: ./frontend
|
||||
|
||||
- run: npx run-p check lint test
|
||||
working-directory: ./frontend
|
||||
@@ -0,0 +1,34 @@
|
||||
name: e2e
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- "frontend/apps/remark42/**"
|
||||
- "frontend/e2e/**"
|
||||
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- "frontend/apps/remark42/**"
|
||||
- "frontend/e2e/**"
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Tests
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Build & run containers
|
||||
id: tests
|
||||
run: COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker compose -f compose-e2e-test.yml up --build --quiet-pull --exit-code-from tests
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: ./playwright-report/
|
||||
retention-days: 30
|
||||
+6
-3
@@ -8,9 +8,6 @@ debug
|
||||
debug.test
|
||||
.vscode
|
||||
.idea/
|
||||
/frontend/node_modules/
|
||||
/frontend/public/
|
||||
/frontend/coverage
|
||||
*.prof
|
||||
*.test
|
||||
/rest-client.env.json
|
||||
@@ -19,7 +16,13 @@ debug.test
|
||||
remark42
|
||||
/bin/
|
||||
/backend/var/
|
||||
/backend/app/var/
|
||||
/backend/app/cmd/web/
|
||||
/backend/*.html.tmpl
|
||||
compose-private-backend.yml
|
||||
compose-private-frontend.yml
|
||||
compose-private.yml
|
||||
/backend/_example/*/vendor
|
||||
http-client.env.json
|
||||
/playwright-report/
|
||||
/backend/app/cmd/var
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
service:
|
||||
golangci-lint-version: 1.19.1
|
||||
+77
-42
@@ -1,78 +1,113 @@
|
||||
FROM umputun/baseimage:buildgo-latest as build-backend
|
||||
FROM --platform=$BUILDPLATFORM node:16.15.1-alpine AS frontend-deps
|
||||
|
||||
ARG SKIP_FRONTEND_TEST
|
||||
ARG SKIP_FRONTEND_BUILD
|
||||
|
||||
WORKDIR /srv/frontend/
|
||||
|
||||
COPY ./frontend/package.json ./frontend/pnpm-lock.yaml ./frontend/pnpm-workspace.yaml /srv/frontend/
|
||||
COPY ./frontend/apps/remark42/package.json /srv/frontend/apps/remark42/
|
||||
|
||||
RUN \
|
||||
if [[ -z "$SKIP_FRONTEND_BUILD" || -z "$SKIP_FRONTEND_TEST" ]]; then \
|
||||
apk add --no-cache --update git && \
|
||||
npm i -g pnpm@7; \
|
||||
fi
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.pnpm-store/v3 \
|
||||
if [[ -z "$SKIP_FRONTEND_BUILD" || -z "$SKIP_FRONTEND_TEST" ]]; then \
|
||||
pnpm i; \
|
||||
fi
|
||||
|
||||
|
||||
FROM --platform=$BUILDPLATFORM frontend-deps AS build-frontend
|
||||
|
||||
ARG SKIP_FRONTEND_TEST
|
||||
ARG SKIP_FRONTEND_BUILD
|
||||
ENV CI=true
|
||||
|
||||
WORKDIR /srv/frontend/apps/remark42/
|
||||
|
||||
COPY ./frontend/apps/remark42/ /srv/frontend/apps/remark42/
|
||||
|
||||
RUN \
|
||||
if [ -z "$SKIP_FRONTEND_TEST" ]; then \
|
||||
pnpm lint type-check translation-check test; \
|
||||
else \
|
||||
echo 'Skip frontend test'; \
|
||||
fi
|
||||
|
||||
RUN \
|
||||
if [ -z "$SKIP_FRONTEND_BUILD" ]; then \
|
||||
pnpm build; \
|
||||
else \
|
||||
mkdir /srv/frontend/apps/remark42/public; \
|
||||
echo 'Skip frontend build'; \
|
||||
fi
|
||||
|
||||
FROM umputun/baseimage:buildgo-v1.11.0 as build-backend
|
||||
|
||||
ARG CI
|
||||
ARG DRONE
|
||||
ARG DRONE_TAG
|
||||
ARG DRONE_COMMIT
|
||||
ARG DRONE_BRANCH
|
||||
ARG DRONE_PULL_REQUEST
|
||||
|
||||
ARG GITHUB_REF
|
||||
ARG GITHUB_SHA
|
||||
ARG GIT_BRANCH
|
||||
ARG SKIP_BACKEND_TEST
|
||||
ARG BACKEND_TEST_TIMEOUT
|
||||
|
||||
ADD backend /build/backend
|
||||
ADD .git/ /build/backend/.git/
|
||||
# to embed the frontend files statically into Remark42 binary
|
||||
COPY --from=build-frontend /srv/frontend/apps/remark42/public/ /build/backend/app/cmd/web/
|
||||
RUN find /build/backend/app/cmd/web/ -regex '.*\.\(html\|js\|mjs\)$' -print -exec sed -i "s|{% REMARK_URL %}|http://127.0.0.1:8080|g" {} \;
|
||||
WORKDIR /build/backend
|
||||
|
||||
ENV GOFLAGS="-mod=vendor"
|
||||
# install gcc in order to be able to go test package with -race
|
||||
RUN apk --no-cache add gcc libc-dev
|
||||
|
||||
RUN echo go version: `go version`
|
||||
|
||||
# run tests
|
||||
RUN \
|
||||
cd app && \
|
||||
if [ -z "$SKIP_BACKEND_TEST" ] ; then \
|
||||
go test -p 1 -timeout="${BACKEND_TEST_TIMEOUT:-300s}" -covermode=count -coverprofile=/profile.cov_tmp ./... && \
|
||||
cat /profile.cov_tmp | grep -v "_mock.go" > /profile.cov ; \
|
||||
CGO_ENABLED=1 go test -race -p 1 -timeout="${BACKEND_TEST_TIMEOUT:-300s}" -covermode=atomic -coverprofile=/profile.cov_tmp ./... && \
|
||||
cat /profile.cov_tmp | grep -v "_mock.go" > /profile.cov && \
|
||||
golangci-lint run --config ../.golangci.yml ./... ; \
|
||||
else echo "skip backend tests and linter" ; fi
|
||||
else \
|
||||
echo "skip backend tests and linter" \
|
||||
; fi
|
||||
|
||||
# if DRONE presented use DRONE_* git env to make version
|
||||
RUN \
|
||||
if [ -z "$DRONE" ] ; then echo "runs outside of drone" && version="$(/script/git-rev.sh)" ; \
|
||||
else version=${DRONE_TAG}${DRONE_BRANCH}${DRONE_PULL_REQUEST}-${DRONE_COMMIT:0:7}-$(date +%Y%m%d-%H:%M:%S) ; fi && \
|
||||
version="$(/script/version.sh)" && \
|
||||
echo "version=$version" && \
|
||||
go build -o remark42 -ldflags "-X main.revision=${version} -s -w" ./app
|
||||
|
||||
FROM node:10.11-alpine as build-frontend-deps
|
||||
FROM umputun/baseimage:app-v1.11.0
|
||||
|
||||
ARG CI
|
||||
ENV HUSKY_SKIP_INSTALL=true
|
||||
ARG GITHUB_SHA
|
||||
|
||||
RUN apk add --no-cache --update git
|
||||
ADD frontend/package.json /srv/frontend/package.json
|
||||
ADD frontend/package-lock.json /srv/frontend/package-lock.json
|
||||
RUN cd /srv/frontend && CI=true npm ci
|
||||
|
||||
FROM node:10.11-alpine as build-frontend
|
||||
|
||||
ARG CI
|
||||
ARG SKIP_FRONTEND_TEST
|
||||
ARG NODE_ENV=production
|
||||
|
||||
COPY --from=build-frontend-deps /srv/frontend/node_modules /srv/frontend/node_modules
|
||||
ADD frontend /srv/frontend
|
||||
RUN cd /srv/frontend && \
|
||||
if [ -z "$SKIP_FRONTEND_TEST" ] ; then npx run-p check lint test build ; \
|
||||
else echo "skip frontend tests and lint" ; npm run build ; fi && \
|
||||
rm -rf ./node_modules
|
||||
|
||||
FROM umputun/baseimage:app
|
||||
LABEL org.opencontainers.image.authors="Umputun <umputun@gmail.com>" \
|
||||
org.opencontainers.image.description="Remark42 comment engine" \
|
||||
org.opencontainers.image.documentation="https://remark42.com/docs/getting-started/" \
|
||||
org.opencontainers.image.licenses="MIT" \
|
||||
org.opencontainers.image.source="https://github.com/umputun/remark42.git" \
|
||||
org.opencontainers.image.title="Remark42" \
|
||||
org.opencontainers.image.url="https://remark42.com/" \
|
||||
org.opencontainers.image.revision="${GITHUB_SHA}"
|
||||
|
||||
WORKDIR /srv
|
||||
|
||||
ADD docker-init.sh /entrypoint.sh
|
||||
COPY docker-init.sh /srv/init.sh
|
||||
ADD backend/scripts/backup.sh /usr/local/bin/backup
|
||||
ADD backend/scripts/restore.sh /usr/local/bin/restore
|
||||
ADD backend/scripts/import.sh /usr/local/bin/import
|
||||
RUN chmod +x /entrypoint.sh /usr/local/bin/backup /usr/local/bin/restore /usr/local/bin/import
|
||||
RUN chmod +x /srv/init.sh /usr/local/bin/backup /usr/local/bin/restore /usr/local/bin/import
|
||||
|
||||
COPY --from=build-backend /build/backend/remark42 /srv/remark42
|
||||
COPY --from=build-frontend /srv/frontend/public/ /srv/web
|
||||
COPY --from=build-frontend /srv/frontend/apps/remark42/public/ /srv/web/
|
||||
RUN chown -R app:app /srv
|
||||
RUN ln -s /srv/remark42 /usr/bin/remark42
|
||||
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD curl --fail http://localhost:8080/ping || exit 1
|
||||
|
||||
COPY docker-init.sh /srv/init.sh
|
||||
RUN chmod +x /srv/init.sh
|
||||
CMD ["/srv/remark42", "server"]
|
||||
|
||||
+29
-71
@@ -1,104 +1,62 @@
|
||||
FROM node:10.11-alpine as build-frontend-deps
|
||||
FROM node:16.15.1-alpine AS frontend-deps
|
||||
|
||||
ARG CI
|
||||
ARG DRONE
|
||||
ARG DRONE_TAG
|
||||
ARG DRONE_COMMIT
|
||||
ARG DRONE_BRANCH
|
||||
ENV CI=true
|
||||
|
||||
ENV SKIP_FRONTEND_TEST=true
|
||||
WORKDIR /srv/frontend
|
||||
|
||||
RUN apk add --no-cache --update git
|
||||
ADD frontend/package.json /srv/frontend/package.json
|
||||
ADD frontend/package-lock.json /srv/frontend/package-lock.json
|
||||
RUN cd /srv/frontend && CI=true npm ci
|
||||
COPY ./frontend/package.json ./frontend/pnpm-lock.yaml ./frontend/pnpm-workspace.yaml /srv/frontend/
|
||||
COPY ./frontend/apps/remark42/package.json /srv/frontend/apps/remark42/package.json
|
||||
|
||||
FROM node:10.11-alpine as build-frontend
|
||||
RUN apk add --no-cache --update git && npm i -g pnpm@7
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.pnpm-store/v3 pnpm i
|
||||
|
||||
ARG CI
|
||||
ARG NODE_ENV=production
|
||||
ENV SKIP_FRONTEND_TEST=true
|
||||
ENV HUSKY_SKIP_INSTALL=true
|
||||
FROM frontend-deps AS build-frontend
|
||||
|
||||
COPY --from=build-frontend-deps /srv/frontend/node_modules /srv/frontend/node_modules
|
||||
ADD frontend /srv/frontend
|
||||
RUN cd /srv/frontend && \
|
||||
npm run build && \
|
||||
rm -rf ./node_modules
|
||||
ENV NODE_ENV=production
|
||||
ENV CI=true
|
||||
|
||||
FROM umputun/baseimage:buildgo-latest as build-backend
|
||||
WORKDIR /srv/frontend/apps/remark42/
|
||||
COPY ./frontend/apps/remark42/ /srv/frontend/apps/remark42/
|
||||
RUN pnpm build
|
||||
|
||||
FROM umputun/baseimage:buildgo-v1.9.2 as build-backend
|
||||
|
||||
ARG GITHUB_TOKEN
|
||||
ENV SKIP_BACKEND_TEST=true
|
||||
|
||||
RUN go get github.com/rakyll/statik
|
||||
ARG GITHUB_REF
|
||||
ARG GITHUB_SHA
|
||||
|
||||
WORKDIR /build/backend
|
||||
ADD backend /build/backend
|
||||
ADD README.md /build/
|
||||
ADD LICENSE /build/
|
||||
|
||||
ADD .git/ /build/backend/.git/
|
||||
COPY --from=build-frontend /srv/frontend/apps/remark42/public/ /build/backend/app/cmd/web/
|
||||
|
||||
COPY --from=build-frontend /srv/frontend/public/ web
|
||||
RUN find /build/backend/app/cmd/web/ -regex '.*\.\(html\|js\|mjs\)$' -print -exec sed -i "s|{% REMARK_URL %}|http://127.0.0.1:8080|g" {} \;
|
||||
|
||||
RUN \
|
||||
export WEB_ROOT=/build/backend/web && \
|
||||
sed -i "s|https://demo.remark42.com|http://127.0.0.1:8080|g" ${WEB_ROOT}/*.js && \
|
||||
sed -i "/REMOVE-START/,/REMOVE-END/d" ${WEB_ROOT}/iframe.html && \
|
||||
statik --src=${WEB_ROOT} --dest=/build/backend/app/rest -p api -f && \
|
||||
ls -la /build/backend/app/rest/api/statik.go && \
|
||||
ls -la /build/backend/web/
|
||||
|
||||
# if DRONE presented use DRONE_* git env to make version
|
||||
RUN \
|
||||
if [ -z "$DRONE" ] ; then \
|
||||
echo "runs outside of drone" && version=$(/script/git-rev.sh); \
|
||||
else version=${DRONE_TAG}${DRONE_BRANCH}${DRONE_PULL_REQUEST}-${DRONE_COMMIT:0:7}-$(date +%Y%m%d-%H:%M:%S); fi && \
|
||||
echo "version=$version" && \
|
||||
export GOFLAGS="-mod=vendor" && \
|
||||
version=$("/script/version.sh") && echo "version=${version}" && \
|
||||
GOOS=linux GOARCH=amd64 go build -o remark42.linux-amd64 -ldflags "-X main.revision=${version} -s -w" ./app && \
|
||||
GOOS=linux GOARCH=386 go build -o remark42.linux-386 -ldflags "-X main.revision=${version} -s -w" ./app && \
|
||||
GOOS=linux GOARCH=arm go build -o remark42.linux-arm -ldflags "-X main.revision=${version} -s -w" ./app && \
|
||||
GOOS=linux GOARCH=arm64 go build -o remark42.linux-arm64 -ldflags "-X main.revision=${version} -s -w" ./app && \
|
||||
GOOS=windows GOARCH=amd64 go build -o remark42.windows-amd64.exe -ldflags "-X main.revision=${version} -s -w" ./app && \
|
||||
GOOS=darwin GOARCH=amd64 go build -o remark42.darwin-amd64 -ldflags "-X main.revision=${version} -s -w" ./app && \
|
||||
GOOS=darwin GOARCH=arm64 go build -o remark42.darwin-arm64 -ldflags "-X main.revision=${version} -s -w" ./app && \
|
||||
GOOS=freebsd GOARCH=amd64 go build -o remark42.freebsd-amd64 -ldflags "-X main.revision=${version} -s -w" ./app
|
||||
|
||||
RUN \
|
||||
if [ -z "$DRONE_TAG" ] ; then \
|
||||
echo "runs outside of drone" && tag=""; \
|
||||
else tag=_${DRONE_TAG}; fi && \
|
||||
apk add --no-cache --update zip && \
|
||||
cp ../LICENSE ./LICENSE && cp ../README.md ./README.md && \
|
||||
tar cvzf remark42${tag}.linux-amd64.tar.gz remark42.linux-amd64 LICENSE README.md && \
|
||||
tar cvzf remark42${tag}.linux-386.tar.gz remark42.linux-386 LICENSE README.md && \
|
||||
tar cvzf remark42${tag}.linux-arm.tar.gz remark42.linux-arm LICENSE README.md && \
|
||||
tar cvzf remark42${tag}.linux-arm64.tar.gz remark42.linux-arm64 LICENSE README.md && \
|
||||
tar cvzf remark42${tag}.darwin-amd64.tar.gz remark42.darwin-amd64 LICENSE README.md && \
|
||||
tar cvzf remark42${tag}.freebsd-amd64.tar.gz remark42.freebsd-amd64 LICENSE README.md && \
|
||||
zip remark42${tag}.windows-amd64.zip remark42.windows-amd64.exe LICENSE README.md
|
||||
tar cvzf remark42.linux-amd64.tar.gz remark42.linux-amd64 LICENSE README.md && \
|
||||
tar cvzf remark42.linux-386.tar.gz remark42.linux-386 LICENSE README.md && \
|
||||
tar cvzf remark42.linux-arm.tar.gz remark42.linux-arm LICENSE README.md && \
|
||||
tar cvzf remark42.linux-arm64.tar.gz remark42.linux-arm64 LICENSE README.md && \
|
||||
tar cvzf remark42.darwin-amd64.tar.gz remark42.darwin-amd64 LICENSE README.md && \
|
||||
tar cvzf remark42.darwin-arm64.tar.gz remark42.darwin-arm64 LICENSE README.md && \
|
||||
tar cvzf remark42.freebsd-amd64.tar.gz remark42.freebsd-amd64 LICENSE README.md && \
|
||||
zip remark42.windows-amd64.zip remark42.windows-amd64.exe LICENSE README.md
|
||||
|
||||
# upload to github
|
||||
#RUN \
|
||||
# if [ -z "$DRONE_TAG" ] ; then \
|
||||
# echo "skip upload to github" ; \
|
||||
# else \
|
||||
# curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
|
||||
# -H "Content-Type: application/gzip" --data-binary @remark42_${DRONE_TAG}.linux-amd64.tar.gz \
|
||||
# "https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.linux-amd64.tar.gz" && \
|
||||
# curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
|
||||
# -H "Content-Type: application/gzip" --data-binary @remark42_${DRONE_TAG}.linux-386.tar.gz \
|
||||
# "https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.linux-386.tar.gz" && \
|
||||
# curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
|
||||
# -H "Content-Type: application/gzip" --data-binary @remark42_${DRONE_TAG}.linux-arm64.tar.gz \
|
||||
# "https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.linux-arm64.tar.gz" && \
|
||||
# curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
|
||||
# -H "Content-Type: application/gzip" --data-binary @remark42_${DRONE_TAG}.darwin-amd64.tar.gz \
|
||||
# "https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.darwin-amd64.tar.gz" && \
|
||||
# curl -H "Authorization: token $GITHUB_TOKEN" -H "Accept: application/vnd.github.manifold-preview" \
|
||||
# -H "Content-Type: application/zip" --data-binary @remark42_${DRONE_TAG}.windows-amd64.zip \
|
||||
# "https://uploads.github.com/repos/umputun/remark/releases/${DRONE_TAG}/assets?name=remark_${DRONE_TAG}.windows-amd64.zip"; fi
|
||||
|
||||
FROM alpine
|
||||
COPY --from=build-backend /build/backend/remark42.* /artifacts/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Umputun
|
||||
Copyright (c) 2021 Umputun
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
OS=linux
|
||||
ARCH=amd64
|
||||
GITHUB_REF=$(shell git rev-parse --symbolic-full-name HEAD)
|
||||
GITHUB_SHA=$(shell git rev-parse --short HEAD)
|
||||
|
||||
bin:
|
||||
docker build -f Dockerfile.artifacts -t remark42.bin .
|
||||
@@ -9,10 +11,18 @@ bin:
|
||||
docker rm -f remark42.bin
|
||||
|
||||
docker:
|
||||
docker build -t umputun/remark42 --build-arg SKIP_FRONTEND_TEST=true --build-arg SKIP_BACKEND_TEST=true .
|
||||
DOCKER_BUILDKIT=1 docker build -t umputun/remark42 --build-arg GITHUB_REF=$(GITHUB_REF) --build-arg GITHUB_SHA=$(GITHUB_SHA) \
|
||||
--build-arg CI=true --build-arg SKIP_FRONTEND_TEST=true --build-arg SKIP_BACKEND_TEST=true .
|
||||
|
||||
deploy:
|
||||
docker build -f Dockerfile.artifacts -t remark42.bin .
|
||||
dockerx:
|
||||
docker buildx build --build-arg GITHUB_REF=$(GITHUB_REF) --build-arg GITHUB_SHA=$(GITHUB_SHA) --build-arg CI=true \
|
||||
--build-arg SKIP_FRONTEND_TEST=true --build-arg SKIP_BACKEND_TEST=true \
|
||||
--progress=plain --platform linux/amd64,linux/arm/v7,linux/arm64 \
|
||||
-t ghcr.io/umputun/remark42:master -t umputun/remark42:master .
|
||||
|
||||
release:
|
||||
docker build -f Dockerfile.artifacts --no-cache --pull --build-arg CI=true \
|
||||
--build-arg GITHUB_REF=$(GITHUB_REF) --build-arg GITHUB_SHA=$(GITHUB_SHA) -t remark42.bin .
|
||||
- @docker rm -f remark42.bin 2>/dev/null || exit 0
|
||||
- @mkdir -p bin
|
||||
docker run -d --name=remark42.bin remark42.bin
|
||||
@@ -20,12 +30,13 @@ deploy:
|
||||
docker cp remark42.bin:/artifacts/remark42.linux-386.tar.gz bin/remark42.linux-386.tar.gz
|
||||
docker cp remark42.bin:/artifacts/remark42.linux-arm64.tar.gz bin/remark42.linux-arm64.tar.gz
|
||||
docker cp remark42.bin:/artifacts/remark42.darwin-amd64.tar.gz bin/remark42.darwin-amd64.tar.gz
|
||||
docker cp remark42.bin:/artifacts/remark42.darwin-arm64.tar.gz bin/remark42.darwin-arm64.tar.gz
|
||||
docker cp remark42.bin:/artifacts/remark42.freebsd-amd64.tar.gz bin/remark42.freebsd-amd64.tar.gz
|
||||
docker cp remark42.bin:/artifacts/remark42.windows-amd64.zip bin/remark42.windows-amd64.zip
|
||||
docker rm -f remark42.bin
|
||||
|
||||
race_test:
|
||||
cd backend/app && go test -race -mod=vendor -timeout=60s -count 1 ./...
|
||||
cd backend/app && go test -race -timeout=60s -count 1 ./...
|
||||
|
||||
backend:
|
||||
docker-compose -f compose-dev-backend.yml build
|
||||
@@ -34,7 +45,11 @@ frontend:
|
||||
docker-compose -f compose-dev-frontend.yml build
|
||||
|
||||
rundev:
|
||||
SKIP_BACKEND_TEST=true SKIP_FRONTEND_TEST=true docker-compose -f compose-private.yml build
|
||||
SKIP_BACKEND_TEST=true SKIP_FRONTEND_TEST=true GITHUB_REF=$(GITHUB_REF) GITHUB_SHA=$(GITHUB_SHA) CI=true \
|
||||
docker-compose -f compose-private.yml build
|
||||
docker-compose -f compose-private.yml up
|
||||
|
||||
.PHONY: bin backend
|
||||
e2e:
|
||||
docker compose -f compose-e2e-test.yml up --build --quiet-pull --exit-code-from tests
|
||||
|
||||
.PHONY: bin backend
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# remark42 [](https://github.com/umputun/remark/actions) [](https://goreportcard.com/report/github.com/umputun/remark) [](https://coveralls.io/github/umputun/remark?branch=master)
|
||||
# Remark42 [](https://github.com/umputun/remark42/actions) [](https://hub.docker.com/r/umputun/remark42) [](https://goreportcard.com/report/github.com/umputun/remark42) [](https://coveralls.io/github/umputun/remark42?branch=master) [](https://app.codecov.io/gh/umputun/remark42)
|
||||
|
||||
Remark42 is a self-hosted, lightweight and simple (yet functional) comment engine, which doesn't spy on users. It can be embedded into blogs, articles, or any other place where readers add comments.
|
||||
|
||||
|
||||
Remark42 is a self-hosted, lightweight, and simple (yet functional) comment engine, which doesn't spy on users. It can be embedded into blogs, articles or any other place where readers add comments.
|
||||
|
||||
* Social login via Google, Twitter, Facebook, GitHub and Yandex
|
||||
* Social login via Google, Twitter, Facebook, Microsoft, GitHub, Yandex, Patreon and Telegram
|
||||
* Login via email
|
||||
* Optional anonymous access
|
||||
* Multi-level nested comments with both tree and plain presentations
|
||||
@@ -16,849 +14,36 @@ Remark42 is a self-hosted, lightweight, and simple (yet functional) comment engi
|
||||
* Images upload with drag-and-drop
|
||||
* Extractor for recent comments, cross-post
|
||||
* RSS for all comments and each post
|
||||
* Telegram and email notifications
|
||||
* Export data to json with automatic backups
|
||||
* Telegram, Slack, Webhook and email notifications for Admins (get notified for each new comment)
|
||||
* Email and Telegram notifications for users (get notified when someone responds to your comment)
|
||||
* Export data to JSON with automatic backups
|
||||
* No external databases, everything embedded in a single data file
|
||||
* Fully dockerized and can be deployed in a single command
|
||||
* Self-contained executable can be deployed directly to Linux, Windows and MacOS
|
||||
* Self-contained executable can be deployed directly to Linux, Windows and macOS
|
||||
* Clean, lightweight and customizable UI with white and dark themes
|
||||
* Multi-site mode from a single instance
|
||||
* Integration with automatic ssl (direct and via [nginx-le](https://github.com/umputun/nginx-le))
|
||||
* [Privacy focused](#privacy)
|
||||
* Integration with automatic SSL (direct and via [nginx-le](https://github.com/nginx-le/nginx-le))
|
||||
* [Privacy focused](https://remark42.com/#privacy)
|
||||
|
||||
[Demo site](https://remark42.com/demo/) available with all authentication methods, including email auth and anonymous access.
|
||||
|
||||
<details><summary>Screenshots</summary>
|
||||
|
||||
Comments example:
|
||||

|
||||

|
||||
|
||||
For admin screenshots see [Admin UI wiki](https://github.com/umputun/remark/wiki/Admin-UI)
|
||||
For admin screenshots see [Admin UI documentation](https://remark42.com/docs/manuals/admin-interface/)
|
||||
</details>
|
||||
|
||||
All remark42 documentation is available [by the link](https://remark42.com/docs/getting-started/installation/).
|
||||
|
||||
#
|
||||
## Contribution
|
||||
|
||||
- [Install](#install)
|
||||
- [Backend](#backend)
|
||||
- [With Docker](#with-docker)
|
||||
- [Without docker](#without-docker)
|
||||
- [Parameters](#parameters)
|
||||
- [Required parameters](#required-parameters)
|
||||
- [Quick installation test](#quick-installation-test)
|
||||
- [Register oauth2 providers](#register-oauth2-providers)
|
||||
- [Google Auth Provider](#google-auth-provider)
|
||||
- [GitHub Auth Provider](#github-auth-provider)
|
||||
- [Facebook Auth Provider](#facebook-auth-provider)
|
||||
- [Twitter Auth Provider](#twitter-auth-provider)
|
||||
- [Yandex Auth Provider](#yandex-auth-provider)
|
||||
- [Initial import from Disqus](#initial-import-from-disqus)
|
||||
- [Initial import from WordPress](#initial-import-from-wordpress)
|
||||
- [Backup and restore](#backup-and-restore)
|
||||
- [Automatic backups](#automatic-backups)
|
||||
- [Manual backup](#manual-backup)
|
||||
- [Restore from backup](#restore-from-backup)
|
||||
- [Backup format](#backup-format)
|
||||
- [Admin users](#admin-users)
|
||||
- [Setup on your website](#setup-on-your-website)
|
||||
- [Comments](#comments)
|
||||
- [Last comments](#last-comments)
|
||||
- [Counter](#counter)
|
||||
- [Build from the source](#build-from-the-source)
|
||||
- [Development](#development)
|
||||
- [Backend development](#backend-development)
|
||||
- [Frontend development](#frontend-development)
|
||||
- [Build](#build)
|
||||
- [Devserver](#devserver)
|
||||
- [API](#api)
|
||||
- [Authorization](#authorization)
|
||||
- [Commenting](#commenting)
|
||||
- [RSS feeds](#rss-feeds)
|
||||
- [Admin](#admin)
|
||||
- [Privacy](#privacy)
|
||||
- [Technical details](#technical-details)
|
||||
In order to start and work on the project locally in development mode check our contribution documentation for [backend](https://remark42.com/docs/contributing/backend/) and [frontend](https://remark42.com/docs/contributing/frontend/).
|
||||
|
||||
If you are interested in adding a new localization please check [these docs](https://remark42.com/docs/contributing/translations/).
|
||||
|
||||
## Install
|
||||
## Related projects
|
||||
|
||||
### Backend
|
||||
|
||||
#### With Docker
|
||||
|
||||
_this is the recommended way to run remark42_
|
||||
|
||||
* copy provided `docker-compose.yml` and customize for your needs
|
||||
* make sure you **don't keep** `ADMIN_PASSWD=something...` for any non-development deployments
|
||||
* pull prepared images from the docker hub and start - `docker-compose pull && docker-compose up -d`
|
||||
* alternatively compile from the sources - `docker-compose build && docker-compose up -d`
|
||||
|
||||
#### Without docker
|
||||
|
||||
* download archive for [stable release](https://github.com/umputun/remark/releases) or [development version](https://remark42.com/downloads)
|
||||
* unpack with `gunzip` (Linux, macOS) or with `zip` (Windows)
|
||||
* run as `remark42.{os}-{arch} server {parameters...}`, i.e. `remark42.linux-amd64 server --secret=12345 --url=http://127.0.0.1:8080`
|
||||
* alternatively compile from the sources - `make OS=[linux|darwin|windows] ARCH=[amd64,386,arm64,arm32]`
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Command line | Environment | Default | Description |
|
||||
| ----------------------- | ----------------------- | ------------------------ | ----------------------------------------------- |
|
||||
| url | REMARK_URL | | url to remark42 server, _required_ |
|
||||
| secret | SECRET | | secret key, _required_ |
|
||||
| site | SITE | `remark` | site name(s), _multi_ |
|
||||
| store.type | STORE_TYPE | `bolt` | type of storage, `bolt` or `rpc` |
|
||||
| store.bolt.path | STORE_BOLT_PATH | `./var` | path to data directory |
|
||||
| store.bolt.timeout | STORE_BOLT_TIMEOUT | `30s` | boltdb access timeout |
|
||||
| admin.shared.id | ADMIN_SHARED_ID | | admin names (list of user ids), _multi_ |
|
||||
| admin.shared.email | ADMIN_SHARED_EMAIL | `admin@${REMARK_URL}` | admin email |
|
||||
| backup | BACKUP_PATH | `./var/backup` | backups location |
|
||||
| max-back | MAX_BACKUP_FILES | `10` | max backup files to keep |
|
||||
| cache.max.items | CACHE_MAX_ITEMS | `1000` | max number of cached items, `0` - unlimited |
|
||||
| cache.max.value | CACHE_MAX_VALUE | `65536` | max size of cached value, `0` - unlimited |
|
||||
| cache.max.size | CACHE_MAX_SIZE | `50000000` | max size of all cached values, `0` - unlimited |
|
||||
| avatar.type | AVATAR_TYPE | `fs` | type of avatar storage, `fs`, `bolt`, or `uri` |
|
||||
| avatar.fs.path | AVATAR_FS_PATH | `./var/avatars` | avatars location for `fs` store |
|
||||
| avatar.bolt.file | AVATAR_BOLT_FILE | `./var/avatars.db` | file name for `bolt` store |
|
||||
| avatar.uri | AVATAR_URI | `./var/avatars` | avatar store uri |
|
||||
| avatar.rsz-lmt | AVATAR_RSZ_LMT | `0` (disabled) | max image size for resizing avatars on save |
|
||||
| image.type | IMAGE_TYPE | `fs` | type of image storage, `fs`, `bolt` |
|
||||
| image.max-size | IMAGE_MAX_SIZE | `5000000` | max size of image file |
|
||||
| image.fs.path | IMAGE_FS_PATH | `./var/pictures` | permanent location of images |
|
||||
| image.fs.staging | IMAGE_FS_STAGING | `./var/pictures.staging` | staging location of images |
|
||||
| image.fs.partitions | IMAGE_FS_PARTITIONS | `100` | number of image partitions |
|
||||
| image.bolt.file | IMAGE_BOLT_FILE | `/var/pictures.db` | images bolt file location |
|
||||
| image.resize-width | IMAGE_RESIZE_WIDTH | `2400` | width of resized image |
|
||||
| image.resize-height | IMAGE_RESIZE_HEIGHT | `900` | height of resized image |
|
||||
| auth.ttl.jwt | AUTH_TTL_JWT | `5m` | jwt TTL |
|
||||
| auth.ttl.cookie | AUTH_TTL_COOKIE | `200h` | cookie TTL |
|
||||
| auth.google.cid | AUTH_GOOGLE_CID | | Google OAuth client ID |
|
||||
| auth.google.csec | AUTH_GOOGLE_CSEC | | Google OAuth client secret |
|
||||
| auth.facebook.cid | AUTH_FACEBOOK_CID | | Facebook OAuth client ID |
|
||||
| auth.facebook.csec | AUTH_FACEBOOK_CSEC | | Facebook OAuth client secret |
|
||||
| auth.github.cid | AUTH_GITHUB_CID | | Github OAuth client ID |
|
||||
| auth.github.csec | AUTH_GITHUB_CSEC | | Github OAuth client secret |
|
||||
| auth.twitter.cid | AUTH_TWITTER_CID | | Twitter Consumer API Key |
|
||||
| auth.twitter.csec | AUTH_TWITTER_CSEC | | Twitter Consumer API Secret key |
|
||||
| auth.yandex.cid | AUTH_YANDEX_CID | | Yandex OAuth client ID |
|
||||
| auth.yandex.csec | AUTH_YANDEX_CSEC | | Yandex OAuth client secret |
|
||||
| auth.dev | AUTH_DEV | `false` | local oauth2 server, development mode only |
|
||||
| auth.anon | AUTH_ANON | `false` | enable anonymous login |
|
||||
| auth.email.enable | AUTH_EMAIL_ENABLE | `false` | enable auth via email |
|
||||
| auth.email.from | AUTH_EMAIL_FROM | | email from |
|
||||
| auth.email.subj | AUTH_EMAIL_SUBJ | `remark42 confirmation` | email subject |
|
||||
| auth.email.content-type | AUTH_EMAIL_CONTENT_TYPE | `text/html` | email content type |
|
||||
| auth.email.template | AUTH_EMAIL_TEMPLATE | none (predefined) | custom email message template file |
|
||||
| notify.type | NOTIFY_TYPE | none | type of notification (telegram and/or email) |
|
||||
| notify.queue | NOTIFY_QUEUE | `100` | size of notification queue |
|
||||
| notify.telegram.token | NOTIFY_TELEGRAM_TOKEN | | telegram token |
|
||||
| notify.telegram.chan | NOTIFY_TELEGRAM_CHAN | | telegram channel |
|
||||
| notify.telegram.timeout | NOTIFY_TELEGRAM_TIMEOUT | `5s` | telegram timeout |
|
||||
| notify.email.fromAddress | NOTIFY_EMAIL_FROM | | from email address |
|
||||
| notify.email.verification_subj | NOTIFY_EMAIL_VERIFICATION_SUBJ | `Email verification` | verification message subject |
|
||||
| smtp.host | SMTP_HOST | | SMTP host |
|
||||
| smtp.port | SMTP_PORT | | SMTP port |
|
||||
| smtp.username | SMTP_USERNAME | | SMTP user name |
|
||||
| smtp.password | SMTP_PASSWORD | | SMTP password |
|
||||
| smtp.tls | SMTP_TLS | | enable TLS for SMTP |
|
||||
| smtp.timeout | SMTP_TIMEOUT | `10s` | SMTP TCP connection timeout |
|
||||
| ssl.type | SSL_TYPE | none | `none`-http, `static`-https, `auto`-https + le |
|
||||
| ssl.port | SSL_PORT | `8443` | port for https server |
|
||||
| ssl.cert | SSL_CERT | | path to cert.pem file |
|
||||
| ssl.key | SSL_KEY | | path to key.pem file |
|
||||
| ssl.acme-location | SSL_ACME_LOCATION | `./var/acme` | dir where obtained le-certs will be stored |
|
||||
| ssl.acme-email | SSL_ACME_EMAIL | | admin email for receiving notifications from LE |
|
||||
| max-comment | MAX_COMMENT_SIZE | `2048` | comment's size limit |
|
||||
| max-votes | MAX_VOTES | `-1` | votes limit per comment, `-1` - unlimited |
|
||||
| votes-ip | VOTES_IP | `false` | restrict votes from the same ip |
|
||||
| anon-vote | ANON_VOTE | `false` | allow voting for anonymous users, require VOTES_IP to be enabled as well |
|
||||
| votes-ip-time | VOTES_IP_TIME | `5m` | same ip vote restriction time, `0s` - unlimited |
|
||||
| low-score | LOW_SCORE | `-5` | low score threshold |
|
||||
| critical-score | CRITICAL_SCORE | `-10` | critical score threshold |
|
||||
| positive-score | POSITIVE_SCORE | `false` | restricts comment's score to be only positive |
|
||||
| restricted-words | RESTRICTED_WORDS | | words banned in comments (can use `*`), _multi_ |
|
||||
| edit-time | EDIT_TIME | `5m` | edit window |
|
||||
| read-age | READONLY_AGE | | read-only age of comments, days |
|
||||
| image-proxy.http2https | IMAGE_PROXY_HTTP2HTTPS | `false` | enable http->https proxy for images |
|
||||
| image-proxy.cache-external | IMAGE_PROXY_CACHE_EXTERNAL | `false` | enable caching external images to current image storage |
|
||||
| emoji | EMOJI | `false` | enable emoji support |
|
||||
| simple-view | SIMPLE_VIEW | `false` | minimized UI with basic info only |
|
||||
| port | REMARK_PORT | `8080` | web server port |
|
||||
| web-root | REMARK_WEB_ROOT | `./web` | web server root directory |
|
||||
| update-limit | UPDATE_LIMIT | `0.5` | updates/sec limit |
|
||||
| admin-passwd | ADMIN_PASSWD | none (disabled) | password for `admin` basic auth |
|
||||
| dbg | DEBUG | `false` | debug mode |
|
||||
|
||||
* command line parameters are long form `--<key>=value`, i.e. `--site=https://demo.remark42.com`
|
||||
* _multi_ parameters separated by `,` in the environment or repeated with command line key, like `--site=s1 --site=s2 ...`
|
||||
* _required_ parameters have to be presented in the environment or provided in command line
|
||||
|
||||
##### Deprecated
|
||||
|
||||
Following list of command-line options is deprecated and will be removed in 2 minor releases or 1 major release (whichever is closer)
|
||||
from the version in which they were deprecated. After remark42 version update, please check startup log once for deprecation warnings to avoid
|
||||
trouble with unrecognized command-line options in the future.
|
||||
|
||||
<details>
|
||||
<summary>deprecated options</summary>
|
||||
|
||||
| Command line | Replacement | Environment | Replacement | Default | Description | Deprecation version |
|
||||
| ------------------ | ------------- | ------------------ | ------------- | ------- | -------------- | ------------------- |
|
||||
| auth.email.host | smtp.host | AUTH_EMAIL_HOST | SMTP_HOST | | smtp host | 1.5.0 |
|
||||
| auth.email.port | smtp.port | AUTH_EMAIL_PORT | SMTP_PORT | | smtp port | 1.5.0 |
|
||||
| auth.email.user | smtp.username | AUTH_EMAIL_USER | SMTP_USERNAME | | smtp user name | 1.5.0 |
|
||||
| auth.email.passwd | smtp.password | AUTH_EMAIL_PASSWD | SMTP_PASSWORD | | smtp password | 1.5.0 |
|
||||
| auth.email.tls | smtp.tls | AUTH_EMAIL_TLS | SMTP_TLS | `false` | enable TLS | 1.5.0 |
|
||||
| auth.email.timeout | smtp.timeout | AUTH_EMAIL_TIMEOUT | SMTP_TIMEOUT | `10s` | smtp timeout | 1.5.0 |
|
||||
| img-proxy | image-proxy.http2https | IMG_PROXY | IMAGE_PROXY_HTTP2HTTPS | `false` | enable http->https proxy for images | 1.5.0 |
|
||||
</details>
|
||||
|
||||
##### Required parameters
|
||||
|
||||
Most of the parameters have sane defaults and don't require customization. There are only a few parameters user has to define:
|
||||
|
||||
1. `SECRET` - secret key, can be any long and hard-to-guess string.
|
||||
2. `REMARK_URL` - url pointing to your remark42 server, i.e. `https://demo.remark42.com`
|
||||
3. At least one pair of `AUTH_<PROVIDER>_CID` and `AUTH_<PROVIDER>_CSEC` defining oauth2 provider(s)
|
||||
|
||||
The minimal `docker-compose.yml` has to include all required parameters:
|
||||
|
||||
```yaml
|
||||
version: '2'
|
||||
|
||||
services:
|
||||
remark42:
|
||||
image: umputun/remark42:latest
|
||||
restart: always
|
||||
container_name: "remark42"
|
||||
environment:
|
||||
- REMARK_URL=https://demo.remark42.com # url pointing to your remark42 server
|
||||
- SITE=YOUR_SITE_ID # site ID, same as used for `site_id`, see "Setup on your website"
|
||||
- SECRET=abcd-123456-xyz-$%^& # secret key
|
||||
- AUTH_GITHUB_CID=12345667890 # oauth2 client ID
|
||||
- AUTH_GITHUB_CSEC=abcdefg12345678 # oauth2 client secret
|
||||
volumes:
|
||||
- ./var:/srv/var # persistent volume to store all remark42 data
|
||||
```
|
||||
|
||||
#### Quick installation test
|
||||
|
||||
To verify if remark has been properly installed, check a demo page at `${REMARK_URL}/web` URL. Make sure to include `remark` site id to `${SITE}` list.
|
||||
|
||||
#### Register oauth2 providers
|
||||
|
||||
Authentication handled by external providers. You should setup oauth2 for all (or some) of them to allow users to make comments. It is not mandatory to have all of them, but at least one should be correctly configured.
|
||||
|
||||
##### Google Auth Provider
|
||||
|
||||
1. Create a new project: https://console.developers.google.com/project
|
||||
1. Choose the new project from the top right project dropdown (only if another project is selected)
|
||||
1. In the project Dashboard center pane, choose **"API Manager"**
|
||||
1. In the left Nav pane, choose **"Credentials"**
|
||||
1. In the center pane, choose **"OAuth consent screen"** tab. Fill in **"Product name shown to users"** and hit save.
|
||||
1. In the center pane, choose **"Credentials"** tab.
|
||||
* Open the **"New credentials"** drop down
|
||||
* Choose **"OAuth client ID"**
|
||||
* Choose **"Web application"**
|
||||
* Application name is freeform, choose something appropriate
|
||||
* Authorized origins is your domain ex: `https://remark42.mysite.com`
|
||||
* Authorized redirect URIs is the location of oauth2/callback constructed as domain + `/auth/google/callback`, ex: `https://remark42.mysite.com/auth/google/callback`
|
||||
* Choose **"Create"**
|
||||
1. Take note of the **Client ID** and **Client Secret**
|
||||
|
||||
_instructions for google oauth2 setup borrowed from [oauth2_proxy](https://github.com/bitly/oauth2_proxy)_
|
||||
|
||||
##### GitHub Auth Provider
|
||||
|
||||
1. Create a new **"OAuth App"**: https://github.com/settings/developers
|
||||
1. Fill **"Application Name"** and **"Homepage URL"** for your site
|
||||
1. Under **"Authorization callback URL"** enter the correct url constructed as domain + `/auth/github/callback`. ie `https://remark42.mysite.com/auth/github/callback`
|
||||
1. Take note of the **Client ID** and **Client Secret**
|
||||
|
||||
##### Facebook Auth Provider
|
||||
|
||||
1. From https://developers.facebook.com select **"My Apps"** / **"Add a new App"**
|
||||
1. Set **"Display Name"** and **"Contact email"**
|
||||
1. Choose **"Facebook Login"** and then **"Web"**
|
||||
1. Set "Site URL" to your domain, ex: `https://remark42.mysite.com`
|
||||
1. Under **"Facebook login"** / **"Settings"** fill "Valid OAuth redirect URIs" with your callback url constructed as domain + `/auth/facebook/callback`
|
||||
1. Select **"App Review"** and turn public flag on. This step may ask you to provide a link to your privacy policy.
|
||||
|
||||
##### Twitter Auth Provider
|
||||
|
||||
1. Create a new twitter application https://developer.twitter.com/en/apps
|
||||
1. Fill **App name**, **Description** and **URL** of your site
|
||||
1. In the field **Callback URLs** enter the correct url of your callback handler e.g. domain + `/auth/twitter/callback`
|
||||
1. Under **Key and tokens** take note of the **Consumer API Key** and **Consumer API Secret key**. Those will be used as `AUTH_TWITTER_CID` and
|
||||
`AUTH_TWITTER_CSEC`
|
||||
|
||||
##### Yandex Auth Provider
|
||||
|
||||
1. Create a new **"OAuth App"**: https://oauth.yandex.com/client/new
|
||||
1. Fill **"App name"** for your site
|
||||
1. Under **Platforms** select **"Web services"** and enter **"Callback URI #1"** constructed as domain + `/auth/yandex/callback`. ie `https://remark42.mysite.com/auth/yandex/callback`
|
||||
1. Select **Permissions**. You need following permissions only from the **"Yandex.Passport API"** section:
|
||||
* Access to user avatar
|
||||
* Access to username, first name and surname, gender
|
||||
1. Fill out the rest of fields if needed
|
||||
1. Take note of the **ID** and **Password**
|
||||
|
||||
For more details refer to [Yandex OAuth](https://tech.yandex.com/oauth/doc/dg/concepts/about-docpage/) and [Yandex.Passport](https://tech.yandex.com/passport/doc/dg/index-docpage/) API documentation.
|
||||
|
||||
##### Anonymous Auth Provider
|
||||
|
||||
Optionally, anonymous access can be turned on. In this case an extra `anonymous` provider will allow logins without any social login with any name satisfying 2 conditions:
|
||||
|
||||
- name should be at least 3 characters long
|
||||
- name has to start from the letter and contains letters, numbers, underscores and spaces only.
|
||||
|
||||
#### Initial import from Disqus
|
||||
|
||||
1. Disqus provides an export of all comments on your site in a g-zipped file. This is found in your Moderation panel at Disqus Admin > Setup > Export. The export will be sent into a queue and then emailed to the address associated with your account once it's ready. Direct link to export will be something like `https://<siteud>.disqus.com/admin/discussions/export/`. See [importing-exporting](https://help.disqus.com/customer/portal/articles/1104797-importing-exporting) for more details.
|
||||
2. Move this file to your remark42 host within `./var` and unzip, i.e. `gunzip <disqus-export-name>.xml.gz`.
|
||||
3. Run import command - `docker exec -it remark42 import -p disqus -f {disqus-export-name}.xml -s {your site id}`
|
||||
|
||||
#### Initial import from WordPress
|
||||
|
||||
1. Install WordPress [plugin](https://wordpress.org/plugins/wp-exporter/) to export comments and follow it instructions. The plugin should produce a xml-based file with site content including comments.
|
||||
2. Move this file to your remark42 host within `./var`
|
||||
3. Run import command - `docker exec -it remark42 import -p wordpress -f {wordpress-export-name}.xml -s {your site id}`
|
||||
|
||||
#### Backup and restore
|
||||
|
||||
##### Automatic backups
|
||||
Remark42 by default makes daily backup files under `${BACKUP_PATH}` (default `./var/backup`). Backups kept up to `${MAX_BACKUP_FILES}` (default 10). Each backup file contains exported and gzipped content, i.e., all comments. At any point, the user can restore such backup and revert all comments to the desirable state. Note: restore procedure cleans the current data store and replaces all comments with comments from the backup file.
|
||||
|
||||
For safety and security reasons restore functionality not exposed outside of your server by default. The recommended way to restore from the backup is to use provided `scripts/restore-backup.sh`. It can run inside the container:
|
||||
|
||||
`docker exec -it remark42 restore -f {backup-filename.gz} -s {your site id}`
|
||||
|
||||
##### Manual backup
|
||||
|
||||
In addition to automatic backups user can make a backup manually. This command makes `userbackup-{site id}-{timestamp}.gz` by default.
|
||||
|
||||
`docker exec -it remark42 backup -s {your site id}`
|
||||
|
||||
##### Restore from backup
|
||||
|
||||
Restore will clean all comments first and then will processed with complete import from a given file.
|
||||
|
||||
`docker exec -it remark42 restore -f {backup file name} -s {your site id}`
|
||||
|
||||
##### Backup format
|
||||
|
||||
Backup file is a text file with all exported comments separated by EOL. Each backup record is a valid json with all key/value
|
||||
unmarshaled from `Comment` struct (see below).
|
||||
|
||||
#### Admin users
|
||||
|
||||
Admins/moderators should be defined in `docker-compose.yml` as a list of user IDs or passed in the command line.
|
||||
|
||||
```
|
||||
environment:
|
||||
- ADMIN_SHARED_ID=github_ef0f706a79cc24b17bbbb374cd234a691a034128,github_dae9983158e9e5e127ef2b87a411ef13c891e9e5
|
||||
```
|
||||
|
||||
To get user id just login and click on your username or any other user you want to promote to admins.
|
||||
It will expand login info and show full user ID.
|
||||
|
||||
#### Docker parameters
|
||||
|
||||
Two parameters allow to customize docker container on the system level:
|
||||
|
||||
- `APP_UID` - sets UID to run remark42 application in container (default=1001)
|
||||
- `TIME_ZONE` - sets time zone of remark42 container (default=America/Chicago)
|
||||
|
||||
_see [umputun/baseimage](https://github.com/umputun/baseimage) for more details_
|
||||
|
||||
example of compose:
|
||||
|
||||
```yaml
|
||||
version: '2'
|
||||
|
||||
services:
|
||||
remark42:
|
||||
image: umputun/remark42:latest
|
||||
restart: always
|
||||
container_name: "remark42"
|
||||
environment:
|
||||
- APP_UID=2000 # runs remark42 app with non-default UID
|
||||
- TIME_ZONE=GTC # sets container time to UTC
|
||||
|
||||
- REMARK_URL=https://demo.remark42.com # url pointing to your remark42 server
|
||||
- SITE=YOUR_SITE_ID # site ID, same as used for `site_id`, see "Setup on your website"
|
||||
- SECRET=abcd-123456-xyz-$%^& # secret key
|
||||
- AUTH_GITHUB_CID=12345667890 # oauth2 client ID
|
||||
- AUTH_GITHUB_CSEC=abcdefg12345678 # oauth2 client secret
|
||||
volumes:
|
||||
- ./var:/srv/var # persistent volume to store all remark42 data
|
||||
```
|
||||
|
||||
|
||||
### Setup on your website
|
||||
|
||||
#### Comments
|
||||
|
||||
It's a main widget which renders list of comments.
|
||||
|
||||
Add this snippet to the bottom of web page:
|
||||
|
||||
```html
|
||||
<script>
|
||||
var remark_config = {
|
||||
host: "REMARK_URL", // hostname of remark server, same as REMARK_URL in backend config, e.g. "https://demo.remark42.com"
|
||||
site_id: 'YOUR_SITE_ID',
|
||||
components: ['embed'], // optional param; which components to load. default to ["embed"]
|
||||
// to load all components define components as ['embed', 'last-comments', 'counter']
|
||||
// available component are:
|
||||
// - 'embed': basic comments widget
|
||||
// - 'last-comments': last comments widget, see `Last Comments` section below
|
||||
// - 'counter': counter widget, see `Counter` section below
|
||||
url: 'PAGE_URL', // optional param; if it isn't defined
|
||||
// `window.location.origin + window.location.pathname` will be used,
|
||||
//
|
||||
// Note that if you use query parameters as significant part of url
|
||||
// (the one that actually changes content on page)
|
||||
// you will have to configure url manually to keep query params, as
|
||||
// `window.location.origin + window.location.pathname` doesn't contain query params and
|
||||
// hash. For example default url for `https://example/com/example-post?id=1#hash`
|
||||
// would be `https://example/com/example-post`.
|
||||
//
|
||||
// The problem with query params is that they often contain useless params added by
|
||||
// various trackers (utm params) and doesn't have defined order, so Remark treats differently
|
||||
// all this examples:
|
||||
// https://example.com/?postid=1&date=2007-02-11
|
||||
// https://example.com/?date=2007-02-11&postid=1
|
||||
// https://example.com/?date=2007-02-11&postid=1&utm_source=google
|
||||
//
|
||||
// If you deal with query parameters make sure you pass only significant part of it
|
||||
// in well defined order
|
||||
max_shown_comments: 10, // optional param; if it isn't defined default value (15) will be used
|
||||
theme: 'dark', // optional param; if it isn't defined default value ('light') will be used
|
||||
page_title: 'Moving to Remark42' // optional param; if it isn't defined `document.title` will be used
|
||||
};
|
||||
|
||||
(function(c) {
|
||||
for(var i = 0; i < c.length; i++){
|
||||
var d = document, s = d.createElement('script');
|
||||
s.src = remark_config.host + '/web/' +c[i] +'.js';
|
||||
s.defer = true;
|
||||
(d.head || d.body).appendChild(s);
|
||||
}
|
||||
})(remark_config.components || ['embed']);
|
||||
</script>
|
||||
```
|
||||
|
||||
And then add this node in the place where you want to see Remark42 widget:
|
||||
|
||||
```html
|
||||
<div id="remark42"></div>
|
||||
```
|
||||
|
||||
After that widget will be rendered inside this node.
|
||||
|
||||
##### Themes
|
||||
|
||||
Right now Remark has two themes: light and dark.
|
||||
You can pick one using configuration object,
|
||||
but there is also a possibility to switch between themes in runtime.
|
||||
For this purpose Remark adds to `window` object named `REMARK42`,
|
||||
which contains function `changeTheme`.
|
||||
Just call this function and pass a name of the theme that you want to turn on:
|
||||
|
||||
```js
|
||||
window.REMARK42.changeTheme('light');
|
||||
```
|
||||
|
||||
#### Last comments
|
||||
|
||||
It's a widget which renders list of last comments from your site.
|
||||
|
||||
Add this snippet to the bottom of web page, or adjust already present `remark_config` to have `last-comments` in `components` list:
|
||||
|
||||
```html
|
||||
<script>
|
||||
var remark_config = {
|
||||
host: "REMARK_URL", // hostname of remark server, same as REMARK_URL in backend config, e.g. "https://demo.remark42.com"
|
||||
site_id: 'YOUR_SITE_ID',
|
||||
components: ['last-comments']
|
||||
};
|
||||
|
||||
(function(c) {
|
||||
for(var i = 0; i < c.length; i++){
|
||||
var d = document, s = d.createElement('script');
|
||||
s.src = remark_config.host + '/web/' +c[i] +'.js';
|
||||
s.defer = true;
|
||||
(d.head || d.body).appendChild(s);
|
||||
}
|
||||
})(remark_config.components || ['embed']);
|
||||
</script>
|
||||
```
|
||||
|
||||
And then add this node in the place where you want to see last comments widget:
|
||||
|
||||
```html
|
||||
<div class="remark42__last-comments" data-max="50"></div>
|
||||
```
|
||||
|
||||
`data-max` sets the max amount of comments (default: `15`).
|
||||
|
||||
#### Counter
|
||||
|
||||
It's a widget which renders a number of comments for the specified page.
|
||||
|
||||
Add this snippet to the bottom of web page, or adjust already present `remark_config` to have `counter` in `components` list:
|
||||
|
||||
```html
|
||||
<script>
|
||||
var remark_config = {
|
||||
host: "REMARK_URL", // hostname of remark server, same as REMARK_URL in backend config, e.g. "https://demo.remark42.com"
|
||||
site_id: 'YOUR_SITE_ID',
|
||||
components: ['counter']
|
||||
};
|
||||
|
||||
(function(c) {
|
||||
for(var i = 0; i < c.length; i++){
|
||||
var d = document, s = d.createElement('script');
|
||||
s.src = remark_config.host + '/web/' +c[i] +'.js';
|
||||
s.defer = true;
|
||||
(d.head || d.body).appendChild(s);
|
||||
}
|
||||
})(remark_config.components || ['embed']);
|
||||
</script>
|
||||
```
|
||||
|
||||
And then add a node like this in the place where you want to see a number of comments:
|
||||
|
||||
```html
|
||||
<span class="remark42__counter" data-url="https://domain.com/path/to/article/"></span>
|
||||
```
|
||||
|
||||
You can use as many nodes like this as you need to.
|
||||
The script will found all them by the class `remark__counter`,
|
||||
and it will use `data-url` attribute to define the page with comments.
|
||||
|
||||
Also script can use `url` property from `remark_config` object, or `window.location.origin + window.location.pathname` if nothing else is defined.
|
||||
|
||||
## Build from the source
|
||||
|
||||
- to build docker container - `make docker`. This command will produce container `umputun/remark42`.
|
||||
- to build a single binary for direct execution - `make OS=<linux|windows|darwin> ARCH=<amd64|386>`. This step will produce executable
|
||||
`remark42` file with everything embedded.
|
||||
|
||||
## Development
|
||||
|
||||
You can use fully functional local version to develop and test both frontend & backend.
|
||||
|
||||
To bring it up run:
|
||||
|
||||
```bash
|
||||
# if you mainly work on backend
|
||||
docker-compose -f compose-dev-backend.yml build
|
||||
docker-compose -f compose-dev-backend.yml up
|
||||
# if you mainly work on frontend
|
||||
docker-compose -f compose-dev-frontend.yml build
|
||||
docker-compose -f compose-dev-frontend.yml up
|
||||
```
|
||||
|
||||
It starts Remark42 on `127.0.0.1:8080` and adds local OAuth2 provider “Dev”.
|
||||
To access UI demo page go to `127.0.0.1:8080/web`.
|
||||
By default, you would be logged in as `dev_user` which defined as admin.
|
||||
You can tweak any of [supported parameters](#Parameters) in corresponded yml file.
|
||||
|
||||
Backend docker compose config by default skips running frontend related tests.
|
||||
Frontend docker compose config by default skips running backend related tests and sets `NODE_ENV=development` for frontend build.
|
||||
|
||||
### Backend development
|
||||
|
||||
In order to run backend locally (development mode, without docker) you have to have latest stable `go` toolchain [installed](https://golang.org/doc/install).
|
||||
|
||||
To run backend - `go run backend/app/main.go server --dbg --secret=12345 --url=http://127.0.0.1:8080 --admin-passwd=password --site=remark`
|
||||
It stars backend service with embedded bolt store on port `8080` with basic auth, allowing to authenticate and run requests directly, like this:
|
||||
`HTTP http://admin:password@127.0.0.1:8080/api/v1/find?site=remark&sort=-active&format=tree&url=http://127.0.0.1:8080`
|
||||
|
||||
### Frontend development
|
||||
|
||||
#### Build
|
||||
|
||||
* install [Node.js 8](https://nodejs.org/en/) or higher;
|
||||
* install [NPM 6.1.0](https://www.npmjs.com/package/npm);
|
||||
* run `npm install` inside `./frontend`;
|
||||
* run `npm run build` there;
|
||||
* result files will be saved in `./frontend/public`.
|
||||
|
||||
**Note** Running `npm install` will set up precommit hooks into your git repository.
|
||||
It used to reformat your frontend code using `prettier` and lint with `eslint` before every commit.
|
||||
|
||||
#### Devserver
|
||||
|
||||
For local development mode with Hot Reloading use `npm start` instead of `npm run build`.
|
||||
In this case `webpack` will serve files using `webpack-dev-server` on `localhost:9000`.
|
||||
By visiting `127.0.0.1:9000/web` you will get a page with main comments widget
|
||||
communicating with demo server backend running on `https://demo.remark42.com`.
|
||||
But you will not be able to login with any oauth providers due to security reasons.
|
||||
|
||||
You can attach to locally running backend by providing `REMARK_URL` environment variable.
|
||||
```sh
|
||||
npx cross-env REMARK_URL=http://127.0.0.1:8080 npm start
|
||||
```
|
||||
|
||||
Developer build running by `webpack-dev-server` supports devtools for [React](https://github.com/facebook/react-devtools) and
|
||||
[Redux](https://github.com/zalmoxisus/redux-devtools-extension).
|
||||
|
||||
#### Frontend guide
|
||||
|
||||
Frontend guide can be found here: [./frontend/Readme.md](./frontend/Readme.md)
|
||||
|
||||
## API
|
||||
|
||||
### Authorization
|
||||
|
||||
* `GET /auth/{provider}/login?from=http://url&site=site_id&session=1` - perform "social" login with one of supported providers and redirect to `url`. Presence of `session` (any non-zero value) change the default cookie expiration and makes them session-only.
|
||||
* `GET /auth/logout` - logout
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
Picture string `json:"picture"`
|
||||
Admin bool `json:"admin"`
|
||||
Blocked bool `json:"block"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
```
|
||||
|
||||
_currently supported providers are `google`, `facebook`, `github` and `yandex`_
|
||||
|
||||
### Commenting
|
||||
|
||||
* `POST /api/v1/comment` - add a comment. _auth required_
|
||||
|
||||
```go
|
||||
type Comment struct {
|
||||
ID string `json:"id"` // comment ID, read only
|
||||
ParentID string `json:"pid"` // parent ID
|
||||
Text string `json:"text"` // comment text, after md processing
|
||||
Orig string `json:"orig"` // original comment text
|
||||
User User `json:"user"` // user info, read only
|
||||
Locator Locator `json:"locator"` // post locator
|
||||
Score int `json:"score"` // comment score, read only
|
||||
Vote int `json:"vote"` // vote for the current user, -1/1/0.
|
||||
Controversy float64 `json:"controversy,omitempty"` // comment controversy, read only
|
||||
Timestamp time.Time `json:"time"` // time stamp, read only
|
||||
Edit *Edit `json:"edit,omitempty" bson:"edit,omitempty"` // pointer to have empty default in json response
|
||||
Pin bool `json:"pin"` // pinned status, read only
|
||||
Delete bool `json:"delete"` // delete status, read only
|
||||
PostTitle string `json:"title"` // post title
|
||||
}
|
||||
|
||||
type Locator struct {
|
||||
SiteID string `json:"site"` // site id
|
||||
URL string `json:"url"` // post url
|
||||
}
|
||||
|
||||
type Edit struct {
|
||||
Timestamp time.Time `json:"time" bson:"time"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
```
|
||||
|
||||
* `POST /api/v1/preview` - preview comment in html. Body is `Comment` to render
|
||||
|
||||
* `GET /api/v1/find?site=site-id&url=post-url&sort=fld&format=tree|plain` - find all comments for given post
|
||||
|
||||
This is the primary call used by UI to show comments for given post. It can return comments in two formats - `plain` and `tree`.
|
||||
In plain format result will be sorted list of `Comment`. In tree format this is going to be tree-like object with this structure:
|
||||
|
||||
```go
|
||||
type Tree struct {
|
||||
Nodes []Node `json:"comments"`
|
||||
Info store.PostInfo `json:"info,omitempty"`
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
Comment store.Comment `json:"comment"`
|
||||
Replies []Node `json:"replies,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
Sort can be `time`, `active` or `score`. Supported sort order with prefix -/+, i.e. `-time`. For `tree` mode sort will be applied to top-level comments only and all replies always sorted by time.
|
||||
|
||||
* `PUT /api/v1/comment/{id}?site=site-id&url=post-url` - edit comment, allowed once in `EDIT_TIME` minutes since creation. Body is `EditRequest` json
|
||||
|
||||
```go
|
||||
type EditRequest struct {
|
||||
Text string `json:"text"` // updated text
|
||||
Summary string `json:"summary"` // optional, summary of the edit
|
||||
Delete bool `json:"delete"` // delete flag
|
||||
}{}
|
||||
```
|
||||
|
||||
* `GET /api/v1/last/{max}?site=site-id&since=ts-msec` - get up to `{max}` last comments, `since` (epoch time, milliseconds) is optional
|
||||
* `GET /api/v1/id/{id}?site=site-id` - get comment by `comment id`
|
||||
* `GET /api/v1/comments?site=site-id&user=id&limit=N` - get comment by `user id`, returns `response` object
|
||||
```go
|
||||
type response struct {
|
||||
Comments []store.Comment `json:"comments"`
|
||||
Count int `json:"count"`
|
||||
}{}
|
||||
```
|
||||
* `GET /api/v1/count?site=site-id&url=post-url` - get comment's count for `{url}`
|
||||
* `POST /api/v1/count?site=siteID` - get number of comments for posts from post body (list of post IDs)
|
||||
* `GET /api/v1/list?site=site-id&limit=5&skip=2` - list commented posts, returns array or `PostInfo`, limit=0 will return all posts
|
||||
```go
|
||||
type PostInfo struct {
|
||||
URL string `json:"url"`
|
||||
Count int `json:"count"`
|
||||
ReadOnly bool `json:"read_only,omitempty"`
|
||||
FirstTS time.Time `json:"first_time,omitempty"`
|
||||
LastTS time.Time `json:"last_time,omitempty"`
|
||||
}
|
||||
```
|
||||
* `GET /api/v1/user` - get user info, _auth required_
|
||||
* `PUT /api/v1/vote/{id}?site=site-id&url=post-url&vote=1` - vote for comment. `vote`=1 will increase score, -1 decrease. _auth required_
|
||||
* `GET /api/v1/userdata?site=site-id` - export all user data to gz stream _auth required_
|
||||
* `POST /api/v1/deleteme?site=site-id` - request deletion of user data. _auth required_
|
||||
* `GET /api/v1/config?site=site-id` - returns configuration (parameters) for given site
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Version string `json:"version"`
|
||||
EditDuration int `json:"edit_duration"`
|
||||
MaxCommentSize int `json:"max_comment_size"`
|
||||
Admins []string `json:"admins"`
|
||||
AdminEmail string `json:"admin_email"`
|
||||
Auth []string `json:"auth_providers"`
|
||||
LowScore int `json:"low_score"`
|
||||
CriticalScore int `json:"critical_score"`
|
||||
PositiveScore bool `json:"positive_score"`
|
||||
ReadOnlyAge int `json:"readonly_age"`
|
||||
MaxImageSize int `json:"max_image_size"`
|
||||
EmojiEnabled bool `json:"emoji_enabled"`
|
||||
}
|
||||
```
|
||||
|
||||
* `GET /api/v1/info?site=site-idd&url=post-url` - returns `PostInfo` for site and url
|
||||
|
||||
### Streaming API
|
||||
|
||||
Streaming API provide server-sent events for post updates as well as site update
|
||||
|
||||
* `GET /api/v1/stream/info?site=site-idd&url=post-url&since=unix_ts_msec` - returns stream (`event: info`) with `PostInfo` records for the site and url. `since` is optional
|
||||
* `GET /api/v1/stream/last?site=site-id&since=unix_ts_msec` - returns updates stream (`event: last`) with comments for the site, `since` is optional
|
||||
|
||||
<details><summary>response example</summary>
|
||||
|
||||
```
|
||||
data: {"url":"https://radio-t.com/blah1","count":2,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.142872-05:00"}
|
||||
|
||||
event: info
|
||||
data: {"url":"https://radio-t.com/blah1","count":3,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.157709-05:00"}
|
||||
|
||||
event: info
|
||||
data: {"url":"https://radio-t.com/blah1","count":4,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.172991-05:00"}
|
||||
|
||||
event: info
|
||||
data: {"url":"https://radio-t.com/blah1","count":5,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.188429-05:00"}
|
||||
|
||||
event: info
|
||||
data: {"url":"https://radio-t.com/blah1","count":6,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.204742-05:00"}
|
||||
|
||||
event: info
|
||||
data: {"url":"https://radio-t.com/blah1","count":7,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.220692-05:00"}
|
||||
|
||||
event: info
|
||||
data: {"url":"https://radio-t.com/blah1","count":8,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.23817-05:00"}
|
||||
|
||||
event: info
|
||||
data: {"url":"https://radio-t.com/blah1","count":9,"first_time":"2019-06-18T12:53:48.125686-05:00","last_time":"2019-06-18T12:53:48.254669-05:00"}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### RSS feeds
|
||||
|
||||
* `GET /api/v1/rss/post?site=site-id&url=post-url` - rss feed for a post
|
||||
* `GET /api/v1/rss/site?site=site-id` - rss feed for given site
|
||||
* `GET /api/v1/rss/reply?site=site-id&user=user-id` - rss feed for replies to user's comments
|
||||
|
||||
### Images management
|
||||
|
||||
* `GET /api/v1/picture/{user}/{id}` - load stored image
|
||||
* `POST /api/v1/picture` - upload and store image, uses post form with `FormFile("file")`. returns `{"id": user/imgid}` _auth required_
|
||||
|
||||
_returned id should be appended to load image url on caller side_
|
||||
|
||||
### Email subscription
|
||||
|
||||
* `GET /api/v1/email?site=site-id` - get user's email, _auth required_
|
||||
* `POST /api/v1/email/subscribe?site=site-id&address=user@example.org` - makes confirmation token and sends it to user over email, _auth required_
|
||||
|
||||
Trying to subscribe same email second time will return response code `409 Conflict` and explaining error message.
|
||||
* `POST /api/v1/email/confirm?site=site-id&tkn=token` - uses provided token parameter to set email for the user, _auth required_
|
||||
|
||||
Setting email subscribe user for all first-level replies to his messages.
|
||||
* `DELETE /api/v1/email?site=siteID` - removes user's email, _auth required_
|
||||
|
||||
### Admin
|
||||
|
||||
* `DELETE /api/v1/admin/comment/{id}?site=site-id&url=post-url` - delete comment by `id`.
|
||||
* `PUT /api/v1/admin/user/{userid}?site=site-id&block=1&ttl=7d` - block or unblock user with optional ttl (default=permanent)
|
||||
* `GET api/v1/admin/blocked&site=site-id` - list of blocked user ids
|
||||
```go
|
||||
type BlockedUser struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Until time.Time `json:"time"`
|
||||
}
|
||||
```
|
||||
* `GET /api/v1/admin/export?site=site-id&mode=[stream|file]` - export all comments to json stream or gz file.
|
||||
* `POST /api/v1/admin/import?site=site-id` - import comments from the backup, uses post body.
|
||||
* `POST /api/v1/admin/import/form?site=site-id` - import comments from the backup, user post form.
|
||||
* `POST /api/v1/admin/remap?site=site-id` - remap comments to different URLs. Expect list of "from-url new-url" pairs separated by \n.
|
||||
From-url and new-url parts separated by space. If urls end with asterisk (*) it means matching by prefix. Remap procedure based on
|
||||
export/import chain so make backup first.
|
||||
```
|
||||
http://oldsite.com* https://newsite.com*
|
||||
http://oldsite.com/from-old-page/1 https://newsite.com/to-new-page/1
|
||||
```
|
||||
* `GET /api/v1/admin/wait?site=site-id` - wait for completion for any async migration ops (import or remap).
|
||||
* `PUT /api/v1/admin/pin/{id}?site=site-id&url=post-url&pin=1` - pin or unpin comment.
|
||||
* `GET /api/v1/admin/user/{userid}?site=site-id` - get user's info.
|
||||
* `DELETE /api/v1/admin/user/{userid}?site=site-id` - delete all user's comments.
|
||||
* `PUT /api/v1/admin/readonly?site=site-id&url=post-url&ro=1` - set read-only status
|
||||
* `PUT /api/v1/admin/verify/{userid}?site=site-id&verified=1` - set verified status
|
||||
* `GET /api/v1/admin/deleteme?token=token` - process deleteme user's request
|
||||
|
||||
_all admin calls require auth and admin privilege_
|
||||
|
||||
## Privacy
|
||||
|
||||
* Remark42 is trying to be very sensitive to any private or semi-private information.
|
||||
* Authentication requesting the minimal possible scope from authentication providers. All extra information returned by them dropped immediately and not stored in any form.
|
||||
* Generally, remark42 keeps user id, username and avatar link only. None of these fields exposed directly - id and name hashed, avatar proxied.
|
||||
* There is no tracking of any sort.
|
||||
* Login mechanic uses JWT stored in a cookie (httpOnly, secured). The second cookie (XSRF_TOKEN) is a random id preventing CSRF.
|
||||
* There is no cross-site login, i.e., user's behavior can't be analyzed across independent sites running remark42.
|
||||
* There are no third-party analytic services involved.
|
||||
* User can request all information remark42 knows about and export to gz file.
|
||||
* Supported complete cleanup of all information related to user's activity.
|
||||
* Cookie lifespan can be restricted to session-only.
|
||||
* All potentially sensitive data stored by remark42 hashed and encrypted.
|
||||
|
||||
## Technical details
|
||||
|
||||
* Data stored in [boltdb](https://github.com/coreos/bbolt) (embedded key/value database) files under `STORE_BOLT_PATH`
|
||||
* Each site stored in a separate boltbd file.
|
||||
* In order to migrate/move remark42 to another host boltbd files as well as avatars directory `AVATAR_FS_PATH` should be transferred. Optionally, boltdb can be used to store avatars as well.
|
||||
* Automatic backup process runs every 24h and exports all content in json-like format to `backup-remark-YYYYMMDD.gz`.
|
||||
* Authentication implemented with [go-pkgz/auth](https://github.com/go-pkgz/auth) stored in a cookie. It uses HttpOnly, secure cookies.
|
||||
* All heavy REST calls cached internally in LRU cache limited by `CACHE_MAX_ITEMS` and `CACHE_MAX_SIZE` with [go-pkgz/rest](https://github.com/go-pkgz/rest)
|
||||
* User's activity throttled globally (up to 1000 simultaneous requests) and limited locally (per user, usually up to 10 req/sec)
|
||||
* Request timeout set to 60sec
|
||||
* Admin authentication (`--admin-password` set) allows to hit remark42 API without social login and with admin privileges. Adds basic-auth for username: `admin`, password: `${ADMIN_PASSWD}`.
|
||||
* User can vote for the comment multiple times but only to change the vote. Double-voting not allowed.
|
||||
* User can edit comments in 5 mins (configurable) window after creation.
|
||||
* User ID hashed and prefixed by oauth provider name to avoid collisions and potential abuse.
|
||||
* All avatars resized and cached locally to prevent rate limiters from oauth providers, part of [go-pkgz/auth](https://github.com/go-pkgz/auth) functionality.
|
||||
* Images can be proxied (`IMG_PROXY=true`) to prevent mixed http/https.
|
||||
* Docker build uses [publicly available](https://github.com/umputun/baseimage) base images.
|
||||
* [A Helm chart for Remark42 on Kubernetes](https://github.com/groundhog2k/helm-charts/tree/master/charts/remark42)
|
||||
* [django-remark42](https://github.com/andrewp-as-is/django-remark42.py)
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We release patches for security vulnerabilities.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| current | :white_check_mark: |
|
||||
| >=1.6.x | :white_check_mark: |
|
||||
| <1.5.x | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report (suspected) security vulnerabilities to umputun@gmail.com. You will receive a response from us within 48 hours. If the issue is confirmed, we will release a patch as soon as possible depending on complexity but historically within a few days.
|
||||
+52
-6
@@ -1,22 +1,68 @@
|
||||
run:
|
||||
tests: false
|
||||
timeout: 5m
|
||||
output:
|
||||
format: tab
|
||||
skip-dirs:
|
||||
- vendor
|
||||
|
||||
linters-settings:
|
||||
govet:
|
||||
check-shadowing: true
|
||||
maligned:
|
||||
suggest-new: true
|
||||
goconst:
|
||||
min-len: 2
|
||||
min-occurrences: 2
|
||||
misspell:
|
||||
locale: US
|
||||
lll:
|
||||
line-length: 140
|
||||
gocritic:
|
||||
enabled-tags:
|
||||
- performance
|
||||
- style
|
||||
- experimental
|
||||
disabled-checks:
|
||||
- wrapperFunc
|
||||
# TODO: feel free to remove these excludes and fix the code
|
||||
- hugeParam
|
||||
- rangeValCopy
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- unconvert
|
||||
- bodyclose
|
||||
- megacheck
|
||||
- structcheck
|
||||
- revive
|
||||
- govet
|
||||
- unconvert
|
||||
- gas
|
||||
- gocyclo
|
||||
- dupl
|
||||
- misspell
|
||||
- unparam
|
||||
- varcheck
|
||||
- deadcode
|
||||
- unused
|
||||
- typecheck
|
||||
- ineffassign
|
||||
- varcheck
|
||||
- stylecheck
|
||||
- gochecknoinits
|
||||
- exportloopref
|
||||
- gocritic
|
||||
- nakedret
|
||||
- gosimple
|
||||
- prealloc
|
||||
fast: false
|
||||
disable-all: true
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- text: "at least one file in a package should have a package comment"
|
||||
linters:
|
||||
- stylecheck
|
||||
- text: "package-comments: should have a package comment"
|
||||
linters:
|
||||
- revive
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gosec
|
||||
- dupl
|
||||
exclude-use-default: false
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../site/src/docs/contributing/backend/index.md
|
||||
@@ -1,15 +1,22 @@
|
||||
FROM umputun/baseimage:buildgo-latest as build-backend
|
||||
|
||||
#ADD . /build/memory_store
|
||||
#WORKDIR /build/memory_store
|
||||
FROM umputun/baseimage:buildgo-v1.9.2 as build-backend
|
||||
|
||||
ADD backend /build/backend
|
||||
WORKDIR /build/backend/_example/memory_store
|
||||
|
||||
RUN go build -o /build/bin/memory_store -ldflags "-X main.revision=0.0.0 -s -w"
|
||||
|
||||
FROM umputun/baseimage:app-v1.9.2
|
||||
|
||||
FROM umputun/baseimage:app-latest
|
||||
ARG GITHUB_SHA
|
||||
|
||||
LABEL org.opencontainers.image.authors="Umputun <umputun@gmail.com>" \
|
||||
org.opencontainers.image.description="Remark42 comment engine example JRPC memory store" \
|
||||
org.opencontainers.image.documentation="https://github.com/umputun/remark42/tree/master/backend/_example/memory_store" \
|
||||
org.opencontainers.image.licenses="MIT" \
|
||||
org.opencontainers.image.source="https://github.com/umputun/remark42.git" \
|
||||
org.opencontainers.image.title="Remark42 JRPC example memory store" \
|
||||
org.opencontainers.image.url="https://remark42.com/" \
|
||||
org.opencontainers.image.revision="${GITHUB_SHA}"
|
||||
|
||||
WORKDIR /srv
|
||||
COPY --from=build-backend /build/bin/memory_store /srv/memory_store
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
# sample store implementation
|
||||
# sample store implementation
|
||||
|
||||
`memory_store` illustrates how to make a custom storage plugin for remark42.
|
||||
`memory_store` illustrates how to make a custom storage plugin for remark42.
|
||||
|
||||
In order to run remark42 with memory_store copy provided `compose-dev-memstore.yml` to the root directory and run:
|
||||
|
||||
1. docker-compose -f compose-dev-memstore.yml build
|
||||
1. docker-compose -f compose-dev-memstore.yml up
|
||||
1. `docker-compose -f compose-dev-memstore.yml build`
|
||||
1. `docker-compose -f compose-dev-memstore.yml up`
|
||||
|
||||
As usual, demo site will run on http://127.0.0.1:8080/web/
|
||||
|
||||
note: in order to work with the latest (current) version of master `go.mod` uses replacement directive for the backend package
|
||||
. In real-life usage `replace github.com/umputun/remark/backend => ../../` should not be used.
|
||||
note: in order to work with the latest (current) version of master `go.mod` uses replacement directive for the backend package. In real-life usage `replace github.com/umputun/remark42/backend => ../../` should not be used.
|
||||
@@ -7,9 +7,11 @@
|
||||
package accessor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/umputun/remark/backend/app/store/admin"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/store/admin"
|
||||
)
|
||||
|
||||
// MemAdmin implements admin.Store with memory backend
|
||||
@@ -33,8 +35,9 @@ func NewMemAdminStore(key string) *MemAdmin {
|
||||
return &MemAdmin{data: map[string]AdminRec{}, key: key}
|
||||
}
|
||||
|
||||
// Key executes find by siteID and returns substructure with secret key
|
||||
func (m *MemAdmin) Key() (key string, err error) {
|
||||
// Key supposed to execute find by siteID and returns substructure with secret key,
|
||||
// but in this case the shared secret is used for all sites
|
||||
func (m *MemAdmin) Key(_ string) (key string, err error) {
|
||||
return m.key, nil
|
||||
}
|
||||
|
||||
@@ -42,7 +45,7 @@ func (m *MemAdmin) Key() (key string, err error) {
|
||||
func (m *MemAdmin) Admins(siteID string) (ids []string, err error) {
|
||||
resp, ok := m.data[siteID]
|
||||
if !ok {
|
||||
return nil, errors.Errorf("site %s not found", siteID)
|
||||
return nil, fmt.Errorf("site %s not found", siteID)
|
||||
}
|
||||
log.Printf("[DEBUG] admins for %s, %+v", siteID, resp.IDs)
|
||||
return resp.IDs, nil
|
||||
@@ -52,7 +55,7 @@ func (m *MemAdmin) Admins(siteID string) (ids []string, err error) {
|
||||
func (m *MemAdmin) Email(siteID string) (email string, err error) {
|
||||
resp, ok := m.data[siteID]
|
||||
if !ok {
|
||||
return "", errors.Errorf("site %s not found", siteID)
|
||||
return "", fmt.Errorf("site %s not found", siteID)
|
||||
}
|
||||
|
||||
return resp.Email, nil
|
||||
@@ -62,7 +65,7 @@ func (m *MemAdmin) Email(siteID string) (email string, err error) {
|
||||
func (m *MemAdmin) Enabled(siteID string) (ok bool, err error) {
|
||||
resp, ok := m.data[siteID]
|
||||
if !ok {
|
||||
return false, errors.Errorf("site %s not found", siteID)
|
||||
return false, fmt.Errorf("site %s not found", siteID)
|
||||
}
|
||||
return resp.Enabled, nil
|
||||
}
|
||||
@@ -71,10 +74,10 @@ func (m *MemAdmin) Enabled(siteID string) (ok bool, err error) {
|
||||
func (m *MemAdmin) OnEvent(siteID string, ev admin.EventType) error {
|
||||
resp, ok := m.data[siteID]
|
||||
if !ok {
|
||||
return errors.Errorf("site %s not found", siteID)
|
||||
return fmt.Errorf("site %s not found", siteID)
|
||||
}
|
||||
if ev == admin.EvCreate {
|
||||
resp.CountCreated += 1 // not a good idea, just for demo
|
||||
resp.CountCreated++ // not a good idea, just for demo
|
||||
m.data[siteID] = resp
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store/admin"
|
||||
"github.com/umputun/remark42/backend/app/store/admin"
|
||||
)
|
||||
|
||||
func TestMemAdmin_Get(t *testing.T) {
|
||||
@@ -31,7 +31,7 @@ func TestMemAdmin_Get(t *testing.T) {
|
||||
email, err := ms.Email("site1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "e1", email)
|
||||
key, err := ms.Key()
|
||||
key, err := ms.Key("any")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "secret", key)
|
||||
|
||||
@@ -41,15 +41,17 @@ func TestMemAdmin_Get(t *testing.T) {
|
||||
email, err = ms.Email("site2")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "e2", email)
|
||||
key, err = ms.Key()
|
||||
key, err = ms.Key("any")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "secret", key)
|
||||
|
||||
admins, err = ms.Admins("no-site-in-db")
|
||||
assert.EqualError(t, err, "site no-site-in-db not found")
|
||||
assert.Empty(t, admins)
|
||||
|
||||
email, err = ms.Email("no-site-in-db")
|
||||
assert.EqualError(t, err, "site no-site-in-db not found")
|
||||
assert.Empty(t, email)
|
||||
|
||||
enabled, err := ms.Enabled("site1")
|
||||
assert.NoError(t, err)
|
||||
@@ -61,6 +63,7 @@ func TestMemAdmin_Get(t *testing.T) {
|
||||
|
||||
enabled, err = ms.Enabled("no-site-in-db")
|
||||
assert.EqualError(t, err, "site no-site-in-db not found")
|
||||
assert.False(t, enabled)
|
||||
|
||||
err = ms.OnEvent("site1", admin.EvCreate)
|
||||
assert.NoError(t, err)
|
||||
|
||||
@@ -7,16 +7,14 @@
|
||||
package accessor
|
||||
|
||||
import (
|
||||
"log"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store/engine"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store/engine"
|
||||
)
|
||||
|
||||
const lastLimit = 1000
|
||||
@@ -26,7 +24,7 @@ type MemData struct {
|
||||
posts map[string][]store.Comment // key is siteID
|
||||
metaUsers map[string]metaUser // key is userID
|
||||
metaPosts map[store.Locator]metaPost // key is post's locator
|
||||
sync.RWMutex
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type metaPost struct {
|
||||
@@ -59,15 +57,15 @@ func NewMemData() *MemData {
|
||||
func (m *MemData) Create(comment store.Comment) (commentID string, err error) {
|
||||
|
||||
if ro, e := m.Flag(engine.FlagRequest{Flag: engine.ReadOnly, Locator: comment.Locator}); e == nil && ro {
|
||||
return "", errors.Errorf("post %s is read-only", comment.Locator.URL)
|
||||
return "", fmt.Errorf("post %s is read-only", comment.Locator.URL)
|
||||
}
|
||||
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
comments := m.posts[comment.Locator.SiteID]
|
||||
for _, c := range comments { // don't allow duplicated IDs
|
||||
if c.ID == comment.ID {
|
||||
return "", errors.New("dup key")
|
||||
return "", fmt.Errorf("dup key")
|
||||
}
|
||||
}
|
||||
comments = append(comments, comment)
|
||||
@@ -77,8 +75,8 @@ func (m *MemData) Create(comment store.Comment) (commentID string, err error) {
|
||||
|
||||
// Find returns all comments for post and sorts results
|
||||
func (m *MemData) Find(req engine.FindRequest) (comments []store.Comment, err error) {
|
||||
m.RLock()
|
||||
defer m.RUnlock()
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
comments = []store.Comment{}
|
||||
|
||||
@@ -133,22 +131,22 @@ func (m *MemData) Find(req engine.FindRequest) (comments []store.Comment, err er
|
||||
|
||||
// Get returns comment for locator.URL and commentID string
|
||||
func (m *MemData) Get(req engine.GetRequest) (comment store.Comment, err error) {
|
||||
m.RLock()
|
||||
defer m.RUnlock()
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.get(req.Locator, req.CommentID)
|
||||
}
|
||||
|
||||
// Update updates comment for locator.URL with mutable part of comment
|
||||
func (m *MemData) Update(comment store.Comment) error {
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.updateComment(comment)
|
||||
}
|
||||
|
||||
// Count returns number of comments for post or user
|
||||
func (m *MemData) Count(req engine.FindRequest) (count int, err error) {
|
||||
m.RLock()
|
||||
defer m.RUnlock()
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
switch {
|
||||
case req.Locator.URL != "": // comment's count for post
|
||||
@@ -162,14 +160,14 @@ func (m *MemData) Count(req engine.FindRequest) (count int, err error) {
|
||||
})
|
||||
return len(comments), nil
|
||||
default:
|
||||
return 0, errors.Errorf("invalid count request %+v", req)
|
||||
return 0, fmt.Errorf("invalid count request %+v", req)
|
||||
}
|
||||
}
|
||||
|
||||
// Info get post(s) meta info
|
||||
func (m *MemData) Info(req engine.InfoRequest) (res []store.PostInfo, err error) {
|
||||
m.RLock()
|
||||
defer m.RUnlock()
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
res = []store.PostInfo{}
|
||||
|
||||
if req.Locator.URL != "" { // post info
|
||||
@@ -177,7 +175,7 @@ func (m *MemData) Info(req engine.InfoRequest) (res []store.PostInfo, err error)
|
||||
return c.Locator == req.Locator
|
||||
})
|
||||
if len(comments) == 0 {
|
||||
return nil, errors.New("not found")
|
||||
return nil, fmt.Errorf("not found")
|
||||
}
|
||||
info := store.PostInfo{
|
||||
URL: req.Locator.URL,
|
||||
@@ -236,13 +234,13 @@ func (m *MemData) Info(req engine.InfoRequest) (res []store.PostInfo, err error)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
return nil, errors.Errorf("invalid info request %+v", req)
|
||||
return nil, fmt.Errorf("invalid info request %+v", req)
|
||||
}
|
||||
|
||||
// Flag sets and gets flag values
|
||||
func (m *MemData) Flag(req engine.FlagRequest) (val bool, err error) {
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if req.Update == engine.FlagNonSet { // read flag value, no update requested
|
||||
return m.checkFlag(req), nil
|
||||
@@ -254,8 +252,8 @@ func (m *MemData) Flag(req engine.FlagRequest) (val bool, err error) {
|
||||
// ListFlags get list of flagged keys, like blocked & verified user
|
||||
// works for full locator (post flags) or with userID
|
||||
func (m *MemData) ListFlags(req engine.FlagRequest) (res []interface{}, err error) {
|
||||
m.RLock()
|
||||
defer m.RUnlock()
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
res = []interface{}{}
|
||||
|
||||
@@ -269,7 +267,7 @@ func (m *MemData) ListFlags(req engine.FlagRequest) (res []interface{}, err erro
|
||||
return res, nil
|
||||
|
||||
case engine.Blocked:
|
||||
log.Printf("%+v", m.metaUsers)
|
||||
log.Printf("[INFO] metaUsers: %+v", m.metaUsers)
|
||||
for _, u := range m.metaUsers {
|
||||
if u.SiteID == req.Locator.SiteID && u.Blocked && u.BlockedUntil.After(time.Now()) {
|
||||
res = append(res, store.BlockedUser{ID: u.UserID, Until: u.BlockedUntil})
|
||||
@@ -278,7 +276,7 @@ func (m *MemData) ListFlags(req engine.FlagRequest) (res []interface{}, err erro
|
||||
return res, nil
|
||||
}
|
||||
|
||||
return nil, errors.Errorf("flag %s not listable", req.Flag)
|
||||
return nil, fmt.Errorf("flag %s not listable", req.Flag)
|
||||
}
|
||||
|
||||
// UserDetail sets or gets single detail value, or gets all details fo§r requested site.
|
||||
@@ -286,13 +284,13 @@ func (m *MemData) ListFlags(req engine.FlagRequest) (res []interface{}, err erro
|
||||
// and all site's details listing under the same function (and not to extend engine interface by two separate functions).
|
||||
func (m *MemData) UserDetail(req engine.UserDetailRequest) ([]engine.UserDetailEntry, error) {
|
||||
switch req.Detail {
|
||||
case engine.UserEmail:
|
||||
case engine.UserEmail, engine.UserTelegram:
|
||||
if req.UserID == "" {
|
||||
return nil, errors.New("userid cannot be empty in request for single detail")
|
||||
return nil, fmt.Errorf("userid cannot be empty in request for single detail")
|
||||
}
|
||||
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if req.Update == "" { // read detail value, no update requested
|
||||
return m.getUserDetail(req)
|
||||
@@ -303,21 +301,21 @@ func (m *MemData) UserDetail(req engine.UserDetailRequest) ([]engine.UserDetailE
|
||||
// list of all details returned in case request is a read request
|
||||
// (Update is not set) and does not have UserID or Detail set
|
||||
if req.Update == "" && req.UserID == "" { // read list of all details
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.listDetails(req.Locator)
|
||||
}
|
||||
return nil, errors.New("unsupported request with userdetail all")
|
||||
return nil, fmt.Errorf("unsupported request with userdetail all")
|
||||
default:
|
||||
return nil, errors.Errorf("unsupported detail %q", req.Detail)
|
||||
return nil, fmt.Errorf("unsupported detail %q", req.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete post(s), user, comment, user details, or everything
|
||||
func (m *MemData) Delete(req engine.DeleteRequest) error {
|
||||
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
switch {
|
||||
case req.UserDetail != "": // delete user detail
|
||||
@@ -338,13 +336,13 @@ func (m *MemData) Delete(req engine.DeleteRequest) error {
|
||||
|
||||
case req.Locator.SiteID != "" && req.Locator.URL == "" && req.CommentID == "" && req.UserID == "" && req.UserDetail == "": // delete site
|
||||
if _, ok := m.posts[req.Locator.SiteID]; !ok {
|
||||
return errors.New("not found")
|
||||
return fmt.Errorf("not found")
|
||||
}
|
||||
m.posts[req.Locator.SiteID] = []store.Comment{}
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.Errorf("invalid delete request %+v", req)
|
||||
return fmt.Errorf("invalid delete request %+v", req)
|
||||
}
|
||||
|
||||
func (m *MemData) deleteComment(loc store.Locator, id string, mode store.DeleteMode) error {
|
||||
@@ -353,7 +351,7 @@ func (m *MemData) deleteComment(loc store.Locator, id string, mode store.DeleteM
|
||||
return c.Locator == loc && c.ID == id
|
||||
})
|
||||
if len(comments) == 0 {
|
||||
return errors.New("not found")
|
||||
return fmt.Errorf("not found")
|
||||
}
|
||||
|
||||
comments[0].SetDeleted(mode)
|
||||
@@ -431,7 +429,10 @@ func (m *MemData) setFlag(req engine.FlagRequest) (res bool, err error) {
|
||||
info.ReadOnly = status
|
||||
m.metaPosts[req.Locator] = info
|
||||
}
|
||||
return status, errors.Wrapf(err, "failed to set flag %+v", req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to set flag %+v: %w", req, err)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// getUserDetail returns UserDetailEntry with requested userDetail (omitting other details)
|
||||
@@ -444,6 +445,8 @@ func (m *MemData) getUserDetail(req engine.UserDetailRequest) ([]engine.UserDeta
|
||||
switch req.Detail {
|
||||
case engine.UserEmail:
|
||||
return []engine.UserDetailEntry{{UserID: req.UserID, Email: meta.Details.Email}}, nil
|
||||
case engine.UserTelegram:
|
||||
return []engine.UserDetailEntry{{UserID: req.UserID, Telegram: meta.Details.Telegram}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,6 +477,10 @@ func (m *MemData) setUserDetail(req engine.UserDetailRequest) ([]engine.UserDeta
|
||||
entry.Details.Email = req.Update
|
||||
m.metaUsers[req.UserID] = entry
|
||||
return []engine.UserDetailEntry{{UserID: req.UserID, Email: req.Update}}, nil
|
||||
case engine.UserTelegram:
|
||||
entry.Details.Telegram = req.Update
|
||||
m.metaUsers[req.UserID] = entry
|
||||
return []engine.UserDetailEntry{{UserID: req.UserID, Telegram: req.Update}}, nil
|
||||
}
|
||||
|
||||
return []engine.UserDetailEntry{}, nil
|
||||
@@ -510,6 +517,8 @@ func (m *MemData) deleteUserDetail(locator store.Locator, userID string, userDet
|
||||
switch userDetail {
|
||||
case engine.UserEmail:
|
||||
entry.Details.Email = ""
|
||||
case engine.UserTelegram:
|
||||
entry.Details.Telegram = ""
|
||||
case engine.AllUserDetails:
|
||||
entry.Details = engine.UserDetailEntry{UserID: userID}
|
||||
}
|
||||
@@ -528,7 +537,7 @@ func (m *MemData) get(loc store.Locator, commentID string) (store.Comment, error
|
||||
return c.Locator == loc && c.ID == commentID
|
||||
})
|
||||
if len(comments) == 0 {
|
||||
return store.Comment{}, errors.New("not found")
|
||||
return store.Comment{}, fmt.Errorf("not found")
|
||||
}
|
||||
return comments[0], nil
|
||||
}
|
||||
@@ -536,20 +545,21 @@ func (m *MemData) get(loc store.Locator, commentID string) (store.Comment, error
|
||||
func (m *MemData) updateComment(comment store.Comment) error {
|
||||
comments := m.posts[comment.Locator.SiteID]
|
||||
for i, c := range comments {
|
||||
if c.ID == comment.ID && c.Locator == comment.Locator {
|
||||
c.Text = comment.Text
|
||||
c.Orig = comment.Orig
|
||||
c.Score = comment.Score
|
||||
c.Votes = comment.Votes
|
||||
c.Pin = comment.Pin
|
||||
c.Deleted = comment.Deleted
|
||||
c.User = comment.User
|
||||
comments[i] = c
|
||||
m.posts[comment.Locator.SiteID] = comments
|
||||
return nil
|
||||
if c.ID != comment.ID || c.Locator != comment.Locator {
|
||||
continue
|
||||
}
|
||||
c.Text = comment.Text
|
||||
c.Orig = comment.Orig
|
||||
c.Score = comment.Score
|
||||
c.Votes = comment.Votes
|
||||
c.Pin = comment.Pin
|
||||
c.Deleted = comment.Deleted
|
||||
c.User = comment.User
|
||||
comments[i] = c
|
||||
m.posts[comment.Locator.SiteID] = comments
|
||||
return nil
|
||||
}
|
||||
return errors.New("not found")
|
||||
return fmt.Errorf("not found")
|
||||
}
|
||||
|
||||
func (m *MemData) match(comments []store.Comment, fn func(c store.Comment) bool) (res []store.Comment) {
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark/backend/app/store/engine"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store/engine"
|
||||
)
|
||||
|
||||
func TestMemData_CreateAndFind(t *testing.T) {
|
||||
@@ -425,6 +425,7 @@ func TestMemData_FlagReadOnlyPost(t *testing.T) {
|
||||
Update: engine.FlagTrue}
|
||||
val, err = b.Flag(req)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, val)
|
||||
req = engine.FlagRequest{Locator: store.Locator{SiteID: "radio-t", URL: "url-1"}, Flag: engine.ReadOnly}
|
||||
val, err = b.Flag(req)
|
||||
assert.NoError(t, err)
|
||||
@@ -546,9 +547,9 @@ func TestMemData_FlagListBlocked(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
|
||||
blockedList := toBlocked(vv)
|
||||
var blockedIds []string
|
||||
for _, x := range blockedList {
|
||||
blockedIds = append(blockedIds, x.ID)
|
||||
var blockedIds = make([]string, len(blockedList))
|
||||
for i, x := range blockedList {
|
||||
blockedIds[i] = x.ID
|
||||
}
|
||||
require.Equal(t, 2, len(blockedList), b.metaUsers)
|
||||
assert.ElementsMatch(t, []string{"user1", "user2"}, blockedIds)
|
||||
@@ -623,6 +624,7 @@ func TestMemData_DeleteComment(t *testing.T) {
|
||||
func TestMemData_Close(t *testing.T) {
|
||||
b := prepMem(t)
|
||||
assert.NoError(t, b.Close())
|
||||
assert.NoError(t, b.Close(), "second call should not result in panic or errors")
|
||||
}
|
||||
|
||||
func TestMemData_DeleteHard(t *testing.T) {
|
||||
@@ -657,12 +659,40 @@ func TestMemData_DeleteAll(t *testing.T) {
|
||||
assert.Equal(t, 0, len(comments), "nothing left")
|
||||
}
|
||||
|
||||
func TestMemData_UserDetailAll(t *testing.T) {
|
||||
b := prepMem(t)
|
||||
|
||||
val, err := b.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, Detail: engine.AllUserDetails})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, val)
|
||||
}
|
||||
|
||||
func TestMemData_UserDetailErrors(t *testing.T) {
|
||||
b := prepMem(t)
|
||||
|
||||
val, err := b.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, Detail: engine.UserEmail, Update: "value1"})
|
||||
require.EqualError(t, err, "userid cannot be empty in request for single detail")
|
||||
require.Nil(t, val)
|
||||
|
||||
val, err = b.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, Detail: engine.AllUserDetails, Update: "value1"})
|
||||
require.EqualError(t, err, "unsupported request with userdetail all")
|
||||
require.Nil(t, val)
|
||||
|
||||
val, err = b.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, Detail: "bad"})
|
||||
require.EqualError(t, err, "unsupported detail \"bad\"")
|
||||
require.Nil(t, val)
|
||||
}
|
||||
|
||||
func TestMemData_DeleteUserDetail(t *testing.T) {
|
||||
var (
|
||||
createUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserEmail, Update: "value1"}
|
||||
readUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserEmail}
|
||||
emailSet = []engine.UserDetailEntry{{UserID: "user1", Email: "value1"}}
|
||||
emailUnset = []engine.UserDetailEntry{{UserID: "user1", Email: ""}}
|
||||
createEmailUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserEmail, Update: "value1"}
|
||||
readEmailUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserEmail}
|
||||
createTelegramUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserTelegram, Update: "value1"}
|
||||
readTelegramUser = engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", Detail: engine.UserTelegram}
|
||||
emailSet = []engine.UserDetailEntry{{UserID: "user1", Email: "value1"}}
|
||||
emailUnset = []engine.UserDetailEntry{{UserID: "user1", Email: ""}}
|
||||
telegramSet = []engine.UserDetailEntry{{UserID: "user1", Telegram: "value1"}}
|
||||
telegramUnset = []engine.UserDetailEntry{{UserID: "user1", Telegram: ""}}
|
||||
)
|
||||
|
||||
b := prepMem(t)
|
||||
@@ -673,15 +703,21 @@ func TestMemData_DeleteUserDetail(t *testing.T) {
|
||||
expected []engine.UserDetailEntry
|
||||
}{
|
||||
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.UserEmail},
|
||||
detailReq: createUser, expected: emailSet},
|
||||
detailReq: createEmailUser, expected: emailSet},
|
||||
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "bad"}, UserID: "user1", UserDetail: engine.UserEmail},
|
||||
detailReq: readUser, expected: emailSet},
|
||||
detailReq: readEmailUser, expected: emailSet},
|
||||
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.UserEmail},
|
||||
detailReq: readUser, expected: emailUnset},
|
||||
detailReq: readEmailUser, expected: emailUnset},
|
||||
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.UserTelegram},
|
||||
detailReq: createTelegramUser, expected: telegramSet},
|
||||
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "bad"}, UserID: "user1", UserDetail: engine.UserTelegram},
|
||||
detailReq: readTelegramUser, expected: telegramSet},
|
||||
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.UserTelegram},
|
||||
detailReq: readTelegramUser, expected: telegramUnset},
|
||||
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.AllUserDetails},
|
||||
detailReq: createUser, expected: emailSet},
|
||||
detailReq: createEmailUser, expected: emailSet},
|
||||
{delReq: engine.DeleteRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "user1", UserDetail: engine.AllUserDetails},
|
||||
detailReq: readUser, expected: emailUnset},
|
||||
detailReq: readEmailUser, expected: emailUnset},
|
||||
}
|
||||
|
||||
for i, x := range testData {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright 2020 Umputun. All rights reserved.
|
||||
* Use of this source code is governed by a MIT-style
|
||||
* license that can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package accessor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/store/image"
|
||||
)
|
||||
|
||||
// MemImage implements image.Store with memory backend
|
||||
type MemImage struct {
|
||||
imagesStaging map[string][]byte
|
||||
images map[string][]byte
|
||||
insertTime map[string]time.Time
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewMemImageStore makes admin Store in memory.
|
||||
func NewMemImageStore() *MemImage {
|
||||
log.Print("[DEBUG] make memory image store")
|
||||
return &MemImage{
|
||||
imagesStaging: map[string][]byte{},
|
||||
images: map[string][]byte{},
|
||||
insertTime: map[string]time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
// Save stores image with passed id to staging
|
||||
func (m *MemImage) Save(id string, img []byte) error {
|
||||
m.mu.Lock()
|
||||
m.imagesStaging[id] = img
|
||||
m.insertTime[id] = time.Now()
|
||||
m.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetCleanupTimer resets cleanup timer for the image
|
||||
func (m *MemImage) ResetCleanupTimer(id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.insertTime[id]; ok {
|
||||
m.insertTime[id] = time.Now()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("image %s not found", id)
|
||||
}
|
||||
|
||||
// Load image by ID
|
||||
func (m *MemImage) Load(id string) ([]byte, error) {
|
||||
m.mu.RLock()
|
||||
img, ok := m.images[id]
|
||||
if !ok {
|
||||
img, ok = m.imagesStaging[id]
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("image %s not found", id)
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// Commit moves image from staging to permanent
|
||||
func (m *MemImage) Commit(id string) error {
|
||||
m.mu.RLock()
|
||||
img, ok := m.imagesStaging[id]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to commit %s, not found in staging", id)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.images[id] = img
|
||||
m.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cleanup runs removal loop for old images on staging
|
||||
func (m *MemImage) Cleanup(_ context.Context, ttl time.Duration) error {
|
||||
var idsToRemove []string
|
||||
|
||||
m.mu.RLock()
|
||||
for id, t := range m.insertTime {
|
||||
age := time.Since(t)
|
||||
if age > ttl {
|
||||
log.Printf("[INFO] remove staging image %s, age %v", id, age)
|
||||
idsToRemove = append(idsToRemove, id)
|
||||
}
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
m.mu.Lock()
|
||||
for _, id := range idsToRemove {
|
||||
delete(m.insertTime, id)
|
||||
delete(m.imagesStaging, id)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Info returns meta information about storage
|
||||
func (m *MemImage) Info() (image.StoreInfo, error) {
|
||||
var ts time.Time
|
||||
m.mu.RLock()
|
||||
for _, t := range m.insertTime {
|
||||
if ts.IsZero() || t.Before(ts) {
|
||||
ts = t
|
||||
}
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
return image.StoreInfo{FirstStagingImageTS: ts}, nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright 2020 Umputun. All rights reserved.
|
||||
* Use of this source code is governed by a MIT-style
|
||||
* license that can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package accessor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// gopher png for test, from https://golang.org/src/image/png/example_test.go
|
||||
const gopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2" +
|
||||
"+OPbo9d7tsWyiyaZti6eWGAhISoIGKECEKCAiJJkYTiUgTMYSIosYYBBIUIxoSPIINEBDi2VhwkQrVsj1ESgu9doHWdrul7ba" +
|
||||
"73WNm3vOPtsseM9MdwvvrzTs+8/t95ze/33sI5BqiabU6m9En8oNjduLnAEDLUsQXFF8tQ5oxK3vmnNmDSMtrncks9Hhtt" +
|
||||
"/qeWZapHb1ha3UqYSWVl2ZmpWgaXMXGohQAvmeop3bjTRtv6SgaK/Pb9/bFzUrYslbFAmHPp+3WhAYdr+7GN/YnpN46Opv55VDs" +
|
||||
"JkoEpMrY/vO2BIYQ6LLvm0ThY3MzDzzeSJeeWNyTkgnIE5ePKsvKlcg/0T9QMzXalwXMlj54z4c0rh/mzEfr+FgWEz2w6uk" +
|
||||
"8dkzFAgcARAgNp1ZYef8bH2AgvuStbc2/i6CiWGj98y2tw2l4FAXKkQBIf+exyRnteY83LfEwDQAYCoK+P6bxkZm/0966LxcAA" +
|
||||
"ILHB56kgD95PPxltuYcMtFTWw/FKkY/6Opf3GGd9ZF+Qp6mzJxzuRSractOmJrH1u8XTvWFHINNkLQLMR+XHXvfPPHw967raE1xxwtA36I" +
|
||||
"MRfkAAG29/7mLuQcb2WOnsJReZGfpiHsSBX81cvMKywYZHhX5hFPtOqPGWZCXnhWGAu6lX91ElKXSalcLXu3UaOXVay57ZSe5f6Gpx7J2" +
|
||||
"MXAsi7EqSp09b/MirKSyJfnfEEgeDjl8FgDAfvewP03zZ+AJ0m9aFRM8eEHBDRKjfcreDXnZdQuAxXpT2NRJ7xl3UkLBhuVGU16gZiGOgZm" +
|
||||
"rSbRdqkILuL/yYoSXHHkl9KXgqNu3PB8oRg0geC5vFmLjad6mUyTKLmF3OtraWDIfACyXqmephaDABawfpi6tqqBZytfQMqOz6S09iWXhkt" +
|
||||
"rRaB8Xz4Yi/8gyABDm5NVe6qq/3VzPrcjELWrebVuyY2T7ar4zQyybUCtsQ5Es1FGaZVrRVQwAgHGW2ZCRZshI5bGQi7HesyE972pOSeMM0" +
|
||||
"dSktlzxRdrlqb3Osa6CCS8IJoQQQgBAbTAa5l5epO34rJszibJI8rxLfGzcp1dRosutGeb2VDNgqYrwTiPNsLxXiPi3dz7LiS1WBRBDBOnqEj" +
|
||||
"yy3aQb+/bLiJzz9dIkscVBBLxMfSEac7kO4Fpkngi0ruNBeSOal+u8jgOuqPz12nryMLCniEjtOOOmpt+KEIqsEdocJjYXwrh9OZqWJQyPCTo67" +
|
||||
"LNS/TdxLAv6R5ZNK9npEjbYdT33gRo4o5oTqR34R+OmaSzDBWsAIPhuRcgyoteNi9gF0KzNYWVItPf2TLoXEg+7isNC7uJkgo1iQWOfRSP9NR" +
|
||||
"11RtbZZ3OMG/VhL6jvx+J1m87+RCfJChAtEBQkSBX2PnSiihc/Twh3j0h7qdYQAoRVsRGmq7HU2QRbaxVGa1D6nIOqaIWRjyRZpHMQKWKpZM5fe" +
|
||||
"A+lzC4ZFultV8S6T0mzQGhQohi5I8iw+CsqBSxhFMuwyLgSwbghGb0AiIKkSDmGZVmJSiKihsiyOAUs70UkywooYP0bii9GdH4sfr1UNysd3fU" +
|
||||
"yLLMQN+rsmo3grHl9VNJHbbwxoa47Vw5gupIqrZcjPh9R4Nye3nRDk199V+aetmvVtDRE8/+cbgAAgMIWGb3UA0MGLE9SCbWX670TDy" +
|
||||
"1y98c3D27eppUjsZ6fql3jcd5rUe7+ZIlLNQny3Rd+E5Tct3WVhTM5RBCEdiEK0b6B+/ca2gYU393nFj/n1AygRQxPIUA043M42u85+z2S" +
|
||||
"nssKrPl8Mx76NL3E6eXc3be7OD+H4WHbJkKI8AU8irbITQjZ+0hQcPEgId/Fn/pl9crKH02+5o2b9T/eMx7pKoskYgAAAABJRU5ErkJggg=="
|
||||
|
||||
func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) }
|
||||
|
||||
func TestMemImage_LoadAfterSave(t *testing.T) {
|
||||
svc := NewMemImageStore()
|
||||
gopher, err := io.ReadAll(gopherPNG())
|
||||
assert.NoError(t, err)
|
||||
|
||||
img, err := svc.Load("test_id")
|
||||
assert.EqualError(t, err, "image test_id not found")
|
||||
assert.Empty(t, img)
|
||||
|
||||
id := "test_img"
|
||||
err = svc.Save(id, gopher)
|
||||
assert.NoError(t, err)
|
||||
|
||||
img, err = svc.Load(id)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, gopher, img)
|
||||
|
||||
svc.ResetCleanupTimer(id)
|
||||
|
||||
err = svc.Commit(id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = svc.Cleanup(context.TODO(), 0)
|
||||
assert.NoError(t, err)
|
||||
|
||||
img, err = svc.Load(id)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, gopher, img)
|
||||
}
|
||||
|
||||
func TestMemImage_CommitFail(t *testing.T) {
|
||||
svc := NewMemImageStore()
|
||||
err := svc.Commit("test_id")
|
||||
assert.EqualError(t, err, "failed to commit test_id, not found in staging")
|
||||
}
|
||||
|
||||
func TestMemImage_Cleanup(t *testing.T) {
|
||||
svc := NewMemImageStore()
|
||||
err := svc.Cleanup(context.TODO(), time.Minute)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestMemImage_Info(t *testing.T) {
|
||||
svc := NewMemImageStore()
|
||||
gopher, err := io.ReadAll(gopherPNG())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// get info on empty storage, should be zero
|
||||
info, err := svc.Info()
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, info.FirstStagingImageTS.IsZero())
|
||||
|
||||
// save image
|
||||
err = svc.Save("test_img", gopher)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// get info after saving, should be non-zero
|
||||
info, err = svc.Info()
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, info.FirstStagingImageTS.IsZero())
|
||||
}
|
||||
@@ -1,22 +1,27 @@
|
||||
# compose file demonstrating custom storage use. The memory_store (see backend/_example/memory_store) starts
|
||||
# in a separate container and remark42 communicates to mem_store.r42 via STORE_RPC_API url
|
||||
|
||||
version: '2'
|
||||
version: "2"
|
||||
|
||||
services:
|
||||
|
||||
remark42:
|
||||
build:
|
||||
context: .
|
||||
context: ../../..
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- SKIP_BACKEND_TEST=true
|
||||
- SKIP_FRONTEND_TEST=true
|
||||
image: umputun/remark42:dev
|
||||
container_name: "remark42"
|
||||
hostname: "remark42"
|
||||
container_name: "remark42-dev"
|
||||
hostname: "remark42-dev"
|
||||
restart: always
|
||||
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
ports:
|
||||
- "8080:8080" # primary rest server
|
||||
- "8084:8084" # local oauth2 server
|
||||
@@ -24,7 +29,6 @@ services:
|
||||
environment:
|
||||
- REMARK_URL=http://127.0.0.1:8080
|
||||
- SECRET=123456
|
||||
- BACKUP_PATH=/srv/var/backup
|
||||
- DEBUG=true
|
||||
- EMOJI=true
|
||||
- AUTH_ANON=true
|
||||
@@ -33,14 +37,26 @@ services:
|
||||
- ADMIN_RPC_API=http://mem_store.r42:8080/cmd
|
||||
- STORE_TYPE=rpc
|
||||
- STORE_RPC_API=http://mem_store.r42:8080/cmd
|
||||
- IMAGE_TYPE=rpc
|
||||
- IMAGE_RPC_API=http://mem_store.r42:8080/cmd
|
||||
|
||||
volumes:
|
||||
- ../../../var:/srv/var
|
||||
|
||||
mem_store.r42:
|
||||
image: umputun/mem_store.r42
|
||||
build:
|
||||
context: .
|
||||
context: ../../..
|
||||
dockerfile: backend/_example/memory_store/Dockerfile
|
||||
container_name: "mem_store.r42"
|
||||
hostname: "mem_store.r42"
|
||||
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
environment:
|
||||
- API=/cmd
|
||||
- SECRET=123456
|
||||
|
||||
@@ -1,16 +1,42 @@
|
||||
module github.com/umputun/remark/memory_store
|
||||
module github.com/umputun/remark42/memory_store
|
||||
|
||||
go 1.12
|
||||
go 1.20
|
||||
|
||||
require (
|
||||
github.com/go-pkgz/jrpc v0.1.0
|
||||
github.com/go-pkgz/lgr v0.6.3
|
||||
github.com/jessevdk/go-flags v1.4.0
|
||||
github.com/pkg/errors v0.8.1
|
||||
github.com/stretchr/testify v1.4.0
|
||||
github.com/umputun/remark/backend v1.4.0
|
||||
github.com/go-pkgz/jrpc v0.3.0
|
||||
github.com/go-pkgz/lgr v0.11.0
|
||||
github.com/jessevdk/go-flags v1.5.0
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/umputun/remark42/backend v1.1000.0
|
||||
)
|
||||
|
||||
replace github.com/umputun/remark/backend => ../../
|
||||
require (
|
||||
github.com/Depado/bfchroma/v2 v2.0.0 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.8.1 // indirect
|
||||
github.com/ajg/form v1.5.1 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.8.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/didip/tollbooth/v7 v7.0.1 // indirect
|
||||
github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f // indirect
|
||||
github.com/dlclark/regexp2 v1.10.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.0.10 // indirect
|
||||
github.com/go-chi/render v1.0.3 // indirect
|
||||
github.com/go-pkgz/expirable-cache v1.0.0 // indirect
|
||||
github.com/go-pkgz/rest v1.17.0 // indirect
|
||||
github.com/gorilla/css v1.0.0 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.25 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rs/xid v1.5.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
go.etcd.io/bbolt v1.3.7 // indirect
|
||||
golang.org/x/image v0.11.0 // indirect
|
||||
golang.org/x/net v0.14.0 // indirect
|
||||
golang.org/x/sys v0.11.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace gopkg.in/russross/blackfriday.v2 => github.com/russross/blackfriday/v2 v2.0.1
|
||||
replace github.com/umputun/remark42/backend v1.1000.0 => ../../
|
||||
|
||||
@@ -1,330 +1,126 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
cloud.google.com/go v0.40.0/go.mod h1:Tk58MuI9rbLMKlAjeO/bDnteAx7tX2gJIXw4T5Jwlro=
|
||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||
cloud.google.com/go v0.47.0/go.mod h1:5p3Ky/7f3N10VBkhuR5LFtddroTiMyjZV/Kj5qOQFxU=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/PuerkitoBio/goquery v1.5.0 h1:uGvmFXOA73IKluu/F84Xd1tt/z07GYm8X49XKHP7EJk=
|
||||
github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg=
|
||||
github.com/ajg/form v0.0.0-20160822230020-523a5da1a92f/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
||||
github.com/Depado/bfchroma/v2 v2.0.0 h1:IRpN9BPkNwEpR6w1ectIcNWOuhDSLx+8f1pn83fzxx8=
|
||||
github.com/Depado/bfchroma/v2 v2.0.0/go.mod h1:wFwW/Pw8Tnd0irzgO9Zxtxgzp3aPS8qBWlyadxujxmw=
|
||||
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
|
||||
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
||||
github.com/alicebob/gopher-json v0.0.0-20180125190556-5a6b3ba71ee6/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
|
||||
github.com/alicebob/miniredis v2.5.0+incompatible/go.mod h1:8HZjEj4yU0dwhYHky+DxYx+6BMjkBbe5ONFIF1MXffk=
|
||||
github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o=
|
||||
github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo=
|
||||
github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/coreos/bbolt v1.3.3 h1:n6AiVyVRKQFNb6mJlwESEvvLoDyiTzXX7ORAUlkeBdY=
|
||||
github.com/coreos/bbolt v1.3.3/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
|
||||
github.com/alecthomas/assert/v2 v2.2.1 h1:XivOgYcduV98QCahG8T5XTezV5bylXe+lBxLG2K2ink=
|
||||
github.com/alecthomas/chroma/v2 v2.8.0 h1:w9WJUjFFmHHB2e8mRpL9jjy3alYDlU0QLDezj1xE264=
|
||||
github.com/alecthomas/chroma/v2 v2.8.0/go.mod h1:yrkMI9807G1ROx13fhe1v6PN2DDeaR73L3d+1nmYQtw=
|
||||
github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dghubble/oauth1 v0.6.0/go.mod h1:8pFdfPkv/jr8mkChVbNVuJ0suiHe278BtWI4Tk1ujxk=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/didip/tollbooth v4.0.2+incompatible h1:fVSa33JzSz0hoh2NxpwZtksAzAgd7zjmGO20HCZtF4M=
|
||||
github.com/didip/tollbooth v4.0.2+incompatible/go.mod h1:A9b0665CE6l1KmzpDws2++elm/CsuWBMa5Jv4WY0PEY=
|
||||
github.com/didip/tollbooth_chi v0.0.0-20170928041846-6ab5f3083f3d h1:vs5Nf6IE0N/PwGJ8//zRed4gpCdcr99K2HzX7RuLOQ8=
|
||||
github.com/didip/tollbooth_chi v0.0.0-20170928041846-6ab5f3083f3d/go.mod h1:YWyIfq3y4ArRfWZ9XksmuusP+7Mad+T0iFZ0kv0XG/M=
|
||||
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/gavv/httpexpect v0.0.0-20180803094507-bdde30871313/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
|
||||
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
|
||||
github.com/gavv/monotime v0.0.0-20171021193802-6f8212e8d10d/go.mod h1:vmp8DIyckQMXOPl0AQVHt+7n5h7Gb7hS6CUydiV8QeA=
|
||||
github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs=
|
||||
github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-chi/cors v1.0.0/go.mod h1:K2Yje0VW/SJzxiyMYu6iPQYa7hMjQX2i/F491VChg1I=
|
||||
github.com/go-chi/render v1.0.1 h1:4/5tis2cKaNdnv9zFLfXzcquC9HbeZgCnxGnKrltBS8=
|
||||
github.com/didip/tollbooth/v7 v7.0.0/go.mod h1:VZhDSGl5bDSPj4wPsih3PFa4Uh9Ghv8hgacaTm5PRT4=
|
||||
github.com/didip/tollbooth/v7 v7.0.1 h1:TkT4sBKoQoHQFPf7blQ54iHrZiTDnr8TceU+MulVAog=
|
||||
github.com/didip/tollbooth/v7 v7.0.1/go.mod h1:VZhDSGl5bDSPj4wPsih3PFa4Uh9Ghv8hgacaTm5PRT4=
|
||||
github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f h1:jtKwihcLmUC9BAhoJ9adCUqdSSZcOdH2KL7mPTUm2aw=
|
||||
github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f/go.mod h1:q9C80dnsuVRP2dAskjnXRNWdUJqtGgwG9wNrzt0019s=
|
||||
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
|
||||
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
|
||||
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-pkgz/auth v0.9.0/go.mod h1:8Kiold3xA9VUiYZwMyMhwBgiGImZFoL/EmtO9lSIQxo=
|
||||
github.com/go-pkgz/jrpc v0.1.0 h1:hNg/IyfEqJcSWOKkuHw0ZwcuGc9TDp7QZREsD2ycmiM=
|
||||
github.com/go-pkgz/jrpc v0.1.0/go.mod h1:JxZsvoBklA50DNhELVJnJ567Rt+KrMH9rR3u515wvE8=
|
||||
github.com/go-pkgz/lcw v0.5.0/go.mod h1:CSdQRQthxJQ4iDD4wTPPuWFbFdknJzwJ8WXu1nfxb10=
|
||||
github.com/go-pkgz/lgr v0.6.3 h1:n9pGk2paBV8w/Y/FVEq5MkwDmP33dnUPKbY4CyyygwM=
|
||||
github.com/go-pkgz/lgr v0.6.3/go.mod h1:hBM1NM/SoYdlrykgdgJWGrZ/TM/XaZIjRbJfx7NkMm8=
|
||||
github.com/go-pkgz/repeater v1.1.3/go.mod h1:hVTavuO5x3Gxnu8zW7d6sQBfAneKV8X2FjU48kGfpKw=
|
||||
github.com/go-pkgz/rest v1.4.1 h1:DmaVLPH2O7yLehrWOW0uz01d2mVHz9fBR/iuTiPRzaw=
|
||||
github.com/go-pkgz/rest v1.4.1/go.mod h1:COazNj35u3RXAgQNBr6neR599tYP3URiOpsu9p0rOtk=
|
||||
github.com/go-pkgz/syncs v1.1.1/go.mod h1:bt9lxWRRJ9vOCMGc8Big8ttjYHLKP88ofj1y38UlaHE=
|
||||
github.com/go-redis/redis/v7 v7.0.0-beta.4/go.mod h1:xhhSbUMTsleRPur+Vgx9sUHtyN33bdjxY+9/0n9Ig8s=
|
||||
github.com/go-session/session v3.1.2+incompatible/go.mod h1:8B3iivBQjrz/JtC68Np2T1yBBLxTan3mn/3OM0CyRt0=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20190812055157-5d271430af9f/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
|
||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
|
||||
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
|
||||
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
|
||||
github.com/go-pkgz/expirable-cache v0.1.0/go.mod h1:GTrEl0X+q0mPNqN6dtcQXksACnzCBQ5k/k1SwXJsZKs=
|
||||
github.com/go-pkgz/expirable-cache v1.0.0 h1:ns5+1hjY8hntGv8bPaQd9Gr7Jyo+Uw5SLyII40aQdtA=
|
||||
github.com/go-pkgz/expirable-cache v1.0.0/go.mod h1:GTrEl0X+q0mPNqN6dtcQXksACnzCBQ5k/k1SwXJsZKs=
|
||||
github.com/go-pkgz/jrpc v0.3.0 h1:Fls38KqPsHzvp0FWfivr6cGnncC+iFBodHBqvUPY+0U=
|
||||
github.com/go-pkgz/jrpc v0.3.0/go.mod h1:MFtKs75JESiSqVicsQkgN2iDFFuCd3gVT1/vKiwRi00=
|
||||
github.com/go-pkgz/lgr v0.11.0 h1:9XH5o+vj09L0sRWEswIGK1lJ6g07xVB4/Z28RV9Z+qM=
|
||||
github.com/go-pkgz/lgr v0.11.0/go.mod h1:4rdRmMSs4yGFjnUg0rSDbKx21LmFNZoH4y8OLl3qDnU=
|
||||
github.com/go-pkgz/rest v1.15.6/go.mod h1:KUWAqbDteYGS/CiXftomQsKjtEOifXsJ36Ka0skYbmk=
|
||||
github.com/go-pkgz/rest v1.17.0 h1:LoBI/lDBMuqwWhOOkc6thM9NnwJO+K9nWvCOjZ7BAgE=
|
||||
github.com/go-pkgz/rest v1.17.0/go.mod h1:HHlLOt02NJc2sgffXBF6hYVMcRo4Gz3vjg43zTzN7VM=
|
||||
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o=
|
||||
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.3/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
|
||||
github.com/jessevdk/go-flags v0.0.0-20180331124232-1c38ed7ad0cc/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.4.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.7.6/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/cpuid v0.0.0-20180405133222-e7e905edc00e/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc=
|
||||
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kyokomi/emoji v2.1.0+incompatible/go.mod h1:mZ6aGCD7yk8j6QY6KICwnZ2pxoszVseX1DNoGtU2tBA=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
|
||||
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
|
||||
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
|
||||
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
|
||||
github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022/go.mod h1:x4NsS+uc7ecH/Cbm9xKQ6XzmJM57rWTkjywjfB2yQ18=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.9.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.6.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJWm9rtloEg=
|
||||
github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rakyll/statik v0.1.6/go.mod h1:OEi9wJV/fMUAGx1eNjq75DKDsJVuEv1U0oYdX6GX8Zs=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/assertions v1.0.1/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM=
|
||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
||||
github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/tidwall/btree v0.0.0-20170113224114-9876f1454cf0/go.mod h1:huei1BkDWJ3/sLXmO+bsCNELL+Bp2Kks9OLyQFkzvA8=
|
||||
github.com/tidwall/buntdb v1.0.0/go.mod h1:Y39xhcDW10WlyYXeLgGftXVbjtM0QP+/kpz8xl9cbzE=
|
||||
github.com/tidwall/buntdb v1.1.0/go.mod h1:Y39xhcDW10WlyYXeLgGftXVbjtM0QP+/kpz8xl9cbzE=
|
||||
github.com/tidwall/gjson v1.1.3/go.mod h1:c/nTNbUr0E0OrXEhq1pwa8iEgc2DOt4ZZqAt1HtCkPA=
|
||||
github.com/tidwall/gjson v1.3.2/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls=
|
||||
github.com/tidwall/grect v0.0.0-20161006141115-ba9a043346eb/go.mod h1:lKYYLFIr9OIgdgrtgkZ9zgRxRdvPYsExnYBsEAd8W5M=
|
||||
github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E=
|
||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e/go.mod h1:/h+UnNGt0IhNNJLkGikcdcJqm66zGD/uJGMRxK/9+Ao=
|
||||
github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563/go.mod h1:mLqSmt7Dv/CNneF2wfcChfN1rvapyQr01LGKnKex0DQ=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.0.0/go.mod h1:4vX61m6KN+xDduDNwXrhIAVZaZaZiQ1luJk8LWSxF3s=
|
||||
github.com/valyala/fasthttp v1.4.0/go.mod h1:4vX61m6KN+xDduDNwXrhIAVZaZaZiQ1luJk8LWSxF3s=
|
||||
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
|
||||
github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I=
|
||||
github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190809123943-df4f5c81cb3b/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/xeipuuv/gojsonschema v0.0.0-20181112162635-ac52e6811b56/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs=
|
||||
github.com/xeipuuv/gojsonschema v1.1.0/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs=
|
||||
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
|
||||
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
|
||||
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
|
||||
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
|
||||
github.com/yuin/gopher-lua v0.0.0-20190514113301-1cd887cd7036/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ=
|
||||
go.etcd.io/bbolt v1.3.3 h1:MUGmc65QhB3pIlaQ5bB4LwqSj6GIonVJXpZiaKNyaKk=
|
||||
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
go.mongodb.org/mongo-driver v1.1.1/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM=
|
||||
go.mongodb.org/mongo-driver v1.1.2/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ=
|
||||
go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||
golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190523035834-f03afa92d3ff/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181217023233-e147a9138326/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190611141213-3f473d35a33a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/image v0.11.0 h1:ds2RoQvBvYTiJkwpSFDwCcDFNX7DqjL2WsUgTNk0Ooo=
|
||||
golang.org/x/image v0.11.0/go.mod h1:bglhjqbqVuEb9e9+eNR45Jfu7D+T4Qan+NhQk8Ck2P8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk=
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191027093000-83d349e8ac1a h1:Yu34BogBivvmu7SAzHHaB9nZWH5D1C+z3F1jyIaYZSQ=
|
||||
golang.org/x/net v0.0.0-20191027093000-83d349e8ac1a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190610200419-93c9922d18ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191010171213-8abd42400456/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.6.0/go.mod h1:btoxGiFvQNVUZQ8W08zLtrVS08CNpINPEfxXxgJL1Q4=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190530194941-fb225487d101/go.mod h1:z3L6/3dTEVtUr6QSP8miRzeRqwQOioJ9I66odjN4I7s=
|
||||
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||
google.golang.org/genproto v0.0.0-20191009194640-548a555dbc03/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/oauth2.v3 v3.10.1/go.mod h1:nTG+m2PRcHR9jzGNrGdxSsUKz7vvwkqSlhFrstgZcRU=
|
||||
gopkg.in/oauth2.v3 v3.11.0/go.mod h1:ZdKwOng79bFq17t/ItHGtL8LJ2dW3tmrdJJhHmqS+uc=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2019 Umputun. All rights reserved.
|
||||
* Copyright 2020 Umputun. All rights reserved.
|
||||
* Use of this source code is governed by a MIT-style
|
||||
* license that can be found in the LICENSE file.
|
||||
*/
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/jessevdk/go-flags"
|
||||
|
||||
"github.com/umputun/remark/memory_store/accessor"
|
||||
"github.com/umputun/remark/memory_store/server"
|
||||
"github.com/umputun/remark42/memory_store/accessor"
|
||||
"github.com/umputun/remark42/memory_store/server"
|
||||
)
|
||||
|
||||
// opts with all cli commands and flags
|
||||
@@ -41,22 +41,22 @@ func main() {
|
||||
|
||||
dataStore := accessor.NewMemData()
|
||||
adminStore := accessor.NewMemAdminStore(opts.Secret)
|
||||
imgStore := accessor.NewMemImageStore()
|
||||
|
||||
rpcServer := jrpc.Server{
|
||||
API: opts.API,
|
||||
AuthUser: opts.AuthUser,
|
||||
AuthPasswd: opts.AuthPasswd,
|
||||
Version: revision,
|
||||
AppName: "remark42-memory",
|
||||
Logger: log.Default(),
|
||||
}
|
||||
rpcServer := jrpc.NewServer(
|
||||
opts.API,
|
||||
jrpc.Auth(opts.AuthUser, opts.AuthPasswd),
|
||||
jrpc.WithSignature("remark42-memory", "umputun", revision),
|
||||
jrpc.WithLogger(log.Default()),
|
||||
)
|
||||
|
||||
srv := server.NewRPC(dataStore, adminStore, &rpcServer)
|
||||
srv := server.NewRPC(dataStore, adminStore, imgStore, rpcServer)
|
||||
|
||||
admRec := accessor.AdminRec{
|
||||
SiteID: "remark",
|
||||
IDs: []string{"dev_user"},
|
||||
Email: "admin@example.com",
|
||||
SiteID: "remark",
|
||||
Enabled: true,
|
||||
IDs: []string{"dev_user"},
|
||||
Email: "admin@example.com",
|
||||
}
|
||||
adminStore.Set("remark", admRec)
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright 2020 Umputun. All rights reserved.
|
||||
* Use of this source code is governed by a MIT-style
|
||||
* license that can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/go-pkgz/jrpc"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/store/admin"
|
||||
)
|
||||
|
||||
// get admin key
|
||||
func (s *RPC) admKeyHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
var siteID string
|
||||
if err := json.Unmarshal(params, &siteID); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
|
||||
key, err := s.adm.Key(siteID)
|
||||
if err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
return jrpc.EncodeResponse(id, key, err)
|
||||
}
|
||||
|
||||
// get admins list
|
||||
func (s *RPC) admAdminsHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
var siteID string
|
||||
if err := json.Unmarshal(params, &siteID); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
|
||||
admins, err := s.adm.Admins(siteID)
|
||||
if err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
return jrpc.EncodeResponse(id, admins, err)
|
||||
}
|
||||
|
||||
// get admin email
|
||||
func (s *RPC) admEmailHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
var siteID string
|
||||
if err := json.Unmarshal(params, &siteID); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
|
||||
email, err := s.adm.Email(siteID)
|
||||
if err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
return jrpc.EncodeResponse(id, email, err)
|
||||
}
|
||||
|
||||
// return site enabled status
|
||||
func (s *RPC) admEnabledHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
var siteID string
|
||||
if err := json.Unmarshal(params, &siteID); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
|
||||
ok, err := s.adm.Enabled(siteID)
|
||||
if err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
return jrpc.EncodeResponse(id, ok, err)
|
||||
}
|
||||
|
||||
// onEvent returns nothing, callback to OnEvent
|
||||
func (s *RPC) admEventHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
var siteID string
|
||||
var ps []interface{}
|
||||
if err := json.Unmarshal(params, &ps); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
siteID, ok := ps[0].(string)
|
||||
if !ok {
|
||||
return jrpc.Response{Error: "wrong siteID type"}
|
||||
}
|
||||
evType, ok := ps[1].(float64)
|
||||
if !ok {
|
||||
return jrpc.Response{Error: "wrong event type"}
|
||||
}
|
||||
err := s.adm.OnEvent(siteID, admin.EventType(evType))
|
||||
if err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
return jrpc.EncodeResponse(id, nil, err)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2020 Umputun. All rights reserved.
|
||||
* Use of this source code is governed by a MIT-style
|
||||
* license that can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/jrpc"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/store/admin"
|
||||
)
|
||||
|
||||
func TestRPC_admKeyHndl(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
ra := admin.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
key, err := ra.Key("any")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "secret", key)
|
||||
}
|
||||
|
||||
func TestRPC_admAdminsHndl(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
ra := admin.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
_, err := ra.Admins("bad site")
|
||||
assert.EqualError(t, err, "site bad site not found")
|
||||
|
||||
admins, err := ra.Admins("test-site")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"id1", "id2"}, admins)
|
||||
}
|
||||
|
||||
func TestRPC_admEmailHndl(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
ra := admin.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
_, err := ra.Admins("bad site")
|
||||
assert.EqualError(t, err, "site bad site not found")
|
||||
|
||||
email, err := ra.Email("test-site")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "admin@example.com", email)
|
||||
}
|
||||
|
||||
func TestRPC_admEnabledHndl(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
ra := admin.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
_, err := ra.Enabled("bad site")
|
||||
assert.EqualError(t, err, "site bad site not found")
|
||||
|
||||
ok, err := ra.Enabled("test-site")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, true, ok)
|
||||
|
||||
ok, err = ra.Enabled("test-site-disabled")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, false, ok)
|
||||
}
|
||||
|
||||
func TestRPC_admEventHndl(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
ra := admin.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
err := ra.OnEvent("bad site", admin.EvCreate)
|
||||
assert.EqualError(t, err, "site bad site not found")
|
||||
|
||||
err = ra.OnEvent("test-site", admin.EvCreate)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright 2020 Umputun. All rights reserved.
|
||||
* Use of this source code is governed by a MIT-style
|
||||
* license that can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/go-pkgz/jrpc"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store/engine"
|
||||
)
|
||||
|
||||
func (s *RPC) createHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
comment := store.Comment{}
|
||||
if err := json.Unmarshal(params, &comment); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
commentID, err := s.eng.Create(comment)
|
||||
return jrpc.EncodeResponse(id, commentID, err)
|
||||
}
|
||||
|
||||
// Find comments
|
||||
func (s *RPC) findHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
req := engine.FindRequest{}
|
||||
if err := json.Unmarshal(params, &req); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
resp, err := s.eng.Find(req)
|
||||
return jrpc.EncodeResponse(id, resp, err)
|
||||
}
|
||||
|
||||
// Get comment
|
||||
func (s *RPC) getHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
req := engine.GetRequest{}
|
||||
if err := json.Unmarshal(params, &req); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
comment, err := s.eng.Get(req)
|
||||
return jrpc.EncodeResponse(id, comment, err)
|
||||
}
|
||||
|
||||
// Update comment
|
||||
func (s *RPC) updateHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
comment := store.Comment{}
|
||||
if err := json.Unmarshal(params, &comment); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
err := s.eng.Update(comment)
|
||||
return jrpc.EncodeResponse(id, nil, err)
|
||||
}
|
||||
|
||||
// counts for site and users
|
||||
func (s *RPC) countHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
req := engine.FindRequest{}
|
||||
if err := json.Unmarshal(params, &req); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
count, err := s.eng.Count(req)
|
||||
return jrpc.EncodeResponse(id, count, err)
|
||||
}
|
||||
|
||||
// info get post meta info
|
||||
func (s *RPC) infoHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
req := engine.InfoRequest{}
|
||||
if err := json.Unmarshal(params, &req); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
info, err := s.eng.Info(req)
|
||||
return jrpc.EncodeResponse(id, info, err)
|
||||
}
|
||||
|
||||
// flagHndl get and sets flag value
|
||||
func (s *RPC) flagHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
req := engine.FlagRequest{}
|
||||
if err := json.Unmarshal(params, &req); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
status, err := s.eng.Flag(req)
|
||||
return jrpc.EncodeResponse(id, status, err)
|
||||
}
|
||||
|
||||
// listFlagsHndl list flags for given request
|
||||
func (s *RPC) listFlagsHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
req := engine.FlagRequest{}
|
||||
if err := json.Unmarshal(params, &req); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
flags, err := s.eng.ListFlags(req)
|
||||
return jrpc.EncodeResponse(id, flags, err)
|
||||
}
|
||||
|
||||
// userDetailHndl sets or gets single detail value, or gets all details for requested site.
|
||||
// userDetailHndl returns list even for single entry request is a compromise in order to have both single detail getting and setting
|
||||
// and all site's details listing under the same function (and not to extend engine interface by two separate functions).
|
||||
func (s *RPC) userDetailHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
req := engine.UserDetailRequest{}
|
||||
if err := json.Unmarshal(params, &req); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
value, err := s.eng.UserDetail(req)
|
||||
return jrpc.EncodeResponse(id, value, err)
|
||||
}
|
||||
|
||||
// deleteHndl delete post(s), user, comment, user details, or everything
|
||||
func (s *RPC) deleteHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
req := engine.DeleteRequest{}
|
||||
if err := json.Unmarshal(params, &req); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
err := s.eng.Delete(req)
|
||||
return jrpc.EncodeResponse(id, nil, err)
|
||||
}
|
||||
|
||||
// close store
|
||||
func (s *RPC) closeHndl(_ uint64, _ json.RawMessage) (rr jrpc.Response) {
|
||||
if err := s.eng.Close(); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
return jrpc.Response{}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
/*
|
||||
* Copyright 2020 Umputun. All rights reserved.
|
||||
* Use of this source code is governed by a MIT-style
|
||||
* license that can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/jrpc"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store/engine"
|
||||
)
|
||||
|
||||
func TestRPC_createHndl(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
id, err := re.Create(store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "123456", id)
|
||||
}
|
||||
|
||||
func TestRPC_findHndl(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
findReq := engine.FindRequest{Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"}}
|
||||
comments, err := re.Find(findReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(comments))
|
||||
|
||||
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
|
||||
id, err := re.Create(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "123456", id)
|
||||
|
||||
comments, err = re.Find(findReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, len(comments))
|
||||
assert.Equal(t, c, comments[0])
|
||||
}
|
||||
|
||||
func TestRPC_getHndl(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
req := engine.GetRequest{
|
||||
Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
CommentID: "123456",
|
||||
}
|
||||
|
||||
_, err := re.Get(req)
|
||||
assert.EqualError(t, err, "not found")
|
||||
|
||||
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
|
||||
_, err = re.Create(c)
|
||||
assert.NoError(t, err)
|
||||
|
||||
comment, err := re.Get(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, c, comment)
|
||||
}
|
||||
|
||||
func TestRPC_updateHndl(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
|
||||
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
|
||||
err := re.Update(c)
|
||||
assert.EqualError(t, err, "not found")
|
||||
|
||||
_, err = re.Create(c)
|
||||
assert.NoError(t, err)
|
||||
|
||||
c.Text = "updates"
|
||||
err = re.Update(c)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := engine.GetRequest{
|
||||
Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
CommentID: "123456",
|
||||
}
|
||||
comment, err := re.Get(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, c, comment)
|
||||
}
|
||||
|
||||
func TestRPC_countHndl(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
findReq := engine.FindRequest{Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"}}
|
||||
count, err := re.Count(findReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, count)
|
||||
|
||||
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
|
||||
id, err := re.Create(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "123456", id)
|
||||
|
||||
count, err = re.Count(findReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
}
|
||||
|
||||
func TestRPC_infoHndl(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
|
||||
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
|
||||
id, err := re.Create(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "123456", id)
|
||||
|
||||
infoReq := engine.InfoRequest{Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"}}
|
||||
info, err := re.Info(infoReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, len(info))
|
||||
i := info[0]
|
||||
assert.Equal(t, store.PostInfo{URL: "http://example.com/post1", Count: 1}, i)
|
||||
}
|
||||
|
||||
func TestRPC_flagHndl(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
|
||||
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
|
||||
id, err := re.Create(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "123456", id)
|
||||
|
||||
flagReq := engine.FlagRequest{
|
||||
Flag: engine.Verified,
|
||||
Locator: store.Locator{
|
||||
SiteID: "test-site",
|
||||
},
|
||||
UserID: "u1",
|
||||
}
|
||||
status, err := re.Flag(flagReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, false, status)
|
||||
|
||||
flagReq.Update = engine.FlagTrue
|
||||
status, err = re.Flag(flagReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, status)
|
||||
|
||||
flagReq.Update = engine.FlagNonSet
|
||||
status, err = re.Flag(flagReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, status)
|
||||
}
|
||||
|
||||
func TestRPC_listFlagsHndl(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
|
||||
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
|
||||
id, err := re.Create(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "123456", id)
|
||||
|
||||
// verify user
|
||||
verifyFlagReq := engine.FlagRequest{
|
||||
Flag: engine.Verified,
|
||||
UserID: "u1",
|
||||
Locator: store.Locator{
|
||||
SiteID: "test-site",
|
||||
},
|
||||
}
|
||||
flags, err := re.ListFlags(verifyFlagReq)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, flags)
|
||||
|
||||
verifyFlagReq.Update = engine.FlagTrue
|
||||
status, err := re.Flag(verifyFlagReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, status)
|
||||
|
||||
flags, err = re.ListFlags(verifyFlagReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []interface{}{"u1"}, flags)
|
||||
verifiedUsers := make([]string, 0, len(flags))
|
||||
for _, v := range flags {
|
||||
verifiedUsers = append(verifiedUsers, v.(string))
|
||||
}
|
||||
assert.Equal(t, []string{"u1"}, verifiedUsers)
|
||||
|
||||
// block user
|
||||
blockFlagReq := engine.FlagRequest{
|
||||
Flag: engine.Blocked,
|
||||
UserID: "u1",
|
||||
Locator: store.Locator{
|
||||
SiteID: "test-site",
|
||||
},
|
||||
TTL: time.Hour,
|
||||
}
|
||||
flags, err = re.ListFlags(blockFlagReq)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, flags)
|
||||
|
||||
blockFlagReq.Update = engine.FlagTrue
|
||||
status, err = re.Flag(blockFlagReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, status)
|
||||
|
||||
flags, err = re.ListFlags(blockFlagReq)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, flags)
|
||||
blockedUsers := make([]store.BlockedUser, 0, len(flags))
|
||||
for _, v := range flags {
|
||||
blockedUsers = append(blockedUsers, v.(store.BlockedUser))
|
||||
}
|
||||
require.Equal(t, 1, len(blockedUsers))
|
||||
blockedUserInfo := blockedUsers[0]
|
||||
assert.Equal(t, "u1", blockedUserInfo.ID)
|
||||
assert.True(t, blockedUserInfo.Until.After(time.Now().Add(time.Minute*59)), "blocked duration is more than 59m away")
|
||||
assert.True(t, blockedUserInfo.Until.Before(time.Now().Add(time.Minute*61)), "blocked duration is less than 61m away")
|
||||
}
|
||||
|
||||
func TestRPC_userDetailHndl(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
|
||||
// add to entries to DB before we start
|
||||
result, err := re.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "u1", Detail: engine.UserEmail, Update: "test@example.com"})
|
||||
assert.NoError(t, err, "No error inserting entry expected")
|
||||
assert.ElementsMatch(t, []engine.UserDetailEntry{{UserID: "u1", Email: "test@example.com"}}, result)
|
||||
result, err = re.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "u2", Detail: engine.UserEmail, Update: "other@example.com"})
|
||||
assert.NoError(t, err, "No error inserting entry expected")
|
||||
assert.ElementsMatch(t, []engine.UserDetailEntry{{UserID: "u2", Email: "other@example.com"}}, result)
|
||||
|
||||
// try to change existing entry with wrong SiteID
|
||||
result, err = re.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "bad"}, UserID: "u2", Detail: engine.UserEmail, Update: "not_relevant"})
|
||||
assert.NoError(t, err, "Updating existing entry with wrong SiteID doesn't produce error")
|
||||
assert.ElementsMatch(t, []engine.UserDetailEntry{}, result, "Updating existing entry with wrong SiteID doesn't change anything")
|
||||
|
||||
// stateless tests without changing the state we set up before
|
||||
var testData = []struct {
|
||||
req engine.UserDetailRequest
|
||||
error string
|
||||
expected []engine.UserDetailEntry
|
||||
}{
|
||||
{req: engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "u1", Detail: engine.UserEmail},
|
||||
expected: []engine.UserDetailEntry{{UserID: "u1", Email: "test@example.com"}}},
|
||||
{req: engine.UserDetailRequest{Locator: store.Locator{SiteID: "bad"}, UserID: "u1", Detail: engine.UserEmail},
|
||||
expected: []engine.UserDetailEntry{}},
|
||||
{req: engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "u1xyz", Detail: engine.UserEmail},
|
||||
expected: []engine.UserDetailEntry{}},
|
||||
{req: engine.UserDetailRequest{Detail: engine.UserEmail, Update: "new_value"},
|
||||
error: `userid cannot be empty in request for single detail`},
|
||||
{req: engine.UserDetailRequest{Detail: engine.UserDetail("bad")},
|
||||
error: `unsupported detail "bad"`},
|
||||
{req: engine.UserDetailRequest{Update: "not_relevant", Detail: engine.AllUserDetails},
|
||||
error: `unsupported request with userdetail all`},
|
||||
{req: engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, Detail: engine.AllUserDetails},
|
||||
expected: []engine.UserDetailEntry{{UserID: "u1", Email: "test@example.com"}, {UserID: "u2", Email: "other@example.com"}}},
|
||||
}
|
||||
|
||||
for i, x := range testData {
|
||||
result, err := re.UserDetail(x.req)
|
||||
if x.error != "" {
|
||||
assert.EqualError(t, err, x.error, "Error should match expected for case %d", i)
|
||||
} else {
|
||||
assert.NoError(t, err, "Error is not expected expected for case %d", i)
|
||||
}
|
||||
assert.ElementsMatch(t, x.expected, result, "Result should match expected for case %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPC_deleteHndl(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
req := engine.DeleteRequest{
|
||||
Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
CommentID: "123456",
|
||||
}
|
||||
|
||||
err := re.Delete(req)
|
||||
assert.EqualError(t, err, "not found")
|
||||
|
||||
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
|
||||
_, err = re.Create(c)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = re.Delete(req)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRPC_closeHndl(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
assert.NoError(t, re.Close())
|
||||
assert.NoError(t, re.Close(), "second call should not result in panic or errors")
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2020 Umputun. All rights reserved.
|
||||
* Use of this source code is governed by a MIT-style
|
||||
* license that can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/jrpc"
|
||||
)
|
||||
|
||||
func (s *RPC) imgSaveWithIDHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
var req [2]string
|
||||
if err := json.Unmarshal(params, &req); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
img, err := base64.StdEncoding.DecodeString(req[1])
|
||||
if err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
err = s.img.Save(req[0], img)
|
||||
return jrpc.EncodeResponse(id, nil, err)
|
||||
}
|
||||
|
||||
func (s *RPC) imgResetClnTimerHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
var fileID string
|
||||
if err := json.Unmarshal(params, &fileID); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
err := s.img.ResetCleanupTimer(fileID)
|
||||
return jrpc.EncodeResponse(id, nil, err)
|
||||
|
||||
}
|
||||
|
||||
func (s *RPC) imgLoadHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
var fileID string
|
||||
if err := json.Unmarshal(params, &fileID); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
value, err := s.img.Load(fileID)
|
||||
return jrpc.EncodeResponse(id, value, err)
|
||||
}
|
||||
|
||||
func (s *RPC) imgCommitHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
var fileID string
|
||||
if err := json.Unmarshal(params, &fileID); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
err := s.img.Commit(fileID)
|
||||
return jrpc.EncodeResponse(id, nil, err)
|
||||
}
|
||||
|
||||
func (s *RPC) imgCleanupHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
var ttl time.Duration
|
||||
if err := json.Unmarshal(params, &ttl); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
err := s.img.Cleanup(context.TODO(), ttl)
|
||||
return jrpc.EncodeResponse(id, nil, err)
|
||||
}
|
||||
|
||||
func (s *RPC) imgInfoHndl(id uint64, _ json.RawMessage) (rr jrpc.Response) {
|
||||
info, err := s.img.Info()
|
||||
return jrpc.EncodeResponse(id, info, err)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2020 Umputun. All rights reserved.
|
||||
* Use of this source code is governed by a MIT-style
|
||||
* license that can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/jrpc"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/store/image"
|
||||
)
|
||||
|
||||
// gopher png for test, from https://golang.org/src/image/png/example_test.go
|
||||
const gopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2" +
|
||||
"+OPbo9d7tsWyiyaZti6eWGAhISoIGKECEKCAiJJkYTiUgTMYSIosYYBBIUIxoSPIINEBDi2VhwkQrVsj1ESgu9doHWdrul7ba" +
|
||||
"73WNm3vOPtsseM9MdwvvrzTs+8/t95ze/33sI5BqiabU6m9En8oNjduLnAEDLUsQXFF8tQ5oxK3vmnNmDSMtrncks9Hhtt" +
|
||||
"/qeWZapHb1ha3UqYSWVl2ZmpWgaXMXGohQAvmeop3bjTRtv6SgaK/Pb9/bFzUrYslbFAmHPp+3WhAYdr+7GN/YnpN46Opv55VDs" +
|
||||
"JkoEpMrY/vO2BIYQ6LLvm0ThY3MzDzzeSJeeWNyTkgnIE5ePKsvKlcg/0T9QMzXalwXMlj54z4c0rh/mzEfr+FgWEz2w6uk" +
|
||||
"8dkzFAgcARAgNp1ZYef8bH2AgvuStbc2/i6CiWGj98y2tw2l4FAXKkQBIf+exyRnteY83LfEwDQAYCoK+P6bxkZm/0966LxcAA" +
|
||||
"ILHB56kgD95PPxltuYcMtFTWw/FKkY/6Opf3GGd9ZF+Qp6mzJxzuRSractOmJrH1u8XTvWFHINNkLQLMR+XHXvfPPHw967raE1xxwtA36I" +
|
||||
"MRfkAAG29/7mLuQcb2WOnsJReZGfpiHsSBX81cvMKywYZHhX5hFPtOqPGWZCXnhWGAu6lX91ElKXSalcLXu3UaOXVay57ZSe5f6Gpx7J2" +
|
||||
"MXAsi7EqSp09b/MirKSyJfnfEEgeDjl8FgDAfvewP03zZ+AJ0m9aFRM8eEHBDRKjfcreDXnZdQuAxXpT2NRJ7xl3UkLBhuVGU16gZiGOgZm" +
|
||||
"rSbRdqkILuL/yYoSXHHkl9KXgqNu3PB8oRg0geC5vFmLjad6mUyTKLmF3OtraWDIfACyXqmephaDABawfpi6tqqBZytfQMqOz6S09iWXhkt" +
|
||||
"rRaB8Xz4Yi/8gyABDm5NVe6qq/3VzPrcjELWrebVuyY2T7ar4zQyybUCtsQ5Es1FGaZVrRVQwAgHGW2ZCRZshI5bGQi7HesyE972pOSeMM0" +
|
||||
"dSktlzxRdrlqb3Osa6CCS8IJoQQQgBAbTAa5l5epO34rJszibJI8rxLfGzcp1dRosutGeb2VDNgqYrwTiPNsLxXiPi3dz7LiS1WBRBDBOnqEj" +
|
||||
"yy3aQb+/bLiJzz9dIkscVBBLxMfSEac7kO4Fpkngi0ruNBeSOal+u8jgOuqPz12nryMLCniEjtOOOmpt+KEIqsEdocJjYXwrh9OZqWJQyPCTo67" +
|
||||
"LNS/TdxLAv6R5ZNK9npEjbYdT33gRo4o5oTqR34R+OmaSzDBWsAIPhuRcgyoteNi9gF0KzNYWVItPf2TLoXEg+7isNC7uJkgo1iQWOfRSP9NR" +
|
||||
"11RtbZZ3OMG/VhL6jvx+J1m87+RCfJChAtEBQkSBX2PnSiihc/Twh3j0h7qdYQAoRVsRGmq7HU2QRbaxVGa1D6nIOqaIWRjyRZpHMQKWKpZM5fe" +
|
||||
"A+lzC4ZFultV8S6T0mzQGhQohi5I8iw+CsqBSxhFMuwyLgSwbghGb0AiIKkSDmGZVmJSiKihsiyOAUs70UkywooYP0bii9GdH4sfr1UNysd3fU" +
|
||||
"yLLMQN+rsmo3grHl9VNJHbbwxoa47Vw5gupIqrZcjPh9R4Nye3nRDk199V+aetmvVtDRE8/+cbgAAgMIWGb3UA0MGLE9SCbWX670TDy" +
|
||||
"1y98c3D27eppUjsZ6fql3jcd5rUe7+ZIlLNQny3Rd+E5Tct3WVhTM5RBCEdiEK0b6B+/ca2gYU393nFj/n1AygRQxPIUA043M42u85+z2S" +
|
||||
"nssKrPl8Mx76NL3E6eXc3be7OD+H4WHbJkKI8AU8irbITQjZ+0hQcPEgId/Fn/pl9crKH02+5o2b9T/eMx7pKoskYgAAAABJRU5ErkJggg=="
|
||||
|
||||
func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) }
|
||||
func gopherPNGBytes() []byte {
|
||||
img, _ := io.ReadAll(gopherPNG())
|
||||
return img
|
||||
}
|
||||
|
||||
func TestRPC_imgLoadHndl(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
ri := image.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
// save
|
||||
id := "test_img"
|
||||
err := ri.Save(id, gopherPNGBytes())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// load
|
||||
img, err := ri.Load(id)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1462, len(img))
|
||||
assert.Equal(t, gopherPNGBytes(), img)
|
||||
|
||||
// commit
|
||||
err = ri.Commit(id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// load after commit
|
||||
img, err = ri.Load(id)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1462, len(img))
|
||||
assert.Equal(t, gopherPNGBytes(), img)
|
||||
|
||||
// cleanup
|
||||
err = ri.Cleanup(context.TODO(), time.Second)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// load after cleanup
|
||||
img, err = ri.Load(id)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1462, len(img))
|
||||
assert.Equal(t, gopherPNGBytes(), img)
|
||||
}
|
||||
|
||||
func TestRPC_imgCommitHndlFail(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
ri := image.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
err := ri.Commit("test_id")
|
||||
assert.EqualError(t, err, "failed to commit test_id, not found in staging")
|
||||
}
|
||||
|
||||
func TestRPC_imgCleanupHndl(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
ri := image.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
|
||||
// save
|
||||
id := "test_img"
|
||||
err := ri.Save(id, gopherPNGBytes())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// load
|
||||
img, err := ri.Load(id)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1462, len(img))
|
||||
assert.Equal(t, gopherPNGBytes(), img)
|
||||
|
||||
// wait for image to expire
|
||||
time.Sleep(time.Millisecond * 50)
|
||||
// reset the time to cleanup
|
||||
err = ri.ResetCleanupTimer(id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// cleanup, should not affect the new image
|
||||
err = ri.Cleanup(context.TODO(), time.Millisecond*45)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// load after cleanup should succeed
|
||||
_, err = ri.Load(id)
|
||||
assert.NoError(t, err, "image is still on staging because it's cleanup timer was reset")
|
||||
|
||||
// cleanup with short TTL, should remove the image from staging
|
||||
err = ri.Cleanup(context.TODO(), time.Nanosecond)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// load after cleanup should fail
|
||||
_, err = ri.Load(id)
|
||||
assert.EqualError(t, err, "image test_img not found")
|
||||
}
|
||||
|
||||
func TestRPC_imgInfoHndl(t *testing.T) {
|
||||
port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
ri := image.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
|
||||
// get info on empty storage, should be zero
|
||||
info, err := ri.Info()
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, info.FirstStagingImageTS.IsZero())
|
||||
|
||||
// save
|
||||
err = ri.Save("test_img", gopherPNGBytes())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// get info after saving, should be non-zero
|
||||
info, err = ri.Info()
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, info.FirstStagingImageTS.IsZero())
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Umputun. All rights reserved.
|
||||
* Use of this source code is governed by a MIT-style
|
||||
* license that can be found in the LICENSE file.
|
||||
*/
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/go-pkgz/jrpc"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark/backend/app/store/admin"
|
||||
"github.com/umputun/remark/backend/app/store/engine"
|
||||
"github.com/umputun/remark42/backend/app/store/admin"
|
||||
"github.com/umputun/remark42/backend/app/store/engine"
|
||||
"github.com/umputun/remark42/backend/app/store/image"
|
||||
)
|
||||
|
||||
// RPC handler wraps both engine and remote server and implements all handlers for data store and admin store
|
||||
@@ -16,11 +20,12 @@ type RPC struct {
|
||||
*jrpc.Server
|
||||
eng engine.Interface
|
||||
adm admin.Store
|
||||
img image.Store
|
||||
}
|
||||
|
||||
// NewRPC makes RPC instance and register handlers
|
||||
func NewRPC(e engine.Interface, a admin.Store, r *jrpc.Server) *RPC {
|
||||
res := &RPC{eng: e, adm: a, Server: r}
|
||||
func NewRPC(e engine.Interface, a admin.Store, i image.Store, r *jrpc.Server) *RPC {
|
||||
res := &RPC{eng: e, adm: a, img: i, Server: r}
|
||||
res.addHandlers()
|
||||
return res
|
||||
}
|
||||
@@ -49,186 +54,14 @@ func (s *RPC) addHandlers() {
|
||||
"enabled": s.admEnabledHndl,
|
||||
"event": s.admEventHndl,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *RPC) createHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
comment := store.Comment{}
|
||||
if err := json.Unmarshal(params, &comment); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
commentID, err := s.eng.Create(comment)
|
||||
return jrpc.EncodeResponse(id, commentID, err)
|
||||
}
|
||||
|
||||
// Find comments
|
||||
func (s *RPC) findHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
req := engine.FindRequest{}
|
||||
if err := json.Unmarshal(params, &req); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
resp, err := s.eng.Find(req)
|
||||
return jrpc.EncodeResponse(id, resp, err)
|
||||
}
|
||||
|
||||
// Get comment
|
||||
func (s *RPC) getHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
req := engine.GetRequest{}
|
||||
if err := json.Unmarshal(params, &req); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
comment, err := s.eng.Get(req)
|
||||
return jrpc.EncodeResponse(id, comment, err)
|
||||
}
|
||||
|
||||
// Update comment
|
||||
func (s *RPC) updateHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
comment := store.Comment{}
|
||||
if err := json.Unmarshal(params, &comment); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
err := s.eng.Update(comment)
|
||||
return jrpc.EncodeResponse(id, nil, err)
|
||||
}
|
||||
|
||||
// counts for site and users
|
||||
func (s *RPC) countHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
req := engine.FindRequest{}
|
||||
if err := json.Unmarshal(params, &req); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
count, err := s.eng.Count(req)
|
||||
return jrpc.EncodeResponse(id, count, err)
|
||||
}
|
||||
|
||||
// info get post meta info
|
||||
func (s *RPC) infoHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
req := engine.InfoRequest{}
|
||||
if err := json.Unmarshal(params, &req); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
info, err := s.eng.Info(req)
|
||||
return jrpc.EncodeResponse(id, info, err)
|
||||
}
|
||||
|
||||
// flagHndl get and sets flag value
|
||||
func (s *RPC) flagHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
req := engine.FlagRequest{}
|
||||
if err := json.Unmarshal(params, &req); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
status, err := s.eng.Flag(req)
|
||||
return jrpc.EncodeResponse(id, status, err)
|
||||
}
|
||||
|
||||
// listFlagsHndl list flags for given request
|
||||
func (s *RPC) listFlagsHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
req := engine.FlagRequest{}
|
||||
if err := json.Unmarshal(params, &req); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
flags, err := s.eng.ListFlags(req)
|
||||
return jrpc.EncodeResponse(id, flags, err)
|
||||
}
|
||||
|
||||
// userDetailHndl sets or gets single detail value, or gets all details for requested site.
|
||||
// userDetailHndl returns list even for single entry request is a compromise in order to have both single detail getting and setting
|
||||
// and all site's details listing under the same function (and not to extend engine interface by two separate functions).
|
||||
func (s *RPC) userDetailHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
req := engine.UserDetailRequest{}
|
||||
if err := json.Unmarshal(params, &req); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
value, err := s.eng.UserDetail(req)
|
||||
return jrpc.EncodeResponse(id, value, err)
|
||||
}
|
||||
|
||||
// deleteHndl delete post(s), user, comment, user details, or everything
|
||||
func (s *RPC) deleteHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
req := engine.DeleteRequest{}
|
||||
if err := json.Unmarshal(params, &req); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
err := s.eng.Delete(req)
|
||||
return jrpc.EncodeResponse(id, nil, err)
|
||||
}
|
||||
|
||||
// close store
|
||||
func (s *RPC) closeHndl(id uint64, _ json.RawMessage) (rr jrpc.Response) {
|
||||
if err := s.eng.Close(); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
return jrpc.Response{}
|
||||
}
|
||||
|
||||
// get admin key
|
||||
func (s *RPC) admKeyHndl(id uint64, _ json.RawMessage) (rr jrpc.Response) {
|
||||
key, err := s.adm.Key()
|
||||
if err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
return jrpc.EncodeResponse(id, key, err)
|
||||
}
|
||||
|
||||
// get admins list
|
||||
func (s *RPC) admAdminsHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
var siteID string
|
||||
if err := json.Unmarshal(params, &siteID); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
|
||||
admins, err := s.adm.Admins(siteID)
|
||||
if err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
return jrpc.EncodeResponse(id, admins, err)
|
||||
}
|
||||
|
||||
// get admin email
|
||||
func (s *RPC) admEmailHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
var siteID string
|
||||
if err := json.Unmarshal(params, &siteID); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
|
||||
email, err := s.adm.Email(siteID)
|
||||
if err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
return jrpc.EncodeResponse(id, email, err)
|
||||
}
|
||||
|
||||
// return site enabled status
|
||||
func (s *RPC) admEnabledHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
var siteID string
|
||||
if err := json.Unmarshal(params, &siteID); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
|
||||
ok, err := s.adm.Enabled(siteID)
|
||||
if err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
return jrpc.EncodeResponse(id, ok, err)
|
||||
}
|
||||
|
||||
// onEvent returns nothing, callback to OnEvent
|
||||
func (s *RPC) admEventHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||
var siteID string
|
||||
ps := []interface{}{}
|
||||
if err := json.Unmarshal(params, &ps); err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
siteID, ok := ps[0].(string)
|
||||
if !ok {
|
||||
return jrpc.Response{Error: "wrong siteID type"}
|
||||
}
|
||||
evType, ok := ps[1].(float64)
|
||||
if !ok {
|
||||
return jrpc.Response{Error: "wrong event type"}
|
||||
}
|
||||
err := s.adm.OnEvent(siteID, admin.EventType(evType))
|
||||
if err != nil {
|
||||
return jrpc.Response{Error: err.Error()}
|
||||
}
|
||||
return jrpc.EncodeResponse(id, nil, err)
|
||||
// image store handlers
|
||||
s.Group("image", jrpc.HandlersGroup{
|
||||
"save_with_id": s.imgSaveWithIDHndl,
|
||||
"reset_cleanup_timer": s.imgResetClnTimerHndl,
|
||||
"load": s.imgLoadHndl,
|
||||
"commit": s.imgCommitHndl,
|
||||
"cleanup": s.imgCleanupHndl,
|
||||
"info": s.imgInfoHndl,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2019 Umputun. All rights reserved.
|
||||
* Copyright 2020 Umputun. All rights reserved.
|
||||
* Use of this source code is governed by a MIT-style
|
||||
* license that can be found in the LICENSE file.
|
||||
*/
|
||||
@@ -15,370 +15,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/jrpc"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark/backend/app/store/admin"
|
||||
"github.com/umputun/remark/backend/app/store/engine"
|
||||
|
||||
"github.com/umputun/remark/memory_store/accessor"
|
||||
"github.com/umputun/remark42/memory_store/accessor"
|
||||
)
|
||||
|
||||
func TestRPC_createHndl(t *testing.T) {
|
||||
_, port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
id, err := re.Create(store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "123456", id)
|
||||
}
|
||||
|
||||
func TestRPC_findHndl(t *testing.T) {
|
||||
_, port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
findReq := engine.FindRequest{Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"}}
|
||||
comments, err := re.Find(findReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(comments))
|
||||
|
||||
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
|
||||
id, err := re.Create(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "123456", id)
|
||||
|
||||
comments, err = re.Find(findReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, len(comments))
|
||||
assert.Equal(t, c, comments[0])
|
||||
}
|
||||
|
||||
func TestRPC_getHndl(t *testing.T) {
|
||||
_, port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
req := engine.GetRequest{
|
||||
Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
CommentID: "123456",
|
||||
}
|
||||
|
||||
_, err := re.Get(req)
|
||||
assert.EqualError(t, err, "not found")
|
||||
|
||||
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
|
||||
_, err = re.Create(c)
|
||||
assert.NoError(t, err)
|
||||
|
||||
comment, err := re.Get(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, c, comment)
|
||||
}
|
||||
|
||||
func TestRPC_updateHndl(t *testing.T) {
|
||||
_, port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
|
||||
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
|
||||
err := re.Update(c)
|
||||
assert.EqualError(t, err, "not found")
|
||||
|
||||
_, err = re.Create(c)
|
||||
assert.NoError(t, err)
|
||||
|
||||
c.Text = "updates"
|
||||
err = re.Update(c)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := engine.GetRequest{
|
||||
Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
CommentID: "123456",
|
||||
}
|
||||
comment, err := re.Get(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, c, comment)
|
||||
}
|
||||
|
||||
func TestRPC_countHndl(t *testing.T) {
|
||||
_, port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
findReq := engine.FindRequest{Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"}}
|
||||
count, err := re.Count(findReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, count)
|
||||
|
||||
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
|
||||
id, err := re.Create(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "123456", id)
|
||||
|
||||
count, err = re.Count(findReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
}
|
||||
|
||||
func TestRPC_infoHndl(t *testing.T) {
|
||||
_, port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
|
||||
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
|
||||
id, err := re.Create(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "123456", id)
|
||||
|
||||
infoReq := engine.InfoRequest{Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"}}
|
||||
info, err := re.Info(infoReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, len(info))
|
||||
i := info[0]
|
||||
assert.Equal(t, store.PostInfo{URL: "http://example.com/post1", Count: 1}, i)
|
||||
}
|
||||
|
||||
func TestRPC_flagHndl(t *testing.T) {
|
||||
_, port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
|
||||
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
|
||||
id, err := re.Create(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "123456", id)
|
||||
|
||||
flagReq := engine.FlagRequest{
|
||||
Flag: engine.Verified,
|
||||
Locator: store.Locator{
|
||||
SiteID: "test-site",
|
||||
},
|
||||
UserID: "u1",
|
||||
}
|
||||
status, err := re.Flag(flagReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, false, status)
|
||||
|
||||
flagReq.Update = engine.FlagTrue
|
||||
status, err = re.Flag(flagReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, status)
|
||||
|
||||
flagReq.Update = engine.FlagNonSet
|
||||
status, err = re.Flag(flagReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, status)
|
||||
}
|
||||
|
||||
func TestRPC_listFlagsHndl(t *testing.T) {
|
||||
_, port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
|
||||
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
|
||||
id, err := re.Create(c)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "123456", id)
|
||||
|
||||
flagReq := engine.FlagRequest{
|
||||
Flag: engine.Verified,
|
||||
UserID: "u1",
|
||||
Locator: store.Locator{
|
||||
SiteID: "test-site",
|
||||
},
|
||||
}
|
||||
flags, err := re.ListFlags(flagReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []interface{}{}, flags)
|
||||
|
||||
flagReq.Update = engine.FlagTrue
|
||||
status, err := re.Flag(flagReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, status)
|
||||
|
||||
flags, err = re.ListFlags(flagReq)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []interface{}{"u1"}, flags)
|
||||
}
|
||||
|
||||
func TestRPC_userDetailHndl(t *testing.T) {
|
||||
_, port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
|
||||
// add to entries to DB before we start
|
||||
result, err := re.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "u1", Detail: engine.UserEmail, Update: "test@example.com"})
|
||||
assert.NoError(t, err, "No error inserting entry expected")
|
||||
assert.ElementsMatch(t, []engine.UserDetailEntry{{UserID: "u1", Email: "test@example.com"}}, result)
|
||||
result, err = re.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "u2", Detail: engine.UserEmail, Update: "other@example.com"})
|
||||
assert.NoError(t, err, "No error inserting entry expected")
|
||||
assert.ElementsMatch(t, []engine.UserDetailEntry{{UserID: "u2", Email: "other@example.com"}}, result)
|
||||
|
||||
// try to change existing entry with wrong SiteID
|
||||
result, err = re.UserDetail(engine.UserDetailRequest{Locator: store.Locator{SiteID: "bad"}, UserID: "u2", Detail: engine.UserEmail, Update: "not_relevant"})
|
||||
assert.NoError(t, err, "Updating existing entry with wrong SiteID doesn't produce error")
|
||||
assert.ElementsMatch(t, []engine.UserDetailEntry{}, result, "Updating existing entry with wrong SiteID doesn't change anything")
|
||||
|
||||
// stateless tests without changing the state we set up before
|
||||
var testData = []struct {
|
||||
req engine.UserDetailRequest
|
||||
error string
|
||||
expected []engine.UserDetailEntry
|
||||
}{
|
||||
{req: engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "u1", Detail: engine.UserEmail},
|
||||
expected: []engine.UserDetailEntry{{UserID: "u1", Email: "test@example.com"}}},
|
||||
{req: engine.UserDetailRequest{Locator: store.Locator{SiteID: "bad"}, UserID: "u1", Detail: engine.UserEmail},
|
||||
expected: []engine.UserDetailEntry{}},
|
||||
{req: engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, UserID: "u1xyz", Detail: engine.UserEmail},
|
||||
expected: []engine.UserDetailEntry{}},
|
||||
{req: engine.UserDetailRequest{Detail: engine.UserEmail, Update: "new_value"},
|
||||
error: `userid cannot be empty in request for single detail`},
|
||||
{req: engine.UserDetailRequest{Detail: engine.UserDetail("bad")},
|
||||
error: `unsupported detail "bad"`},
|
||||
{req: engine.UserDetailRequest{Update: "not_relevant", Detail: engine.AllUserDetails},
|
||||
error: `unsupported request with userdetail all`},
|
||||
{req: engine.UserDetailRequest{Locator: store.Locator{SiteID: "test-site"}, Detail: engine.AllUserDetails},
|
||||
expected: []engine.UserDetailEntry{{UserID: "u1", Email: "test@example.com"}, {UserID: "u2", Email: "other@example.com"}}},
|
||||
}
|
||||
|
||||
for i, x := range testData {
|
||||
result, err := re.UserDetail(x.req)
|
||||
if x.error != "" {
|
||||
assert.EqualError(t, err, x.error, "Error should match expected for case %d", i)
|
||||
} else {
|
||||
assert.NoError(t, err, "Error is not expected expected for case %d", i)
|
||||
}
|
||||
assert.ElementsMatch(t, x.expected, result, "Result should match expected for case %d", i)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPC_deleteHndl(t *testing.T) {
|
||||
_, port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
req := engine.DeleteRequest{
|
||||
Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
CommentID: "123456",
|
||||
}
|
||||
|
||||
err := re.Delete(req)
|
||||
assert.EqualError(t, err, "not found")
|
||||
|
||||
c := store.Comment{ID: "123456", Locator: store.Locator{SiteID: "test-site", URL: "http://example.com/post1"},
|
||||
Text: "text 123", User: store.User{ID: "u1", Name: "user1"}}
|
||||
_, err = re.Create(c)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = re.Delete(req)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRPC_closeHndl(t *testing.T) {
|
||||
_, port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
err := re.Close()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
func TestRPC_admKeyHndl(t *testing.T) {
|
||||
_, port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
ra := admin.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
key, err := ra.Key()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "secret", key)
|
||||
}
|
||||
|
||||
func TestRPC_admAdminsHndl(t *testing.T) {
|
||||
_, port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
ra := admin.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
_, err := ra.Admins("bad site")
|
||||
assert.EqualError(t, err, "site bad site not found")
|
||||
|
||||
admins, err := ra.Admins("test-site")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"id1", "id2"}, admins)
|
||||
}
|
||||
|
||||
func TestRPC_admEmailHndl(t *testing.T) {
|
||||
_, port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
ra := admin.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
_, err := ra.Admins("bad site")
|
||||
assert.EqualError(t, err, "site bad site not found")
|
||||
|
||||
email, err := ra.Email("test-site")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "admin@example.com", email)
|
||||
}
|
||||
|
||||
func TestRPC_admEnabledHndl(t *testing.T) {
|
||||
_, port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
ra := admin.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
_, err := ra.Enabled("bad site")
|
||||
assert.EqualError(t, err, "site bad site not found")
|
||||
|
||||
ok, err := ra.Enabled("test-site")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, true, ok)
|
||||
|
||||
ok, err = ra.Enabled("test-site-disabled")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, false, ok)
|
||||
}
|
||||
|
||||
func TestRPC_admEventHndl(t *testing.T) {
|
||||
_, port, teardown := prepTestStore(t)
|
||||
defer teardown()
|
||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||
|
||||
ra := admin.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||
err := ra.OnEvent("bad site", admin.EvCreate)
|
||||
assert.EqualError(t, err, "site bad site not found")
|
||||
|
||||
err = ra.OnEvent("test-site", admin.EvCreate)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func chooseRandomUnusedPort() (port int) {
|
||||
for i := 0; i < 10; i++ {
|
||||
port = 40000 + int(rand.Int31n(10000))
|
||||
@@ -402,10 +43,11 @@ func waitForHTTPServerStart(port int) {
|
||||
}
|
||||
}
|
||||
|
||||
func prepTestStore(t *testing.T) (s *RPC, port int, teardown func()) {
|
||||
func prepTestStore(t *testing.T) (port int, teardown func()) {
|
||||
mg := accessor.NewMemData()
|
||||
adm := accessor.NewMemAdminStore("secret")
|
||||
s = NewRPC(mg, adm, &jrpc.Server{API: "/test", Logger: jrpc.NoOpLogger})
|
||||
img := accessor.NewMemImageStore()
|
||||
s := NewRPC(mg, adm, img, jrpc.NewServer("/test"))
|
||||
|
||||
admRec := accessor.AdminRec{
|
||||
SiteID: "test-site",
|
||||
@@ -421,12 +63,12 @@ func prepTestStore(t *testing.T) (s *RPC, port int, teardown func()) {
|
||||
|
||||
port = chooseRandomUnusedPort()
|
||||
go func() {
|
||||
log.Printf("%v", s.Run(port))
|
||||
_ = s.Run(port)
|
||||
}()
|
||||
|
||||
waitForHTTPServerStart(port)
|
||||
|
||||
return s, port, func() {
|
||||
return port, func() {
|
||||
require.NoError(t, s.Shutdown())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
|
||||
bolt "github.com/coreos/bbolt"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/pkg/errors"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
|
||||
"github.com/go-pkgz/auth/avatar"
|
||||
)
|
||||
@@ -34,17 +34,17 @@ func (a avatarMigrator) Migrate(dst, src avatar.Store) (int, error) {
|
||||
}
|
||||
|
||||
// Execute runs with AvatarCommand parameters, entry point for "avatar" command
|
||||
func (ac *AvatarCommand) Execute(args []string) error {
|
||||
func (ac *AvatarCommand) Execute(_ []string) error {
|
||||
log.Printf("[INFO] migrate avatars from %s to %s", ac.AvatarSrc.Type, ac.AvatarDst.Type)
|
||||
|
||||
src, err := ac.makeAvatarStore(ac.AvatarSrc)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "can't make avatart store for %s", ac.AvatarSrc.Type)
|
||||
return fmt.Errorf("can't make avatart store for %s: %w", ac.AvatarSrc.Type, err)
|
||||
}
|
||||
|
||||
dst, err := ac.makeAvatarStore(ac.AvatarDst)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "can't make avatart store for %s", ac.AvatarDst.Type)
|
||||
return fmt.Errorf("can't make avatart store for %s: %w", ac.AvatarDst.Type, err)
|
||||
}
|
||||
|
||||
if ac.migrator == nil {
|
||||
@@ -72,14 +72,14 @@ func (ac *AvatarCommand) makeAvatarStore(gr AvatarGroup) (avatar.Store, error) {
|
||||
switch gr.Type {
|
||||
case "fs":
|
||||
if err := makeDirs(gr.FS.Path); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to create avatar store: %w", err)
|
||||
}
|
||||
return avatar.NewLocalFS(gr.FS.Path), nil
|
||||
case "bolt":
|
||||
if err := makeDirs(path.Dir(gr.Bolt.File)); err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to create avatar store: %w", err)
|
||||
}
|
||||
return avatar.NewBoltDB(gr.Bolt.File, bolt.Options{})
|
||||
}
|
||||
return nil, errors.Errorf("unsupported avatar store type %s", gr.Type)
|
||||
return nil, fmt.Errorf("unsupported avatar store type %s", gr.Type)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/go-pkgz/auth/avatar"
|
||||
flags "github.com/jessevdk/go-flags"
|
||||
"github.com/jessevdk/go-flags"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAvatar_Execute(t *testing.T) {
|
||||
|
||||
defer os.RemoveAll("/tmp/ava-test")
|
||||
|
||||
// from fs to bolt
|
||||
@@ -22,16 +21,18 @@ func TestAvatar_Execute(t *testing.T) {
|
||||
_, err := p.ParseArgs([]string{"--src.type=fs", "--src.fs.path=/tmp/ava-test", "--dst.type=bolt",
|
||||
"--dst.bolt.file=/tmp/ava-test.db"})
|
||||
require.NoError(t, err)
|
||||
defer os.Remove("/tmp/ava-test.db")
|
||||
err = cmd.Execute(nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// failed
|
||||
cmd = AvatarCommand{migrator: &avatarMigratorMock{retCount: 0, retError: errors.New("failed blah")}}
|
||||
cmd = AvatarCommand{migrator: &avatarMigratorMock{retCount: 0, retError: fmt.Errorf("failed blah")}}
|
||||
cmd.SetCommon(CommonOpts{RemarkURL: "", SharedSecret: "123456"})
|
||||
p = flags.NewParser(&cmd, flags.Default)
|
||||
_, err = p.ParseArgs([]string{"--src.type=fs", "--src.fs.path=/tmp/ava-test", "--dst.type=bolt",
|
||||
"--dst.bolt.file=/tmp/ava-test2.db"})
|
||||
require.NoError(t, err)
|
||||
defer os.Remove("/tmp/ava-test2.db")
|
||||
err = cmd.Execute(nil)
|
||||
assert.Error(t, err, "failed blah")
|
||||
}
|
||||
@@ -42,7 +43,7 @@ type avatarMigratorMock struct {
|
||||
retCount int
|
||||
}
|
||||
|
||||
func (a *avatarMigratorMock) Migrate(dst, src avatar.Store) (int, error) {
|
||||
func (a *avatarMigratorMock) Migrate(_, _ avatar.Store) (int, error) {
|
||||
a.called++
|
||||
return a.retCount, a.retError
|
||||
}
|
||||
|
||||
+13
-14
@@ -9,22 +9,20 @@ import (
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// BackupCommand set of flags and command for export
|
||||
// ExportPath used as a separate element to leverage BACKUP_PATH. If ExportFile has a path (i.e. with /) BACKUP_PATH ignored.
|
||||
type BackupCommand struct {
|
||||
ExportPath string `short:"p" long:"path" env:"BACKUP_PATH" default:"./var/backup" description:"export path"`
|
||||
ExportFile string `short:"f" long:"file" default:"userbackup-{{.SITE}}-{{.TS}}.gz" description:"file name"`
|
||||
Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"`
|
||||
Timeout time.Duration `long:"timeout" default:"15m" description:"export (backup) timeout"`
|
||||
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" required:"true" description:"admin basic auth password"`
|
||||
ExportPath string `short:"p" long:"path" env:"BACKUP_PATH" default:"./var/backup" description:"export path"`
|
||||
ExportFile string `short:"f" long:"file" default:"userbackup-{{.SITE}}-{{.TS}}.gz" description:"file name"`
|
||||
|
||||
SupportCmdOpts
|
||||
CommonOpts
|
||||
}
|
||||
|
||||
// Execute runs export with ExportCommand parameters, entry point for "export" command
|
||||
func (ec *BackupCommand) Execute(args []string) error {
|
||||
func (ec *BackupCommand) Execute(_ []string) error {
|
||||
log.Printf("[INFO] export to %s, site %s", ec.ExportPath, ec.Site)
|
||||
resetEnv("SECRET", "ADMIN_PASSWD")
|
||||
|
||||
@@ -38,19 +36,20 @@ func (ec *BackupCommand) Execute(args []string) error {
|
||||
|
||||
// prepare http client and request
|
||||
client := http.Client{}
|
||||
defer client.CloseIdleConnections()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), ec.Timeout)
|
||||
defer cancel()
|
||||
exportURL := fmt.Sprintf("%s/api/v1/admin/export?mode=file&site=%s", ec.RemarkURL, ec.Site)
|
||||
req, err := http.NewRequest(http.MethodGet, exportURL, nil)
|
||||
req, err := http.NewRequest(http.MethodGet, exportURL, http.NoBody)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "can't make export request for %s", exportURL)
|
||||
return fmt.Errorf("can't make export request for %s: %w", exportURL, err)
|
||||
}
|
||||
req.SetBasicAuth("admin", ec.AdminPasswd)
|
||||
|
||||
// get with timeout
|
||||
resp, err := client.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "request failed for %s", exportURL)
|
||||
return fmt.Errorf("request failed for %s: %w", exportURL, err)
|
||||
}
|
||||
defer func() {
|
||||
if err = resp.Body.Close(); err != nil {
|
||||
@@ -62,18 +61,18 @@ func (ec *BackupCommand) Execute(args []string) error {
|
||||
return responseError(resp)
|
||||
}
|
||||
|
||||
fh, err := os.Create(fname)
|
||||
fh, err := os.Create(fname) //nolint:gosec // harmless
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "can't create backup file %s", fname)
|
||||
return fmt.Errorf("can't create backup file %s: %w", fname, err)
|
||||
}
|
||||
defer func() {
|
||||
defer func() { //nolint:gosec // false positive on defer without error check when it's checked here
|
||||
if err = fh.Close(); err != nil {
|
||||
log.Printf("[WARN] failed to close file %s, %s", fh.Name(), err)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err = io.Copy(fh, resp.Body); err != nil {
|
||||
return errors.Wrapf(err, "failed to write backup file %s", fname)
|
||||
return fmt.Errorf("failed to write backup file %s: %w", fname, err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] export completed, file %s", fname)
|
||||
|
||||
@@ -2,13 +2,12 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
flags "github.com/jessevdk/go-flags"
|
||||
"github.com/jessevdk/go-flags"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -30,7 +29,7 @@ func TestBackup_Execute(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
defer os.Remove("/tmp/remark-test.export")
|
||||
|
||||
data, err := ioutil.ReadFile("/tmp/remark-test.export")
|
||||
data, err := os.ReadFile("/tmp/remark-test.export")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "blah\nblah2\n12345678\n", string(data))
|
||||
}
|
||||
|
||||
+36
-38
@@ -9,21 +9,20 @@ import (
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
)
|
||||
|
||||
// CleanupCommand set of flags and command for cleanup
|
||||
type CleanupCommand struct {
|
||||
Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"`
|
||||
Dry bool `long:"dry" description:"dry mode, will not remove comments"`
|
||||
From string `long:"from" description:"from yyyymmdd"`
|
||||
To string `long:"to" description:"from yyyymmdd"`
|
||||
BadWords []string `short:"w" long:"bword" description:"bad word(s)"`
|
||||
BadUsers []string `short:"u" long:"buser" description:"bad user(s)"`
|
||||
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" required:"true" description:"admin basic auth password"`
|
||||
SetTitle bool `long:"title" description:"title mode, will not remove comments, but reset titles to page's title'"`
|
||||
Dry bool `long:"dry" description:"dry mode, will not remove comments"`
|
||||
From string `long:"from" description:"from yyyymmdd"`
|
||||
To string `long:"to" description:"from yyyymmdd"`
|
||||
BadWords []string `short:"w" long:"bword" description:"bad word(s)"`
|
||||
BadUsers []string `short:"u" long:"buser" description:"bad user(s)"`
|
||||
SetTitle bool `long:"title" description:"title mode, will not remove comments, but reset titles to page's title'"`
|
||||
|
||||
SupportCmdOpts
|
||||
CommonOpts
|
||||
}
|
||||
|
||||
@@ -34,12 +33,12 @@ var (
|
||||
|
||||
// Execute runs cleanup with CleanupCommand parameters, entry point for "cleanup" command
|
||||
// This command uses provided flags to detect and remove junk comments
|
||||
func (cc *CleanupCommand) Execute(args []string) error {
|
||||
func (cc *CleanupCommand) Execute(_ []string) error {
|
||||
log.Printf("[INFO] cleanup for site %s", cc.Site)
|
||||
|
||||
posts, err := cc.postsInRange(cc.From, cc.To)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "can't get posts")
|
||||
return fmt.Errorf("can't get posts: %w", err)
|
||||
}
|
||||
log.Printf("[DEBUG] got %d posts", len(posts))
|
||||
|
||||
@@ -55,7 +54,6 @@ func (cc *CleanupCommand) Execute(args []string) error {
|
||||
cc.procTitles(comments)
|
||||
} else {
|
||||
spamComments += cc.procSpam(comments)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +98,7 @@ func (cc *CleanupCommand) procTitles(comments []store.Comment) {
|
||||
func (cc *CleanupCommand) postsInRange(fromS, toS string) ([]store.PostInfo, error) {
|
||||
posts, err := cc.listPosts()
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "can't list posts for %s", cc.Site)
|
||||
return nil, fmt.Errorf("can't list posts for %s: %w", cc.Site, err)
|
||||
}
|
||||
|
||||
from, to := defaultFrom, defaultTo
|
||||
@@ -108,14 +106,14 @@ func (cc *CleanupCommand) postsInRange(fromS, toS string) ([]store.PostInfo, err
|
||||
if fromS != "" {
|
||||
from, err = time.ParseInLocation("20060102", fromS, time.Local)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "can't parse --from")
|
||||
return nil, fmt.Errorf("can't parse --from: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if toS != "" {
|
||||
to, err = time.ParseInLocation("20060102", toS, time.Local)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "can't parse --to")
|
||||
return nil, fmt.Errorf("can't parse --to: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,37 +130,38 @@ func (cc *CleanupCommand) postsInRange(fromS, toS string) ([]store.PostInfo, err
|
||||
func (cc *CleanupCommand) listPosts() ([]store.PostInfo, error) {
|
||||
listURL := fmt.Sprintf("%s/api/v1/list?site=%s&limit=10000", cc.RemarkURL, cc.Site)
|
||||
client := http.Client{Timeout: 30 * time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
r, err := client.Get(listURL)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "get request failed for list of posts, site %s", cc.Site)
|
||||
return nil, fmt.Errorf("get request failed for list of posts, site %s: %w", cc.Site, err)
|
||||
}
|
||||
defer func() { _ = r.Body.Close() }()
|
||||
|
||||
if r.StatusCode != 200 {
|
||||
return nil, errors.Errorf("request %s failed with status %d", listURL, r.StatusCode)
|
||||
return nil, fmt.Errorf("request %s failed with status %d", listURL, r.StatusCode)
|
||||
}
|
||||
|
||||
list := []store.PostInfo{}
|
||||
if err = json.NewDecoder(r.Body).Decode(&list); err != nil {
|
||||
return nil, errors.Wrapf(err, "can't decode list of posts for site %s", cc.Site)
|
||||
return nil, fmt.Errorf("can't decode list of posts for site %s: %w", cc.Site, err)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// get all comments for post url via /find?site=siteID&url=post-url&format=[tree|plain]
|
||||
func (cc *CleanupCommand) listComments(postURL string) ([]store.Comment, error) {
|
||||
|
||||
commentsURL := fmt.Sprintf("%s/api/v1/find?site=%s&url=%s&format=plain", cc.RemarkURL, cc.Site, postURL)
|
||||
|
||||
var r *http.Response
|
||||
var err error
|
||||
|
||||
// handle 429 error from limiter
|
||||
client := http.Client{Timeout: 30 * time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
for {
|
||||
client := http.Client{Timeout: 30 * time.Second}
|
||||
r, err = client.Get(commentsURL)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "get request failed for comments, %s", postURL)
|
||||
return nil, fmt.Errorf("get request failed for comments, %s: %w", postURL, err)
|
||||
}
|
||||
if r.StatusCode == http.StatusTooManyRequests {
|
||||
_ = r.Body.Close()
|
||||
@@ -175,7 +174,7 @@ func (cc *CleanupCommand) listComments(postURL string) ([]store.Comment, error)
|
||||
defer func() { _ = r.Body.Close() }()
|
||||
|
||||
if r.StatusCode != http.StatusOK {
|
||||
return nil, errors.Errorf("request %s failed with status %d", commentsURL, r.StatusCode)
|
||||
return nil, fmt.Errorf("request %s failed with status %d", commentsURL, r.StatusCode)
|
||||
}
|
||||
|
||||
commentsWithInfo := struct {
|
||||
@@ -184,58 +183,57 @@ func (cc *CleanupCommand) listComments(postURL string) ([]store.Comment, error)
|
||||
}{}
|
||||
|
||||
if err = json.NewDecoder(r.Body).Decode(&commentsWithInfo); err != nil {
|
||||
return nil, errors.Wrapf(err, "can't decode list of comments for %s", postURL)
|
||||
return nil, fmt.Errorf("can't decode list of comments for %s: %w", postURL, err)
|
||||
}
|
||||
return commentsWithInfo.Comments, nil
|
||||
}
|
||||
|
||||
// deleteComment with DELETE /admin/comment/{id}?site=siteID&url=post-url
|
||||
func (cc *CleanupCommand) deleteComment(c store.Comment) error {
|
||||
|
||||
func (cc *CleanupCommand) deleteComment(c store.Comment) error { //nolint:dupl // not worth combining
|
||||
deleteURL := fmt.Sprintf("%s/api/v1/admin/comment/%s?site=%s&url=%s&format=plain", cc.RemarkURL, c.ID, cc.Site, c.Locator.URL)
|
||||
req, err := http.NewRequest("DELETE", deleteURL, nil)
|
||||
req, err := http.NewRequest("DELETE", deleteURL, http.NoBody)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to make delete request for comment %s, %s", c.ID, c.Locator.URL)
|
||||
return fmt.Errorf("failed to make delete request for comment %s, %s: %w", c.ID, c.Locator.URL, err)
|
||||
}
|
||||
req.SetBasicAuth("admin", cc.AdminPasswd)
|
||||
|
||||
client := http.Client{}
|
||||
defer client.CloseIdleConnections()
|
||||
r, err := client.Do(req)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "delete request failed for comment %s, %s", c.ID, c.Locator.URL)
|
||||
return fmt.Errorf("delete request failed for comment %s, %s: %w", c.ID, c.Locator.URL, err)
|
||||
}
|
||||
defer func() { _ = r.Body.Close() }()
|
||||
if r.StatusCode != http.StatusOK {
|
||||
return errors.Errorf("delete request failed with status %s", r.Status)
|
||||
return fmt.Errorf("delete request failed with status %s", r.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setTitle with PUT /admin/title/{id}?site=siteID&url=post-url
|
||||
func (cc *CleanupCommand) setTitle(c store.Comment) error {
|
||||
|
||||
func (cc *CleanupCommand) setTitle(c store.Comment) error { //nolint:dupl // not worth combining
|
||||
titleURL := fmt.Sprintf("%s/api/v1/admin/title/%s?site=%s&url=%s&format=plain", cc.RemarkURL, c.ID, cc.Site, c.Locator.URL)
|
||||
req, err := http.NewRequest("PUT", titleURL, nil)
|
||||
req, err := http.NewRequest("PUT", titleURL, http.NoBody)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to make title request for comment %s, %s", c.ID, c.Locator.URL)
|
||||
return fmt.Errorf("failed to make title request for comment %s, %s: %w", c.ID, c.Locator.URL, err)
|
||||
}
|
||||
req.SetBasicAuth("admin", cc.AdminPasswd)
|
||||
|
||||
client := http.Client{}
|
||||
defer client.CloseIdleConnections()
|
||||
r, err := client.Do(req)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "title request failed for comment %s, %s", c.ID, c.Locator.URL)
|
||||
return fmt.Errorf("title request failed for comment %s, %s: %w", c.ID, c.Locator.URL, err)
|
||||
}
|
||||
defer func() { _ = r.Body.Close() }()
|
||||
if r.StatusCode != http.StatusOK {
|
||||
return errors.Errorf("title request failed with status %s", r.Status)
|
||||
return fmt.Errorf("title request failed with status %s", r.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isSpam calculates spam's probability as a score
|
||||
func (cc *CleanupCommand) isSpam(comment store.Comment) (bool, float64) {
|
||||
|
||||
func (cc *CleanupCommand) isSpam(comment store.Comment) (isSpam bool, spamScore float64) {
|
||||
badWord := func(txt string) float64 {
|
||||
res := 0.0
|
||||
for _, w := range cc.BadWords {
|
||||
|
||||
@@ -9,12 +9,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
flags "github.com/jessevdk/go-flags"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jessevdk/go-flags"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
)
|
||||
|
||||
type cleanedComments struct {
|
||||
@@ -46,6 +46,7 @@ func TestCleanup_IsSpam(t *testing.T) {
|
||||
}
|
||||
|
||||
for n, tt := range tbl {
|
||||
tt := tt
|
||||
checkName := fmt.Sprintf("check-%d-%s", n, tt.name)
|
||||
t.Run(checkName, func(t *testing.T) {
|
||||
c := store.Comment{ID: checkName, Text: tt.text, Score: tt.score}
|
||||
@@ -58,7 +59,6 @@ func TestCleanup_IsSpam(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCleanup_postsInRange(t *testing.T) {
|
||||
|
||||
r := chi.NewRouter()
|
||||
cleanupRoutes(t, r, nil)
|
||||
ts := httptest.NewServer(r)
|
||||
@@ -144,7 +144,7 @@ func TestCleanup_ExecuteTitle(t *testing.T) {
|
||||
}
|
||||
|
||||
func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) {
|
||||
r.HandleFunc("/api/v1/list", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.HandleFunc("/api/v1/list", func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "GET", r.Method)
|
||||
require.Equal(t, "site=remark&limit=10000", r.URL.RawQuery)
|
||||
list := []store.PostInfo{
|
||||
@@ -165,9 +165,9 @@ func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) {
|
||||
},
|
||||
}
|
||||
require.NoError(t, json.NewEncoder(w).Encode(list))
|
||||
}))
|
||||
})
|
||||
|
||||
r.HandleFunc("/api/v1/find", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.HandleFunc("/api/v1/find", func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "GET", r.Method)
|
||||
require.Equal(t, "remark", r.URL.Query().Get("site"))
|
||||
require.Equal(t, "plain", r.URL.Query().Get("format"))
|
||||
@@ -193,22 +193,21 @@ func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) {
|
||||
}
|
||||
|
||||
require.NoError(t, json.NewEncoder(w).Encode(commentsWithInfo))
|
||||
}))
|
||||
})
|
||||
|
||||
r.HandleFunc("/api/v1/admin/comment/{id}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.HandleFunc("/api/v1/admin/comment/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "DELETE", r.Method)
|
||||
t.Log("delete ", r.URL.Path)
|
||||
c.lock.Lock()
|
||||
c.ids = append(c.ids, r.URL.Path)
|
||||
c.lock.Unlock()
|
||||
}))
|
||||
})
|
||||
|
||||
r.HandleFunc("/api/v1/admin/title/{id}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.HandleFunc("/api/v1/admin/title/{id}", func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "PUT", r.Method)
|
||||
t.Log("title for ", r.URL.Path)
|
||||
c.lock.Lock()
|
||||
c.ids = append(c.ids, r.URL.Path)
|
||||
c.lock.Unlock()
|
||||
}))
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
+19
-11
@@ -4,7 +4,8 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -13,7 +14,6 @@ import (
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// CommonOptionsCommander extends flags.Commander with SetCommon
|
||||
@@ -31,11 +31,20 @@ type CommonOpts struct {
|
||||
Revision string
|
||||
}
|
||||
|
||||
// SupportCmdOpts is set of commands shared among similar commands like backup/restore and such.
|
||||
// Order of fields defines the help command output order.
|
||||
type SupportCmdOpts struct {
|
||||
Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"`
|
||||
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" default:"" description:"admin basic auth password"`
|
||||
Timeout time.Duration `long:"timeout" default:"60m" description:"timeout for the command run"`
|
||||
}
|
||||
|
||||
// DeprecatedFlag contains information about deprecated option
|
||||
type DeprecatedFlag struct {
|
||||
Old string
|
||||
New string
|
||||
RemoveVersion string
|
||||
Old string
|
||||
New string
|
||||
Version string
|
||||
Collision bool
|
||||
}
|
||||
|
||||
// SetCommon satisfies CommonOptionsCommander interface and sets common option fields
|
||||
@@ -58,7 +67,6 @@ type fileParser struct {
|
||||
|
||||
// parse apply template and also concat path and file. In case if file contains path separator path will be ignored
|
||||
func (p *fileParser) parse(now time.Time) (string, error) {
|
||||
|
||||
// file/location parameters my have template masks
|
||||
fileTemplate := struct {
|
||||
YYYYMMDD string
|
||||
@@ -87,7 +95,7 @@ func (p *fileParser) parse(now time.Time) (string, error) {
|
||||
}
|
||||
|
||||
if err := template.Must(template.New("bb").Parse(fname)).Execute(&bb, fileTemplate); err != nil {
|
||||
return "", errors.Wrapf(err, "failed to parse %q", fname)
|
||||
return "", fmt.Errorf("failed to parse %q: %w", fname, err)
|
||||
}
|
||||
return bb.String(), nil
|
||||
}
|
||||
@@ -103,18 +111,18 @@ func resetEnv(envs ...string) {
|
||||
|
||||
// responseError returns error with status and response body
|
||||
func responseError(resp *http.Response) error {
|
||||
body, e := ioutil.ReadAll(resp.Body)
|
||||
body, e := io.ReadAll(resp.Body)
|
||||
if e != nil {
|
||||
body = []byte("")
|
||||
}
|
||||
return errors.Errorf("error response %q, %s", resp.Status, body)
|
||||
return fmt.Errorf("error response %q, %s", resp.Status, body)
|
||||
}
|
||||
|
||||
// mkdir -p for all dirs
|
||||
func makeDirs(dirs ...string) error {
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil { // If path is already a directory, MkdirAll does nothing
|
||||
return errors.Wrapf(err, "can't make directory %s", dir)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil { // If path is already a directory, MkdirAll does nothing
|
||||
return fmt.Errorf("can't make directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
+14
-17
@@ -5,49 +5,46 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// ImportCommand set of flags and command for import
|
||||
type ImportCommand struct {
|
||||
InputFile string `short:"f" long:"file" description:"input file name" required:"true"`
|
||||
Provider string `short:"p" long:"provider" default:"disqus" choice:"disqus" choice:"wordpress" description:"import format"` //nolint
|
||||
Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"`
|
||||
Timeout time.Duration `long:"timeout" default:"15m" description:"import timeout"`
|
||||
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" required:"true" description:"admin basic auth password"`
|
||||
InputFile string `short:"f" long:"file" description:"input file name" required:"true"`
|
||||
Provider string `short:"p" long:"provider" default:"disqus" choice:"disqus" choice:"wordpress" choice:"commento" description:"import format"` //nolint
|
||||
|
||||
SupportCmdOpts
|
||||
CommonOpts
|
||||
}
|
||||
|
||||
// Execute runs import with ImportCommand parameters, entry point for "import" command
|
||||
func (ic *ImportCommand) Execute(args []string) error {
|
||||
func (ic *ImportCommand) Execute(_ []string) error {
|
||||
log.Printf("[INFO] import %s (%s), site %s", ic.InputFile, ic.Provider, ic.Site)
|
||||
resetEnv("SECRET", "ADMIN_PASSWD")
|
||||
|
||||
reader, err := ic.reader(ic.InputFile)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "can't open import file %s", ic.InputFile)
|
||||
return fmt.Errorf("can't open import file %s: %w", ic.InputFile, err)
|
||||
}
|
||||
|
||||
client := http.Client{}
|
||||
defer client.CloseIdleConnections()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), ic.Timeout)
|
||||
defer cancel()
|
||||
importURL := fmt.Sprintf("%s/api/v1/admin/import?site=%s&provider=%s", ic.RemarkURL, ic.Site, ic.Provider)
|
||||
req, err := http.NewRequest(http.MethodPost, importURL, reader)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "can't make import request for %s", importURL)
|
||||
return fmt.Errorf("can't make import request for %s: %w", importURL, err)
|
||||
}
|
||||
req.SetBasicAuth("admin", ic.AdminPasswd)
|
||||
|
||||
resp, err := client.Do(req.WithContext(ctx)) // closes request's reader
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "request failed for %s", importURL)
|
||||
return fmt.Errorf("request failed for %s: %w", importURL, err)
|
||||
}
|
||||
defer func() {
|
||||
if err = resp.Body.Close(); err != nil {
|
||||
@@ -58,9 +55,9 @@ func (ic *ImportCommand) Execute(args []string) error {
|
||||
return responseError(resp)
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "can't get response from importer")
|
||||
return fmt.Errorf("can't get response from importer: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] completed, status=%d, %s", resp.StatusCode, string(body))
|
||||
@@ -69,15 +66,15 @@ func (ic *ImportCommand) Execute(args []string) error {
|
||||
|
||||
// reader returns reader for file. For .gz file wraps with gunzip
|
||||
func (ic *ImportCommand) reader(inp string) (reader io.Reader, err error) {
|
||||
inpFile, err := os.Open(inp)
|
||||
inpFile, err := os.Open(inp) // nolint
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "import failed, can't open %s", inp)
|
||||
return nil, fmt.Errorf("import failed, can't open %s: %w", inp, err)
|
||||
}
|
||||
|
||||
reader = inpFile
|
||||
if strings.HasSuffix(ic.InputFile, ".gz") {
|
||||
if reader, err = gzip.NewReader(inpFile); err != nil {
|
||||
return nil, errors.Wrap(err, "can't make gz reader")
|
||||
return nil, fmt.Errorf("can't make gz reader: %w", err)
|
||||
}
|
||||
}
|
||||
return reader, nil
|
||||
|
||||
@@ -2,24 +2,23 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
flags "github.com/jessevdk/go-flags"
|
||||
"github.com/jessevdk/go-flags"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestImport_Execute(t *testing.T) {
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
body, err := ioutil.ReadAll(r.Body)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
|
||||
|
||||
@@ -48,7 +47,6 @@ func TestImport_Execute(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestImport_ExecuteFailed(t *testing.T) {
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
@@ -96,13 +94,12 @@ func TestImport_ExecuteTimeout(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
body, err := ioutil.ReadAll(r.Body)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
fmt.Fprintln(w, "some response")
|
||||
fmt.Fprintln(w, string(body))
|
||||
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
|
||||
+11
-13
@@ -3,48 +3,46 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// RemapCommand set of flags and command for change linkage between comments to
|
||||
// different urls based on given rules (input file)
|
||||
type RemapCommand struct {
|
||||
Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"`
|
||||
InputFile string `short:"f" long:"file" description:"input file name" required:"true"`
|
||||
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" required:"true" description:"admin basic auth password"`
|
||||
Timeout time.Duration `long:"timeout" default:"15m" description:"remap timeout"`
|
||||
InputFile string `short:"f" long:"file" description:"input file name" required:"true"`
|
||||
|
||||
SupportCmdOpts
|
||||
CommonOpts
|
||||
}
|
||||
|
||||
// Execute runs (re)mapper with RemapCommand parameters, entry point for "remap" command
|
||||
func (rc *RemapCommand) Execute(args []string) error {
|
||||
func (rc *RemapCommand) Execute(_ []string) error {
|
||||
log.Printf("[INFO] start remap, site %s, file with rules %s", rc.Site, rc.InputFile)
|
||||
resetEnv("SECRET", "ADMIN_PASSWD")
|
||||
|
||||
rulesReader, err := os.Open(rc.InputFile)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "cant open file %s", rc.InputFile)
|
||||
return fmt.Errorf("cant open file %s: %w", rc.InputFile, err)
|
||||
}
|
||||
|
||||
client := http.Client{}
|
||||
defer client.CloseIdleConnections()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), rc.Timeout)
|
||||
defer cancel()
|
||||
remapURL := fmt.Sprintf("%s/api/v1/admin/remap?site=%s", rc.RemarkURL, rc.Site)
|
||||
req, err := http.NewRequest(http.MethodPost, remapURL, rulesReader)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "can't make remap request for %s", remapURL)
|
||||
return fmt.Errorf("can't make remap request for %s: %w", remapURL, err)
|
||||
}
|
||||
req.SetBasicAuth("admin", rc.AdminPasswd)
|
||||
|
||||
resp, err := client.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "request failed for %s", remapURL)
|
||||
return fmt.Errorf("request failed for %s: %w", remapURL, err)
|
||||
}
|
||||
defer func() {
|
||||
if err = resp.Body.Close(); err != nil {
|
||||
@@ -55,9 +53,9 @@ func (rc *RemapCommand) Execute(args []string) error {
|
||||
return responseError(resp)
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "can't get response")
|
||||
return fmt.Errorf("can't get response: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] completed, status=%d, %s", resp.StatusCode, string(body))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -12,12 +12,11 @@ import (
|
||||
)
|
||||
|
||||
func TestRemap_Execute(t *testing.T) {
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, r.URL.Path, "/api/v1/admin/remap")
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "remark", r.URL.Query().Get("site"))
|
||||
body, err := ioutil.ReadAll(r.Body)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "http://oldsite.com* https://newsite.com*\nhttp://oldsite.com/from-old-page/1 https://newsite.com/to-new-page/1", string(body))
|
||||
|
||||
|
||||
@@ -11,9 +11,7 @@ type RestoreCommand struct {
|
||||
ImportPath string `short:"p" long:"path" env:"BACKUP_PATH" default:"./var/backup" description:"export path"`
|
||||
ImportFile string `short:"f" long:"file" default:"userbackup-{{.SITE}}-{{.YYYYMMDD}}.gz" description:"file name" required:"true"`
|
||||
|
||||
Site string `short:"s" long:"site" env:"SITE" default:"remark" description:"site name"`
|
||||
Timeout time.Duration `long:"timeout" default:"15m" description:"import timeout"`
|
||||
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" required:"true" description:"admin basic auth password"`
|
||||
SupportCmdOpts
|
||||
CommonOpts
|
||||
}
|
||||
|
||||
@@ -29,12 +27,10 @@ func (rc *RestoreCommand) Execute(args []string) error {
|
||||
return err
|
||||
}
|
||||
importer := ImportCommand{
|
||||
InputFile: fname,
|
||||
Site: rc.Site,
|
||||
Provider: "native",
|
||||
Timeout: rc.Timeout,
|
||||
AdminPasswd: rc.AdminPasswd,
|
||||
CommonOpts: rc.CommonOpts,
|
||||
InputFile: fname,
|
||||
Provider: "native",
|
||||
SupportCmdOpts: rc.SupportCmdOpts,
|
||||
CommonOpts: rc.CommonOpts,
|
||||
}
|
||||
return importer.Execute(args)
|
||||
}
|
||||
|
||||
@@ -2,23 +2,22 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
flags "github.com/jessevdk/go-flags"
|
||||
"github.com/jessevdk/go-flags"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRestore_Execute(t *testing.T) {
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "native", r.URL.Query().Get("provider"))
|
||||
body, err := ioutil.ReadAll(r.Body)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
|
||||
|
||||
|
||||
+700
-311
File diff suppressed because it is too large
Load Diff
+327
-72
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -15,9 +15,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/go-pkgz/auth/token"
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/jessevdk/go-flags"
|
||||
"go.uber.org/goleak"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -35,15 +36,17 @@ func TestServerApp(t *testing.T) {
|
||||
|
||||
// send ping
|
||||
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
||||
defer http.DefaultClient.CloseIdleConnections()
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pong", string(body))
|
||||
|
||||
// add comment
|
||||
client := http.Client{Timeout: 10 * time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
||||
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`))
|
||||
require.NoError(t, err)
|
||||
@@ -52,7 +55,7 @@ func TestServerApp(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
body, _ = ioutil.ReadAll(resp.Body)
|
||||
body, _ = io.ReadAll(resp.Body)
|
||||
t.Log(string(body))
|
||||
|
||||
email, err := app.dataService.AdminStore.Email("")
|
||||
@@ -75,15 +78,17 @@ func TestServerApp_DevMode(t *testing.T) {
|
||||
go func() { _ = app.run(ctx) }()
|
||||
waitForHTTPServerStart(port)
|
||||
|
||||
require.Equal(t, 5+1, len(app.restSrv.Authenticator.Providers()), "extra auth provider")
|
||||
assert.Equal(t, "dev", app.restSrv.Authenticator.Providers()[4].Name(), "dev auth provider")
|
||||
providers := app.restSrv.Authenticator.Providers()
|
||||
require.Equal(t, 10+1, len(providers), "extra auth provider")
|
||||
assert.Equal(t, "dev", providers[len(providers)-2].Name(), "dev auth provider")
|
||||
// send ping
|
||||
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
||||
defer http.DefaultClient.CloseIdleConnections()
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, "pong", string(body))
|
||||
|
||||
cancel()
|
||||
@@ -101,40 +106,123 @@ func TestServerApp_AnonMode(t *testing.T) {
|
||||
go func() { _ = app.run(ctx) }()
|
||||
waitForHTTPServerStart(port)
|
||||
|
||||
require.Equal(t, 5+1, len(app.restSrv.Authenticator.Providers()), "extra auth provider for anon")
|
||||
assert.Equal(t, "anonymous", app.restSrv.Authenticator.Providers()[5].Name(), "anon auth provider")
|
||||
providers := app.restSrv.Authenticator.Providers()
|
||||
require.Equal(t, 10+1, len(providers), "extra auth provider for anon")
|
||||
assert.Equal(t, "anonymous", providers[len(providers)-1].Name(), "anon auth provider")
|
||||
|
||||
client := http.Client{Timeout: 10 * time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
// send ping
|
||||
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
||||
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pong", string(body))
|
||||
|
||||
// try to login with good name
|
||||
resp, err = http.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=blah123&aud=remark42", port))
|
||||
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=blah123&aud=remark", port))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// try to add a comment as good anonymous
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
||||
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
tkn, claims := getAuthFromCookie(t, app, resp)
|
||||
require.NotEmpty(t, tkn)
|
||||
assert.False(t, claims.User.BoolAttr("blocked"), "should not be blocked")
|
||||
req.Header.Add("X-JWT", tkn)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
// try to login with non-latin name
|
||||
time.Sleep(time.Second)
|
||||
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=Раз_Два%20%20Три_34567&aud=remark", port))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// try to login with bad name
|
||||
resp, err = http.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=**blah123&aud=remark42", port))
|
||||
time.Sleep(time.Second)
|
||||
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=**blah123&aud=remark", port))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// try to login with short name
|
||||
resp, err = http.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=bl%20%20&aud=remark42", port))
|
||||
time.Sleep(time.Second)
|
||||
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=bl%%20%%20&aud=remark", port))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// try to login with name what have space in prefix
|
||||
time.Sleep(time.Second)
|
||||
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=%%20somebody&aud=remark", port))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// try to login with name what have space in suffix
|
||||
time.Sleep(time.Second)
|
||||
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=somebody%%20&aud=remark", port))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// try to login with long name
|
||||
time.Sleep(time.Second)
|
||||
ln := strings.Repeat("x", 65)
|
||||
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=%s&aud=remark", port, ln))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// try to login with admin name
|
||||
time.Sleep(time.Second)
|
||||
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=umpUtun&aud=remark", port))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// try to add a comment as anonymous with admin name
|
||||
time.Sleep(time.Second)
|
||||
req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
||||
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
tkn, claims = getAuthFromCookie(t, app, resp)
|
||||
require.NotEmpty(t, tkn)
|
||||
assert.True(t, claims.User.BoolAttr("blocked"), "should be blocked")
|
||||
req.Header.Add("X-JWT", tkn)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
|
||||
cancel()
|
||||
app.Wait()
|
||||
}
|
||||
|
||||
func getAuthFromCookie(t *testing.T, app *serverApp, resp *http.Response) (tkn string, claims token.Claims) {
|
||||
var err error
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == "JWT" {
|
||||
tkn = c.Value
|
||||
claims, err = app.restSrv.Authenticator.TokenService().Parse(c.Value)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
return tkn, claims
|
||||
}
|
||||
|
||||
func TestServerApp_WithSSL(t *testing.T) {
|
||||
opts := ServerCommand{}
|
||||
sslPort := chooseRandomUnusedPort()
|
||||
@@ -144,13 +232,16 @@ func TestServerApp_WithSSL(t *testing.T) {
|
||||
p := flags.NewParser(&opts, flags.Default)
|
||||
port := chooseRandomUnusedPort()
|
||||
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--port=" + strconv.Itoa(port), "--store.bolt.path=/tmp/xyz", "--backup=/tmp",
|
||||
"--avatar.type=bolt", "--avatar.bolt.file=/tmp/ava-test.db", "--notify.type=none",
|
||||
"--avatar.type=bolt", "--avatar.bolt.file=/tmp/ava-test.db",
|
||||
"--ssl.type=static", "--ssl.cert=testdata/cert.pem", "--ssl.key=testdata/key.pem",
|
||||
"--ssl.port=" + strconv.Itoa(sslPort), "--image.fs.path=/tmp"})
|
||||
require.NoError(t, err)
|
||||
defer os.Remove("/tmp/xyz")
|
||||
defer os.Remove("/tmp/xyz/remark.db")
|
||||
defer os.Remove("/tmp/ava-test.db")
|
||||
|
||||
// create app
|
||||
app, err := opts.newServerApp()
|
||||
app, err := opts.newServerApp(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -168,20 +259,21 @@ func TestServerApp_WithSSL(t *testing.T) {
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
}
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
// check http to https redirect response
|
||||
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/blah?param=1", port))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, 307, resp.StatusCode)
|
||||
assert.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
assert.Equal(t, fmt.Sprintf("https://localhost:%d/blah?param=1", sslPort), resp.Header.Get("Location"))
|
||||
|
||||
// check https server
|
||||
resp, err = client.Get(fmt.Sprintf("https://localhost:%d/ping", sslPort))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pong", string(body))
|
||||
|
||||
@@ -190,7 +282,6 @@ func TestServerApp_WithSSL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServerApp_WithRemote(t *testing.T) {
|
||||
|
||||
opts := ServerCommand{}
|
||||
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
||||
|
||||
@@ -199,13 +290,14 @@ func TestServerApp_WithRemote(t *testing.T) {
|
||||
port := chooseRandomUnusedPort()
|
||||
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--cache.type=none",
|
||||
"--store.type=rpc", "--store.rpc.api=http://127.0.0.1",
|
||||
"--port=" + strconv.Itoa(port), "--admin.type=rpc", "--admin.rpc.api=http://127.0.0.1", "--avatar.fs.path=/tmp"})
|
||||
"--port=" + strconv.Itoa(port), "--avatar.fs.path=/tmp",
|
||||
"--admin.type=rpc", "--admin.rpc.secret_per_site", "--admin.rpc.api=http://127.0.0.1"})
|
||||
require.NoError(t, err)
|
||||
opts.Auth.Github.CSEC, opts.Auth.Github.CID = "csec", "cid"
|
||||
opts.BackupLocation, opts.Image.FS.Path = "/tmp", "/tmp"
|
||||
|
||||
// create app
|
||||
app, err := opts.newServerApp()
|
||||
app, err := opts.newServerApp(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -214,10 +306,11 @@ func TestServerApp_WithRemote(t *testing.T) {
|
||||
|
||||
// send ping
|
||||
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
||||
defer http.DefaultClient.CloseIdleConnections()
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pong", string(body))
|
||||
|
||||
@@ -234,7 +327,7 @@ func TestServerApp_Failed(t *testing.T) {
|
||||
// RO bolt location
|
||||
_, err := p.ParseArgs([]string{"--backup=/tmp", "--store.bolt.path=/dev/null", "--image.fs.path=/tmp"})
|
||||
assert.NoError(t, err)
|
||||
_, err = opts.newServerApp()
|
||||
_, err = opts.newServerApp(context.Background())
|
||||
assert.EqualError(t, err, "failed to make data store engine: failed to create bolt store: can't make directory /dev/null: mkdir /dev/null: not a directory")
|
||||
t.Log(err)
|
||||
|
||||
@@ -244,8 +337,9 @@ func TestServerApp_Failed(t *testing.T) {
|
||||
|
||||
_, err = p.ParseArgs([]string{"--store.bolt.path=/tmp", "--backup=/dev/null/not-writable"})
|
||||
assert.NoError(t, err)
|
||||
_, err = opts.newServerApp()
|
||||
assert.EqualError(t, err, "can't make directory /dev/null/not-writable: mkdir /dev/null: not a directory")
|
||||
defer os.Remove("/tmp/remark.db")
|
||||
_, err = opts.newServerApp(context.Background())
|
||||
assert.EqualError(t, err, "failed to create backup store: can't make directory /dev/null/not-writable: mkdir /dev/null: not a directory")
|
||||
t.Log(err)
|
||||
|
||||
// invalid url
|
||||
@@ -254,10 +348,11 @@ func TestServerApp_Failed(t *testing.T) {
|
||||
|
||||
_, err = p.ParseArgs([]string{"--backup=/tmp", "----store.bolt.path=/tmp"})
|
||||
assert.NoError(t, err)
|
||||
_, err = opts.newServerApp()
|
||||
_, err = opts.newServerApp(context.Background())
|
||||
assert.EqualError(t, err, "invalid remark42 url demo.remark42.com")
|
||||
t.Log(err)
|
||||
|
||||
// wrong store type
|
||||
opts = ServerCommand{}
|
||||
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
||||
|
||||
@@ -265,9 +360,35 @@ func TestServerApp_Failed(t *testing.T) {
|
||||
assert.Error(t, err, "blah is invalid type")
|
||||
|
||||
opts.Store.Type = "blah"
|
||||
_, err = opts.newServerApp()
|
||||
_, err = opts.newServerApp(context.Background())
|
||||
assert.EqualError(t, err, "failed to make data store engine: unsupported store type blah")
|
||||
t.Log(err)
|
||||
|
||||
// wrong redis location
|
||||
opts = ServerCommand{}
|
||||
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
||||
p = flags.NewParser(&opts, flags.Default)
|
||||
_, err = p.ParseArgs([]string{"--store.bolt.path=/tmp", "--cache.type=redis_pub_sub", "--cache.redis_addr=wrong_address"})
|
||||
assert.NoError(t, err)
|
||||
_, err = opts.newServerApp(context.Background())
|
||||
assert.EqualError(t, err,
|
||||
"failed to make cache: cache backend initialization, redis PubSub initialisation: "+
|
||||
"problem subscribing to channel remark42-cache on address wrong_address: "+
|
||||
"dial tcp: address wrong_address: missing port in address")
|
||||
t.Log(err)
|
||||
|
||||
// wrong apple private key type
|
||||
opts = ServerCommand{}
|
||||
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
||||
p = flags.NewParser(&opts, flags.Default)
|
||||
_, err = p.ParseArgs([]string{"--auth.apple.cid=123", "--auth.apple.tid=123",
|
||||
"--auth.apple.kid=123", "--auth.apple.private-key-filepath=testdata/apple-bad.p8"})
|
||||
assert.NoError(t, err)
|
||||
_, err = opts.newServerApp(context.Background())
|
||||
assert.EqualError(t, err,
|
||||
"failed to make authenticator: an AppleProvider creating failed: "+
|
||||
"provided private key is not ECDSA")
|
||||
t.Log(err)
|
||||
}
|
||||
|
||||
func TestServerApp_Shutdown(t *testing.T) {
|
||||
@@ -286,7 +407,6 @@ func TestServerApp_Shutdown(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestServerApp_MainSignal(t *testing.T) {
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
<-done
|
||||
@@ -301,7 +421,9 @@ func TestServerApp_MainSignal(t *testing.T) {
|
||||
p := flags.NewParser(&s, flags.Default)
|
||||
port := chooseRandomUnusedPort()
|
||||
args := []string{"test", "--store.bolt.path=/tmp/xyz", "--backup=/tmp", "--avatar.type=bolt",
|
||||
"--avatar.bolt.file=/tmp/ava-test.db", "--port=" + strconv.Itoa(port), "--notify.type=none", "--image.fs.path=/tmp"}
|
||||
"--avatar.bolt.file=/tmp/ava-test.db", "--port=" + strconv.Itoa(port), "--image.fs.path=/tmp"}
|
||||
defer os.Remove("/tmp/xyz")
|
||||
defer os.Remove("/tmp/xyz/remark.db")
|
||||
defer os.Remove("/tmp/ava-test.db")
|
||||
_, err := p.ParseArgs(args)
|
||||
require.NoError(t, err)
|
||||
@@ -319,12 +441,22 @@ func TestServerApp_DeprecatedArgs(t *testing.T) {
|
||||
p := flags.NewParser(&s, flags.Default)
|
||||
args := []string{
|
||||
"test",
|
||||
"--notify.type=email",
|
||||
"--notify.type=telegram",
|
||||
"--notify.users=none",
|
||||
"--notify.admins=none",
|
||||
"--img-proxy",
|
||||
"--notify.email.notify_admin",
|
||||
"--auth.email.host=smtp.example.org",
|
||||
"--auth.email.port=666",
|
||||
"--auth.email.tls",
|
||||
"--auth.email.user=test_user",
|
||||
"--auth.email.passwd=test_password",
|
||||
"--auth.email.timeout=15s",
|
||||
"--auth.email.template=file.tmpl",
|
||||
"--notify.telegram.token=abcd",
|
||||
"--notify.telegram.timeout=3m",
|
||||
"--notify.telegram.api=http://example.org",
|
||||
}
|
||||
assert.Empty(t, s.SMTP.Host)
|
||||
assert.Empty(t, s.SMTP.Port)
|
||||
@@ -337,12 +469,19 @@ func TestServerApp_DeprecatedArgs(t *testing.T) {
|
||||
deprecatedFlags := s.HandleDeprecatedFlags()
|
||||
assert.ElementsMatch(t,
|
||||
[]DeprecatedFlag{
|
||||
{Old: "auth.email.host", New: "smtp.host", RemoveVersion: "1.7.0"},
|
||||
{Old: "auth.email.port", New: "smtp.port", RemoveVersion: "1.7.0"},
|
||||
{Old: "auth.email.tls", New: "smtp.tls", RemoveVersion: "1.7.0"},
|
||||
{Old: "auth.email.user", New: "smtp.username", RemoveVersion: "1.7.0"},
|
||||
{Old: "auth.email.passwd", New: "smtp.password", RemoveVersion: "1.7.0"},
|
||||
{Old: "auth.email.timeout", New: "smtp.timeout", RemoveVersion: "1.7.0"},
|
||||
{Old: "auth.email.host", New: "smtp.host", Version: "1.5"},
|
||||
{Old: "auth.email.port", New: "smtp.port", Version: "1.5"},
|
||||
{Old: "auth.email.tls", New: "smtp.tls", Version: "1.5"},
|
||||
{Old: "auth.email.user", New: "smtp.username", Version: "1.5"},
|
||||
{Old: "auth.email.passwd", New: "smtp.password", Version: "1.5"},
|
||||
{Old: "auth.email.timeout", New: "smtp.timeout", Version: "1.5"},
|
||||
{Old: "auth.email.template", Version: "1.5"},
|
||||
{Old: "img-proxy", New: "image-proxy.http2https", Version: "1.5"},
|
||||
{Old: "notify.email.notify_admin", New: "notify.admins=email", Version: "1.9"},
|
||||
{Old: "notify.type", New: "notify.(users|admins)", Version: "1.9"},
|
||||
{Old: "notify.telegram.token", New: "telegram.token", Version: "1.9"},
|
||||
{Old: "notify.telegram.timeout", New: "telegram.timeout", Version: "1.9"},
|
||||
{Old: "notify.telegram.api", Version: "1.9"},
|
||||
},
|
||||
deprecatedFlags)
|
||||
assert.Equal(t, "smtp.example.org", s.SMTP.Host)
|
||||
@@ -353,6 +492,62 @@ func TestServerApp_DeprecatedArgs(t *testing.T) {
|
||||
assert.Equal(t, 15*time.Second, s.SMTP.TimeOut)
|
||||
}
|
||||
|
||||
func TestServerApp_DeprecatedArgsCollisions(t *testing.T) {
|
||||
s := ServerCommand{}
|
||||
s.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
||||
|
||||
p := flags.NewParser(&s, flags.Default)
|
||||
args := []string{
|
||||
"test",
|
||||
"--auth.email.host=smtp-old.example.org",
|
||||
"--smtp.host=smtp-new.example.org",
|
||||
"--auth.email.port=666",
|
||||
"--smtp.port=999",
|
||||
"--auth.email.user=test_user",
|
||||
"--smtp.username=new_test_user",
|
||||
"--auth.email.passwd=test_password",
|
||||
"--smtp.password=new_test_password",
|
||||
"--auth.email.timeout=15s",
|
||||
"--smtp.timeout=20s",
|
||||
"--notify.type=telegram",
|
||||
"--notify.users=telegram",
|
||||
"--notify.admins=none",
|
||||
"--notify.telegram.token=abcd",
|
||||
"--telegram.token=dcba",
|
||||
"--notify.telegram.timeout=3m",
|
||||
"--telegram.timeout=5m",
|
||||
}
|
||||
_, err := p.ParseArgs(args)
|
||||
require.NoError(t, err)
|
||||
deprecatedFlagsCollisions := s.findDeprecatedFlagsCollisions()
|
||||
assert.ElementsMatch(t,
|
||||
[]DeprecatedFlag{
|
||||
{Old: "notify.type", New: "notify.(users|admins)", Collision: true},
|
||||
{Old: "auth.email.host", New: "smtp.host", Collision: true},
|
||||
{Old: "auth.email.port", New: "smtp.port", Collision: true},
|
||||
{Old: "auth.email.user", New: "smtp.username", Collision: true},
|
||||
{Old: "auth.email.passwd", New: "smtp.password", Collision: true},
|
||||
{Old: "auth.email.timeout", New: "smtp.timeout", Collision: true},
|
||||
{Old: "notify.telegram.token", New: "telegram.token", Collision: true},
|
||||
{Old: "notify.telegram.timeout", New: "telegram.timeout", Collision: true},
|
||||
},
|
||||
deprecatedFlagsCollisions)
|
||||
|
||||
// case which should return nothing
|
||||
s = ServerCommand{}
|
||||
s.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
||||
p = flags.NewParser(&s, flags.Default)
|
||||
args = []string{
|
||||
"test",
|
||||
"--auth.email.host=smtp-old.example.org",
|
||||
"--smtp.host=''",
|
||||
}
|
||||
_, err = p.ParseArgs(args)
|
||||
require.NoError(t, err)
|
||||
deprecatedFlagsCollisions = s.findDeprecatedFlagsCollisions()
|
||||
assert.Empty(t, []DeprecatedFlag{}, deprecatedFlagsCollisions)
|
||||
}
|
||||
|
||||
func Test_ACMEEmail(t *testing.T) {
|
||||
cmd := ServerCommand{}
|
||||
cmd.SetCommon(CommonOpts{RemarkURL: "https://remark.com:443", SharedSecret: "123456"})
|
||||
@@ -417,7 +612,7 @@ func TestServerAuthHooks(t *testing.T) {
|
||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
||||
},
|
||||
User: &token.User{
|
||||
ID: "dev",
|
||||
ID: "github_dev",
|
||||
Name: "developer one",
|
||||
},
|
||||
}
|
||||
@@ -425,15 +620,17 @@ func TestServerAuthHooks(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
t.Log(tk)
|
||||
|
||||
// add comment
|
||||
client := http.Client{Timeout: 10 * time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
// add comment
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
||||
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/p/2018/12/29/podcast-630/", "site": "remark"}}`))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("X-JWT", tk)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode, "non-blocked user able to post")
|
||||
|
||||
// add comment with no-aud claim
|
||||
@@ -443,28 +640,28 @@ func TestServerAuthHooks(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
t.Logf("no-aud claims: %s", tkNoAud)
|
||||
req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
||||
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/p/2018/12/29/podcast-631/",
|
||||
"site": "remark"}}`))
|
||||
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/p/2018/12/29/podcast-631/",
|
||||
"site": "remark"}}`))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("X-JWT", tkNoAud)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "user without aud claim rejected, \n"+tkNoAud+"\n"+string(body))
|
||||
|
||||
// block user dev as admin
|
||||
// block user github_dev as admin
|
||||
req, err = http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("http://localhost:%d/api/v1/admin/user/dev?site=remark&block=1&ttl=10d", port), nil)
|
||||
fmt.Sprintf("http://localhost:%d/api/v1/admin/user/github_dev?site=remark&block=1&ttl=10d", port), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, "user dev blocked")
|
||||
b, err := ioutil.ReadAll(resp.Body)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, "user github_dev blocked")
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
t.Log(string(b))
|
||||
|
||||
// try add a comment with blocked user
|
||||
@@ -474,29 +671,59 @@ func TestServerAuthHooks(t *testing.T) {
|
||||
req.Header.Set("X-JWT", tk)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized,
|
||||
"blocked user can't post, \n"+tk+"\n"+string(body))
|
||||
|
||||
cancel()
|
||||
app.Wait()
|
||||
client.CloseIdleConnections()
|
||||
}
|
||||
|
||||
func TestServer_loadEmailTemplate(t *testing.T) {
|
||||
func TestServerCommand_parseSameSite(t *testing.T) {
|
||||
tbl := []struct {
|
||||
inp string
|
||||
res http.SameSite
|
||||
}{
|
||||
{"", http.SameSiteDefaultMode},
|
||||
{"default", http.SameSiteDefaultMode},
|
||||
{"blah", http.SameSiteDefaultMode},
|
||||
{"none", http.SameSiteNoneMode},
|
||||
{"lax", http.SameSiteLaxMode},
|
||||
{"strict", http.SameSiteStrictMode},
|
||||
}
|
||||
|
||||
cmd := ServerCommand{}
|
||||
cmd.Auth.Email.MsgTemplate = "testdata/email.tmpl"
|
||||
r := cmd.loadEmailTemplate()
|
||||
assert.Equal(t, "The token is {{.Token}}", r)
|
||||
for i, tt := range tbl {
|
||||
tt := tt
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
assert.Equal(t, tt.res, cmd.parseSameSite(tt.inp))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
cmd.Auth.Email.MsgTemplate = ""
|
||||
r = cmd.loadEmailTemplate()
|
||||
assert.Contains(t, r, "Remark42</h1>")
|
||||
func Test_splitAtCommas(t *testing.T) {
|
||||
tbl := []struct {
|
||||
inp string
|
||||
res []string
|
||||
}{
|
||||
{"a string", []string{"a string"}},
|
||||
{"vv1, vv2, vv3", []string{"vv1", "vv2", "vv3"}},
|
||||
{`"vv1, blah", vv2, vv3`, []string{"vv1, blah", "vv2", "vv3"}},
|
||||
{
|
||||
`Access-Control-Allow-Headers:"DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type",header123:val, foo:"bar1,bar2"`,
|
||||
[]string{"Access-Control-Allow-Headers:\"DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type\"", "header123:val", "foo:\"bar1,bar2\""},
|
||||
},
|
||||
{"", []string{}},
|
||||
}
|
||||
|
||||
cmd.Auth.Email.MsgTemplate = "bad-file"
|
||||
r = cmd.loadEmailTemplate()
|
||||
assert.Contains(t, r, "Remark42</h1>")
|
||||
for i, tt := range tbl {
|
||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||
assert.Equal(t, tt.res, splitAtCommas(tt.inp))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func chooseRandomUnusedPort() (port int) {
|
||||
@@ -513,6 +740,7 @@ func chooseRandomUnusedPort() (port int) {
|
||||
func waitForHTTPServerStart(port int) {
|
||||
// wait for up to 3 seconds for server to start before returning it
|
||||
client := http.Client{Timeout: time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
for i := 0; i < 300; i++ {
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil {
|
||||
@@ -542,17 +770,25 @@ func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serve
|
||||
p := flags.NewParser(&cmd, flags.Default)
|
||||
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--site=remark"})
|
||||
require.NoError(t, err)
|
||||
cmd.Avatar.FS.Path, cmd.Avatar.Type, cmd.BackupLocation, cmd.Image.FS.Path = "/tmp", "fs", "/tmp", "/tmp"
|
||||
cmd.Avatar.FS.Path, cmd.Avatar.Type, cmd.BackupLocation, cmd.Image.FS.Path = "/tmp/remark42_test", "fs", "/tmp/remark42_test", "/tmp/remark42_test"
|
||||
cmd.Store.Bolt.Path = fmt.Sprintf("/tmp/%d", cmd.Port)
|
||||
cmd.Store.Bolt.Timeout = 10 * time.Second
|
||||
cmd.Auth.Apple.CID, cmd.Auth.Apple.KID, cmd.Auth.Apple.TID = "cid", "kid", "tid"
|
||||
cmd.Auth.Apple.PrivateKeyFilePath = "testdata/apple.p8"
|
||||
cmd.Auth.Github.CSEC, cmd.Auth.Github.CID = "csec", "cid"
|
||||
cmd.Auth.Google.CSEC, cmd.Auth.Google.CID = "csec", "cid"
|
||||
cmd.Auth.Facebook.CSEC, cmd.Auth.Facebook.CID = "csec", "cid"
|
||||
cmd.Auth.Yandex.CSEC, cmd.Auth.Yandex.CID = "csec", "cid"
|
||||
cmd.Auth.Microsoft.CSEC, cmd.Auth.Microsoft.CID = "csec", "cid"
|
||||
cmd.Auth.Twitter.CSEC, cmd.Auth.Twitter.CID = "csec", "cid"
|
||||
cmd.Auth.Patreon.CSEC, cmd.Auth.Patreon.CID = "csec", "cid"
|
||||
cmd.Auth.Telegram = true
|
||||
cmd.Telegram.Token = "token"
|
||||
cmd.Auth.Email.Enable = true
|
||||
cmd.Auth.Email.MsgTemplate = "testdata/email.tmpl"
|
||||
cmd.BackupLocation = "/tmp"
|
||||
cmd.Notify.Type = []string{"email"}
|
||||
cmd.Notify.Users = []string{"email"}
|
||||
cmd.Notify.Admins = []string{"email"}
|
||||
cmd.Notify.Email.From = "from@example.org"
|
||||
cmd.Notify.Email.VerificationSubject = "test verification email subject"
|
||||
cmd.SMTP.Host = "127.0.0.1"
|
||||
@@ -561,15 +797,34 @@ func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serve
|
||||
cmd.SMTP.Password = "test_password"
|
||||
cmd.SMTP.TimeOut = time.Second
|
||||
cmd.UpdateLimit = 10
|
||||
cmd.Admin.Type = "shared"
|
||||
cmd.Admin.Shared.Admins = []string{"id1", "id2"}
|
||||
cmd.RestrictedNames = []string{"umputun", "bobuk"}
|
||||
cmd.emailMsgTemplatePath = "../../templates/email_reply.html.tmpl"
|
||||
cmd.emailVerificationTemplatePath = "../../templates/email_confirmation_subscription.html.tmpl"
|
||||
cmd = fn(cmd)
|
||||
|
||||
os.Remove(cmd.Store.Bolt.Path + "/remark.db")
|
||||
app, ctx, cancel := createAppFromCmd(t, cmd)
|
||||
|
||||
// create app
|
||||
app, err := cmd.newServerApp()
|
||||
require.NoError(t, err)
|
||||
// cleanup the remark.db file after context is canceled
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
os.RemoveAll(cmd.Store.Bolt.Path)
|
||||
os.RemoveAll(cmd.Avatar.FS.Path)
|
||||
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
return app, ctx, cancel
|
||||
}
|
||||
|
||||
func createAppFromCmd(t *testing.T, cmd ServerCommand) (*serverApp, context.Context, context.CancelFunc) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
app, err := cmd.newServerApp(ctx)
|
||||
require.NoError(t, err)
|
||||
return app, ctx, cancel
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// ignore is added only for GitHub Actions, can't reproduce locally
|
||||
goleak.VerifyTestMain(m, goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"))
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKNwapOQ6rQJHetP
|
||||
HRlJBIh1OsOsUBiXb3rXXE3xpWAxAha0MH+UPRblOko+5T2JqIb+xKf9Vi3oTM3t
|
||||
KvffaOPtzKXZauscjq6NGzA3LgeiMy6q19pvkUUOlGYK6+Xfl+B7Xw6+hBMkQuGE
|
||||
nUS8nkpR5mK4ne7djIyfHFfMu4ptAgMBAAECgYA+s0PPtMq1osG9oi4xoxeAGikf
|
||||
JB3eMUptP+2DYW7mRibc+ueYKhB9lhcUoKhlQUhL8bUUFVZYakP8xD21thmQqnC4
|
||||
f63asad0ycteJMLb3r+z26LHuCyOdPg1pyLk3oQ32lVQHBCYathRMcVznxOG16VK
|
||||
I8BFfstJTaJu0lK/wQJBANYFGusBiZsJQ3utrQMVPpKmloO2++4q1v6ZR4puDQHx
|
||||
TjLjAIgrkYfwTJBLBRZxec0E7TmuVQ9uJ+wMu/+7zaUCQQDDf2xMnQqYknJoKGq+
|
||||
oAnyC66UqWC5xAnQS32mlnJ632JXA0pf9pb1SXAYExB1p9Dfqd3VAwQDwBsDDgP6
|
||||
HD8pAkEA0lscNQZC2TaGtKZk2hXkdcH1SKru/g3vWTkRHxfCAznJUaza1fx0wzdG
|
||||
GcES1Bdez0tbW4llI5By/skZc2eE3QJAFl6fOskBbGHde3Oce0F+wdZ6XIJhEgCP
|
||||
iukIcKZoZQzoiMJUoVRrA5gqnmaYDI5uRRl/y57zt6YksR3KcLUIuQJAd242M/WF
|
||||
6YAZat3q/wEeETeQq1wrooew+8lHl05/Nt0cCpV48RGEhJ83pzBm3mnwHf8lTBJH
|
||||
x6XroMXsmbnsEw==
|
||||
-----END PRIVATE KEY-----
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgGH2MylyZjjRdauTk
|
||||
xxXW6p8VSHqIeVRRKSJPg1xn6+KgCgYIKoZIzj0DAQehRANCAAS/mNzQ7aBbIBr3
|
||||
DiHiJGIDEzi6+q3mmyhH6ZWQWFdFei2qgdyM1V6qtRPVq+yHBNSBebbR4noE/IYO
|
||||
hMdWYrKn
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
This stub page would be replaced by the frontend statically built HTML during the Docker image build.
|
||||
+24
-10
@@ -10,7 +10,7 @@ import (
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/jessevdk/go-flags"
|
||||
|
||||
"github.com/umputun/remark/backend/app/cmd"
|
||||
"github.com/umputun/remark42/backend/app/cmd"
|
||||
)
|
||||
|
||||
// Opts with all cli commands and flags
|
||||
@@ -23,8 +23,9 @@ type Opts struct {
|
||||
CleanupCmd cmd.CleanupCommand `command:"cleanup"`
|
||||
RemapCmd cmd.RemapCommand `command:"remap"`
|
||||
|
||||
RemarkURL string `long:"url" env:"REMARK_URL" required:"true" description:"url to remark"`
|
||||
SharedSecret string `long:"secret" env:"SECRET" required:"true" description:"shared secret key"`
|
||||
RemarkURL string `long:"url" env:"REMARK_URL" required:"true" description:"url to remark"`
|
||||
// SharedSecret is only used in server command, but defined for all commands for historical reasons
|
||||
SharedSecret string `long:"secret" env:"SECRET" required:"true" description:"the shared secret key used to sign JWT, should be a random, long, hard-to-guess string"`
|
||||
|
||||
Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"`
|
||||
}
|
||||
@@ -45,10 +46,7 @@ func main() {
|
||||
SharedSecret: opts.SharedSecret,
|
||||
Revision: revision,
|
||||
})
|
||||
for _, entry := range c.HandleDeprecatedFlags() {
|
||||
log.Printf("[WARN] --%s is deprecated and will be removed in v%s, please use --%s instead",
|
||||
entry.Old, entry.RemoveVersion, entry.New)
|
||||
}
|
||||
logDeprecatedParams(c.HandleDeprecatedFlags())
|
||||
err := c.Execute(args)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] failed with %+v", err)
|
||||
@@ -59,9 +57,8 @@ func main() {
|
||||
if _, err := p.Parse(); err != nil {
|
||||
if flagsErr, ok := err.(*flags.Error); ok && flagsErr.Type == flags.ErrHelp {
|
||||
os.Exit(0)
|
||||
} else {
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +70,22 @@ func setupLog(dbg bool) {
|
||||
log.Setup(log.Msec, log.LevelBraces)
|
||||
}
|
||||
|
||||
// logs usual and "collision" deprecated parameters
|
||||
func logDeprecatedParams(params []cmd.DeprecatedFlag) {
|
||||
for _, entry := range params {
|
||||
var deprecationNote string
|
||||
if entry.Collision {
|
||||
deprecationNote = fmt.Sprintf("[ERROR] deprecated --%s and new --%s options are set to different values, old one is ignored: please remove it", entry.Old, entry.New)
|
||||
} else {
|
||||
deprecationNote = fmt.Sprintf("[WARN] --%s is deprecated since v%s and will be removed in the future", entry.Old, entry.Version)
|
||||
if entry.New != "" {
|
||||
deprecationNote += fmt.Sprintf(", please use --%s instead", entry.New)
|
||||
}
|
||||
}
|
||||
log.Print(deprecationNote)
|
||||
}
|
||||
}
|
||||
|
||||
// getDump reads runtime stack and returns as a string
|
||||
func getDump() string {
|
||||
maxSize := 5 * 1024 * 1024
|
||||
@@ -84,9 +97,10 @@ func getDump() string {
|
||||
return string(stacktrace[:length])
|
||||
}
|
||||
|
||||
// nolint:gochecknoinits // can't avoid it in this place
|
||||
func init() {
|
||||
// catch SIGQUIT and print stack traces
|
||||
sigChan := make(chan os.Signal)
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
go func() {
|
||||
for range sigChan {
|
||||
log.Printf("[INFO] SIGQUIT detected, dump:\n%s", getDump())
|
||||
|
||||
@@ -2,25 +2,26 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func Test_Main(t *testing.T) {
|
||||
|
||||
dir, err := ioutil.TempDir(os.TempDir(), "remark42")
|
||||
dir, err := os.MkdirTemp(os.TempDir(), "remark42")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
@@ -31,8 +32,8 @@ func Test_Main(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
<-done
|
||||
err := syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
|
||||
require.NoError(t, err)
|
||||
e := syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
|
||||
require.NoError(t, e)
|
||||
}()
|
||||
|
||||
finished := make(chan struct{})
|
||||
@@ -51,18 +52,79 @@ func Test_Main(t *testing.T) {
|
||||
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "pong", string(body))
|
||||
}
|
||||
|
||||
func TestMain_WithWebhook(t *testing.T) {
|
||||
dir, err := os.MkdirTemp(os.TempDir(), "remark42")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
var webhookSent int32
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.StoreInt32(&webhookSent, 1)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
b, e := io.ReadAll(r.Body)
|
||||
defer r.Body.Close()
|
||||
|
||||
assert.Nil(t, e)
|
||||
assert.Equal(t, "Comment: env test", string(b))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
port := chooseRandomUnusedPort()
|
||||
os.Args = []string{"test", "server", "--secret=123456", "--store.bolt.path=" + dir, "--backup=/tmp",
|
||||
"--avatar.fs.path=" + dir, "--port=" + strconv.Itoa(port), "--url=https://demo.remark42.com", "--dbg",
|
||||
"--admin-passwd=password", "--site=remark", "--notify.admins=webhook"}
|
||||
|
||||
err = os.Setenv("NOTIFY_WEBHOOK_URL", ts.URL)
|
||||
assert.NoError(t, err)
|
||||
err = os.Setenv("NOTIFY_WEBHOOK_TEMPLATE", "Comment: {{.Orig}}")
|
||||
assert.NoError(t, err)
|
||||
err = os.Setenv("NOTIFY_WEBHOOK_HEADERS", "Content-Type:application/json")
|
||||
assert.NoError(t, err)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
<-done
|
||||
e := syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
|
||||
require.NoError(t, e)
|
||||
}()
|
||||
|
||||
finished := make(chan struct{})
|
||||
go func() {
|
||||
main()
|
||||
assert.Eventually(t, func() bool {
|
||||
return atomic.LoadInt32(&webhookSent) == int32(1)
|
||||
}, time.Second, 100*time.Millisecond, "webhook was not sent")
|
||||
close(finished)
|
||||
}()
|
||||
|
||||
// defer cleanup because require check below can fail
|
||||
defer func() {
|
||||
close(done)
|
||||
<-finished
|
||||
}()
|
||||
|
||||
waitForHTTPServerStart(port)
|
||||
|
||||
resp, err := http.Post(fmt.Sprintf("http://admin:password@localhost:%d/api/v1/comment", port), "",
|
||||
strings.NewReader(`{"text": "env test", "locator":{"url": "https://radio-t.com", "site": "remark"}}`))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestGetDump(t *testing.T) {
|
||||
dump := getDump()
|
||||
assert.True(t, strings.Contains(dump, "goroutine"))
|
||||
assert.True(t, strings.Contains(dump, "[running]"))
|
||||
assert.True(t, strings.Contains(dump, "backend/app/main.go"))
|
||||
log.Printf("\n dump: %s", dump)
|
||||
t.Logf("\n dump: %s", dump)
|
||||
}
|
||||
|
||||
func chooseRandomUnusedPort() (port int) {
|
||||
@@ -79,6 +141,7 @@ func chooseRandomUnusedPort() (port int) {
|
||||
func waitForHTTPServerStart(port int) {
|
||||
// wait for up to 10 seconds for server to start before returning it
|
||||
client := http.Client{Timeout: time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
for i := 0; i < 100; i++ {
|
||||
time.Sleep(time.Millisecond * 100)
|
||||
if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil {
|
||||
@@ -87,3 +150,12 @@ func waitForHTTPServerStart(port int) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// both ignores are for leaks which are detected locally
|
||||
goleak.VerifyTestMain(
|
||||
m,
|
||||
goleak.IgnoreTopFunction("github.com/umputun/remark42/backend/app.init.0.func1"),
|
||||
goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,14 +4,12 @@ import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// AutoBackup struct handles daily backups params for siteID
|
||||
@@ -27,6 +25,7 @@ type AutoBackup struct {
|
||||
func (ab AutoBackup) Do(ctx context.Context) {
|
||||
log.Printf("[INFO] activate auto-backup for %s under %s, duration %s", ab.SiteID, ab.BackupLocation, ab.Duration)
|
||||
tick := time.NewTicker(ab.Duration)
|
||||
defer tick.Stop()
|
||||
log.Printf("[DEBUG] first backup for %s at %s", ab.SiteID, time.Now().Add(ab.Duration))
|
||||
|
||||
for {
|
||||
@@ -48,38 +47,43 @@ func (ab AutoBackup) Do(ctx context.Context) {
|
||||
func (ab AutoBackup) makeBackup() (string, error) {
|
||||
log.Printf("[DEBUG] make backup for %s", ab.SiteID)
|
||||
backupFile := fmt.Sprintf("%s/backup-%s-%s.gz", ab.BackupLocation, ab.SiteID, time.Now().Format("20060102"))
|
||||
fh, err := os.Create(backupFile)
|
||||
fh, err := os.Create(backupFile) //nolint:gosec // harmless
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "can't create backup file %s", backupFile)
|
||||
return "", fmt.Errorf("can't create backup file %s: %w", backupFile, err)
|
||||
}
|
||||
gz := gzip.NewWriter(fh)
|
||||
|
||||
if _, err = ab.Exporter.Export(gz, ab.SiteID); err != nil {
|
||||
return "", errors.Wrapf(err, "export failed for %s", ab.SiteID)
|
||||
return "", fmt.Errorf("export failed for %s: %w", ab.SiteID, err)
|
||||
}
|
||||
if err = gz.Close(); err != nil {
|
||||
return "", errors.Wrapf(err, "can't close gz for %s", backupFile)
|
||||
return "", fmt.Errorf("can't close gz for %s: %w", backupFile, err)
|
||||
}
|
||||
if err = fh.Close(); err != nil {
|
||||
return "", errors.Wrapf(err, "can't close file handler for %s", backupFile)
|
||||
return "", fmt.Errorf("can't close file handler for %s: %w", backupFile, err)
|
||||
}
|
||||
log.Printf("[DEBUG] created backup file %s", backupFile)
|
||||
return backupFile, nil
|
||||
}
|
||||
|
||||
func (ab AutoBackup) removeOldBackupFiles() {
|
||||
files, err := ioutil.ReadDir(ab.BackupLocation)
|
||||
files, err := os.ReadDir(ab.BackupLocation)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] can't read files in backup directory %s, %s", ab.BackupLocation, err)
|
||||
return
|
||||
}
|
||||
backFiles := []os.FileInfo{}
|
||||
for _, file := range files {
|
||||
info, e := file.Info()
|
||||
if e != nil {
|
||||
log.Printf("[WARN] can't read info for directory %s, %s", file.Name(), e)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(file.Name(), "backup-"+ab.SiteID) {
|
||||
backFiles = append(backFiles, file)
|
||||
backFiles = append(backFiles, info)
|
||||
}
|
||||
}
|
||||
sort.Slice(backFiles, func(i int, j int) bool { return backFiles[i].Name() < backFiles[j].Name() })
|
||||
sort.Slice(backFiles, func(i, j int) bool { return backFiles[i].Name() < backFiles[j].Name() })
|
||||
|
||||
if len(backFiles) > ab.KeepMax {
|
||||
for i := 0; i < len(backFiles)-ab.KeepMax; i++ {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -17,20 +16,20 @@ func TestBackup_RemoveOldBackupFiles(t *testing.T) {
|
||||
loc := "/tmp/remark-backups.test"
|
||||
defer os.RemoveAll(loc)
|
||||
|
||||
assert.NoError(t, os.MkdirAll(loc, 0700))
|
||||
assert.NoError(t, os.MkdirAll(loc, 0o700))
|
||||
|
||||
for i := 1; i <= 10; i++ {
|
||||
fname := fmt.Sprintf("%s/backup-site1-201712%02d.gz", loc, i)
|
||||
err := ioutil.WriteFile(fname, []byte("blah"), 0600)
|
||||
err := os.WriteFile(fname, []byte("blah"), 0o600)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
fname := fmt.Sprintf("%s/backup-site2-20171210.gz", loc)
|
||||
err := ioutil.WriteFile(fname, []byte("blah"), 0600)
|
||||
err := os.WriteFile(fname, []byte("blah"), 0o600)
|
||||
assert.NoError(t, err)
|
||||
|
||||
bk := AutoBackup{BackupLocation: loc, SiteID: "site1", KeepMax: 3}
|
||||
bk.removeOldBackupFiles()
|
||||
ff, err := ioutil.ReadDir(loc)
|
||||
ff, err := os.ReadDir(loc)
|
||||
assert.NoError(t, err)
|
||||
require.Equal(t, 4, len(ff), "should keep 4 files - 3 kept for sit1, and one for site2")
|
||||
assert.Equal(t, "backup-site1-20171208.gz", ff[0].Name())
|
||||
@@ -42,7 +41,7 @@ func TestBackup_RemoveOldBackupFiles(t *testing.T) {
|
||||
func TestBackup_MakeBackup(t *testing.T) {
|
||||
loc := "/tmp/remark-backups.test"
|
||||
defer os.RemoveAll(loc)
|
||||
assert.NoError(t, os.MkdirAll(loc, 0700))
|
||||
assert.NoError(t, os.MkdirAll(loc, 0o700))
|
||||
|
||||
bk := AutoBackup{BackupLocation: loc, SiteID: "site1", KeepMax: 3, Exporter: &mockExporter{}}
|
||||
fname, err := bk.makeBackup()
|
||||
@@ -58,7 +57,7 @@ func TestBackup_MakeBackup(t *testing.T) {
|
||||
func TestBackup_Do(t *testing.T) {
|
||||
loc := "/tmp/remark-backups.test"
|
||||
defer os.RemoveAll(loc)
|
||||
assert.NoError(t, os.MkdirAll(loc, 0700))
|
||||
assert.NoError(t, os.MkdirAll(loc, 0o700))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
@@ -77,7 +76,7 @@ func TestBackup_Do(t *testing.T) {
|
||||
|
||||
type mockExporter struct{}
|
||||
|
||||
func (mock *mockExporter) Export(w io.Writer, siteID string) (int, error) {
|
||||
func (mock *mockExporter) Export(w io.Writer, _ string) (int, error) {
|
||||
_, err := w.Write([]byte("some export blah blah 1234567890"))
|
||||
return 1000, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
)
|
||||
|
||||
// Commento implements Importer from commento export json
|
||||
type Commento struct {
|
||||
DataStore Store
|
||||
}
|
||||
|
||||
// Credit: https://gitlab.com/commento/commento/-/blob/master/api/domain_import_commento.go#L11-L15
|
||||
type commentoExport struct {
|
||||
Version int `json:"version"`
|
||||
Comments []commentoComment `json:"comments"`
|
||||
Commenters []commentoCommenter `json:"commenters"`
|
||||
}
|
||||
|
||||
// Credit: https://gitlab.com/commento/commento/-/blob/master/api/comment.go#L7-L20
|
||||
type commentoComment struct {
|
||||
CommentHex string `json:"commentHex"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Path string `json:"url,omitempty"`
|
||||
CommenterHex string `json:"commenterHex"`
|
||||
Markdown string `json:"markdown"`
|
||||
HTML string `json:"html"`
|
||||
ParentHex string `json:"parentHex"`
|
||||
Score int `json:"score"`
|
||||
State string `json:"state,omitempty"`
|
||||
CreationDate time.Time `json:"creationDate"`
|
||||
Direction int `json:"direction"`
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// Credit: https://gitlab.com/commento/commento/-/blob/master/api/commenter.go#L7-L16
|
||||
type commentoCommenter struct {
|
||||
CommenterHex string `json:"commenterHex,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Link string `json:"link"`
|
||||
Photo string `json:"photo"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
JoinDate time.Time `json:"joinDate,omitempty"`
|
||||
IsModerator bool `json:"isModerator"`
|
||||
}
|
||||
|
||||
// Import comments from Commento and save to store
|
||||
func (d *Commento) Import(r io.Reader, siteID string) (size int, err error) {
|
||||
if e := d.DataStore.DeleteAll(siteID); e != nil {
|
||||
return 0, e
|
||||
}
|
||||
|
||||
commentsCh := d.convert(r, siteID)
|
||||
failed, passed := 0, 0
|
||||
for c := range commentsCh {
|
||||
if _, err = d.DataStore.Create(c); err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
passed++
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
err = fmt.Errorf("failed to save %d comments", failed)
|
||||
if passed == 0 {
|
||||
err = fmt.Errorf("import failed")
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] imported %d comments to site %s", passed, siteID)
|
||||
|
||||
return passed, err
|
||||
}
|
||||
|
||||
func (d *Commento) convert(r io.Reader, siteID string) (ch chan store.Comment) {
|
||||
commentsCh := make(chan store.Comment)
|
||||
|
||||
decoder := json.NewDecoder(r)
|
||||
|
||||
go func() {
|
||||
var exportedData commentoExport
|
||||
err := decoder.Decode(&exportedData)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] can't decode commento export json, %s", err.Error())
|
||||
}
|
||||
|
||||
usersMap := map[string]store.User{}
|
||||
for _, commenter := range exportedData.Commenters {
|
||||
usersMap[commenter.CommenterHex] = store.User{
|
||||
Name: commenter.Name,
|
||||
ID: "commento_" + store.EncodeID(commenter.CommenterHex),
|
||||
Picture: commenter.Photo,
|
||||
}
|
||||
}
|
||||
|
||||
for _, comment := range exportedData.Comments {
|
||||
u, ok := usersMap[comment.CommenterHex]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if comment.Deleted {
|
||||
continue
|
||||
}
|
||||
|
||||
c := store.Comment{
|
||||
ID: comment.CommentHex,
|
||||
Locator: store.Locator{
|
||||
URL: comment.Path,
|
||||
SiteID: siteID,
|
||||
},
|
||||
User: u,
|
||||
Text: comment.Markdown,
|
||||
Timestamp: comment.CreationDate,
|
||||
ParentID: comment.ParentHex,
|
||||
Imported: true,
|
||||
}
|
||||
|
||||
commentsCh <- c
|
||||
}
|
||||
|
||||
close(commentsCh)
|
||||
}()
|
||||
|
||||
return commentsCh
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store/admin"
|
||||
"github.com/umputun/remark42/backend/app/store/engine"
|
||||
"github.com/umputun/remark42/backend/app/store/service"
|
||||
)
|
||||
|
||||
func TestCommento_Import(t *testing.T) {
|
||||
defer os.Remove("/tmp/remark-test.db")
|
||||
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
|
||||
require.NoError(t, err, "create store")
|
||||
dataStore := service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
|
||||
defer dataStore.Close()
|
||||
|
||||
d := Commento{DataStore: &dataStore}
|
||||
fh, err := os.Open("testdata/commento.json")
|
||||
require.NoError(t, err)
|
||||
size, err := d.Import(fh, "test")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, size)
|
||||
|
||||
last, err := dataStore.Last("test", 10, time.Time{}, adminUser)
|
||||
assert.NoError(t, err)
|
||||
require.Equal(t, 2, len(last), "2 comments imported")
|
||||
|
||||
t.Log(last[0])
|
||||
|
||||
c := last[0] // last reverses, get first one
|
||||
assert.Equal(t, "Great reply!", c.Text)
|
||||
assert.Equal(t, "ea5f7bcd6ac9bb7b657f7d0569831104e1bcf9c253d03c1e16bf9654c49a5ce9", c.ID)
|
||||
assert.Equal(t, "7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854", c.ParentID)
|
||||
assert.Equal(t, store.Locator{SiteID: "test", URL: "https://example.com/blog/post/1"}, c.Locator)
|
||||
assert.Equal(t, "Saturnin Uf", c.User.Name)
|
||||
assert.Equal(t, "commento_35369aeb6ac5255de30410a0f86dc71eb9c6d0ca", c.User.ID)
|
||||
assert.True(t, c.Imported)
|
||||
|
||||
posts, err := dataStore.List("test", 0, 0)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(posts), "1 post")
|
||||
|
||||
count, err := dataStore.Count(store.Locator{SiteID: "test", URL: "https://example.com/blog/post/1"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, count)
|
||||
}
|
||||
@@ -2,14 +2,14 @@ package migrator
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
)
|
||||
|
||||
// Disqus implements Importer from disqus xml
|
||||
@@ -44,6 +44,7 @@ type disqusComment struct {
|
||||
Tid uid `xml:"thread"`
|
||||
Pid uid `xml:"parent"`
|
||||
IsSpam bool `xml:"isSpam"`
|
||||
Deleted bool `xml:"isDeleted"`
|
||||
}
|
||||
|
||||
type uid struct {
|
||||
@@ -52,9 +53,8 @@ type uid struct {
|
||||
|
||||
// Import from disqus and save to store
|
||||
func (d *Disqus) Import(r io.Reader, siteID string) (size int, err error) {
|
||||
|
||||
if err = d.DataStore.DeleteAll(siteID); err != nil {
|
||||
return 0, err
|
||||
if e := d.DataStore.DeleteAll(siteID); e != nil {
|
||||
return 0, e
|
||||
}
|
||||
|
||||
commentsCh := d.convert(r, siteID)
|
||||
@@ -68,9 +68,9 @@ func (d *Disqus) Import(r io.Reader, siteID string) (size int, err error) {
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
err = errors.Errorf("failed to save %d comments", failed)
|
||||
err = fmt.Errorf("failed to save %d comments", failed)
|
||||
if passed == 0 {
|
||||
err = errors.New("import failed")
|
||||
err = fmt.Errorf("import failed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,15 +82,15 @@ func (d *Disqus) Import(r io.Reader, siteID string) (size int, err error) {
|
||||
// convert disqus stream (xml) from reader and fill channel of comments.
|
||||
// runs async and closes channel on completion.
|
||||
func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) {
|
||||
|
||||
postsMap := map[string]string{} // tid:url
|
||||
decoder := xml.NewDecoder(r)
|
||||
commentsCh := make(chan store.Comment)
|
||||
|
||||
stats := struct {
|
||||
inpThreads, inpComments int
|
||||
commentsCount, spamComments int
|
||||
failedThreads, failedPosts int
|
||||
inpThreads, inpComments int
|
||||
commentsCount, spamComments int
|
||||
failedThreads, failedPosts int
|
||||
deletedComments, skippedComments int
|
||||
}{}
|
||||
|
||||
go func() {
|
||||
@@ -100,8 +100,7 @@ func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) {
|
||||
break
|
||||
}
|
||||
|
||||
switch se := t.(type) {
|
||||
case xml.StartElement:
|
||||
if se, ok := t.(xml.StartElement); ok {
|
||||
if se.Name.Local == "thread" {
|
||||
stats.inpThreads++
|
||||
thread := disqusThread{}
|
||||
@@ -110,9 +109,13 @@ func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) {
|
||||
stats.failedThreads++
|
||||
continue
|
||||
}
|
||||
if thread.Deleted {
|
||||
continue
|
||||
}
|
||||
postsMap[thread.UID] = thread.Link
|
||||
continue
|
||||
}
|
||||
|
||||
if se.Name.Local == "post" {
|
||||
stats.inpComments++
|
||||
comment := disqusComment{}
|
||||
@@ -121,13 +124,24 @@ func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) {
|
||||
stats.failedPosts++
|
||||
continue
|
||||
}
|
||||
if comment.Deleted {
|
||||
stats.deletedComments++
|
||||
continue
|
||||
}
|
||||
if comment.IsSpam {
|
||||
stats.spamComments++
|
||||
continue
|
||||
}
|
||||
|
||||
url, ok := postsMap[comment.Tid.Val]
|
||||
if !ok {
|
||||
stats.skippedComments++
|
||||
continue
|
||||
}
|
||||
|
||||
c := store.Comment{
|
||||
ID: comment.UID,
|
||||
Locator: store.Locator{URL: postsMap[comment.Tid.Val], SiteID: siteID},
|
||||
Locator: store.Locator{URL: url, SiteID: siteID},
|
||||
User: store.User{
|
||||
ID: "disqus_" + store.EncodeID(comment.AuthorUserName),
|
||||
Name: comment.AuthorName,
|
||||
@@ -136,9 +150,10 @@ func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) {
|
||||
Text: d.cleanText(comment.Message),
|
||||
Timestamp: comment.CreatedAt,
|
||||
ParentID: comment.Pid.Val,
|
||||
Imported: true,
|
||||
}
|
||||
if c.User.ID == "disqus_" { // empty comment.AuthorUserName from disqus
|
||||
c.User.ID = "disqus_" + c.User.Name
|
||||
if comment.AuthorUserName == "" { // empty comment.AuthorUserName from disqus
|
||||
c.User.ID = "disqus_" + store.EncodeID(c.User.Name)
|
||||
}
|
||||
if c.ID == "" { // no comment.UID
|
||||
c.ID = comment.ID
|
||||
@@ -159,6 +174,7 @@ func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) {
|
||||
}
|
||||
|
||||
func (*Disqus) cleanText(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
text = strings.Replace(text, "\n", "", -1)
|
||||
text = strings.Replace(text, "\t", "", -1)
|
||||
return text
|
||||
|
||||
@@ -6,14 +6,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
bolt "github.com/coreos/bbolt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark/backend/app/store/admin"
|
||||
"github.com/umputun/remark/backend/app/store/engine"
|
||||
"github.com/umputun/remark/backend/app/store/service"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store/admin"
|
||||
"github.com/umputun/remark42/backend/app/store/engine"
|
||||
"github.com/umputun/remark42/backend/app/store/service"
|
||||
)
|
||||
|
||||
func TestDisqus_Import(t *testing.T) {
|
||||
@@ -23,7 +23,9 @@ func TestDisqus_Import(t *testing.T) {
|
||||
dataStore := service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
|
||||
defer dataStore.Close()
|
||||
d := Disqus{DataStore: &dataStore}
|
||||
size, err := d.Import(strings.NewReader(xmlTestDisqus), "test")
|
||||
fh, err := os.Open("testdata/disqus.xml")
|
||||
require.NoError(t, err)
|
||||
size, err := d.Import(fh, "test")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 4, size)
|
||||
|
||||
@@ -39,6 +41,11 @@ func TestDisqus_Import(t *testing.T) {
|
||||
assert.Equal(t, "Alexander Blah", c.User.Name)
|
||||
assert.Equal(t, "disqus_328c8b68974aef73785f6b38c3d3fedfdf941434", c.User.ID)
|
||||
assert.Equal(t, "2ba6b71dbf9750ae3356cce14cac6c1b1962747c", c.User.IP)
|
||||
assert.True(t, c.Imported)
|
||||
|
||||
c = last[1] // get comment with empty username
|
||||
assert.Equal(t, "No Username", c.User.Name)
|
||||
assert.Equal(t, "disqus_62e24ea213756cda0339e1074819f15e25214361", c.User.ID)
|
||||
|
||||
posts, err := dataStore.List("test", 0, 0)
|
||||
assert.NoError(t, err)
|
||||
@@ -49,9 +56,71 @@ func TestDisqus_Import(t *testing.T) {
|
||||
assert.Equal(t, 2, count)
|
||||
}
|
||||
|
||||
func TestDisqus_ImportDeletedThread(t *testing.T) {
|
||||
defer os.Remove("/tmp/remark-test.db")
|
||||
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
|
||||
require.NoError(t, err, "create store")
|
||||
dataStore := service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
|
||||
defer dataStore.Close()
|
||||
d := Disqus{DataStore: &dataStore}
|
||||
fh, err := os.Open("testdata/disqus-deleted-thread.xml")
|
||||
require.NoError(t, err)
|
||||
size, err := d.Import(fh, "test")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, size)
|
||||
|
||||
last, err := dataStore.Last("test", 10, time.Time{}, adminUser)
|
||||
assert.NoError(t, err)
|
||||
require.Equal(t, 2, len(last), "2 comments imported")
|
||||
|
||||
c := last[len(last)-1] // last reverses, get first one
|
||||
assert.True(t, strings.HasPrefix(c.Text, "<p>Google App Engine "), c.Text)
|
||||
assert.Equal(t, "299986072", c.ID)
|
||||
assert.Equal(t, "", c.ParentID)
|
||||
assert.Equal(t, store.Locator{SiteID: "test", URL: "http://radio-t.umputun.com/2011/03/229_8880.html"}, c.Locator)
|
||||
assert.Equal(t, "No Username", c.User.Name)
|
||||
assert.Equal(t, "disqus_62e24ea213756cda0339e1074819f15e25214361", c.User.ID)
|
||||
assert.Equal(t, "7001968ea3f6c9013a9f0a3650f200c10c927638", c.User.IP)
|
||||
assert.True(t, c.Imported)
|
||||
|
||||
c = last[1] // get comment with empty username
|
||||
assert.Equal(t, "No Username", c.User.Name)
|
||||
assert.Equal(t, "disqus_62e24ea213756cda0339e1074819f15e25214361", c.User.ID)
|
||||
}
|
||||
|
||||
func TestDisqus_ImportDeletedPost(t *testing.T) {
|
||||
defer os.Remove("/tmp/remark-test.db")
|
||||
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
|
||||
require.NoError(t, err, "create store")
|
||||
dataStore := service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
|
||||
defer dataStore.Close()
|
||||
d := Disqus{DataStore: &dataStore}
|
||||
fh, err := os.Open("testdata/disqus-deleted-post.xml")
|
||||
require.NoError(t, err)
|
||||
size, err := d.Import(fh, "test")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, size, "1 post deleted")
|
||||
|
||||
last, err := dataStore.Last("test", 10, time.Time{}, adminUser)
|
||||
assert.NoError(t, err)
|
||||
require.Equal(t, 3, len(last), "3 comments imported")
|
||||
|
||||
c := last[len(last)-1] // last reverses, get first one
|
||||
assert.True(t, strings.HasPrefix(c.Text, "<p>Microsoft "), c.Text)
|
||||
assert.Equal(t, "299744309", c.ID)
|
||||
assert.Equal(t, "", c.ParentID)
|
||||
assert.Equal(t, store.Locator{SiteID: "test", URL: "https://radio-t.com/p/2011/03/05/podcast-229/"}, c.Locator)
|
||||
assert.Equal(t, "mikhail", c.User.Name)
|
||||
assert.Equal(t, "disqus_1b6709749c0cab163db9070cc4edf3322b398d8c", c.User.ID)
|
||||
assert.Equal(t, "9d3657a95a4e341510404bd8bf1a363faefd4ba4", c.User.IP)
|
||||
assert.True(t, c.Imported)
|
||||
}
|
||||
|
||||
func TestDisqus_Convert(t *testing.T) {
|
||||
d := Disqus{}
|
||||
ch := d.convert(strings.NewReader(xmlTestDisqus), "test")
|
||||
fh, err := os.Open("testdata/disqus.xml")
|
||||
require.NoError(t, err)
|
||||
ch := d.convert(fh, "test")
|
||||
|
||||
res := []store.Comment{}
|
||||
for comment := range ch {
|
||||
@@ -71,160 +140,8 @@ func TestDisqus_Convert(t *testing.T) {
|
||||
ID: "disqus_328c8b68974aef73785f6b38c3d3fedfdf941434",
|
||||
IP: "178.178.178.178",
|
||||
},
|
||||
Imported: true,
|
||||
}
|
||||
exp0.Timestamp, _ = time.Parse("2006-01-02T15:04:05Z", "2011-08-31T15:16:29Z")
|
||||
assert.Equal(t, exp0, res[0])
|
||||
}
|
||||
|
||||
var xmlTestDisqus = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<disqus xmlns="http://disqus.com" xmlns:dsq="http://disqus.com/disqus-internals" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://disqus.com/api/schemas/1.0/disqus.xsd http://disqus.com/api/schemas/1.0/disqus-internals.xsd">
|
||||
|
||||
<category dsq:id="707279">
|
||||
<forum>radiot</forum>
|
||||
<title>General</title>
|
||||
<isDefault>true</isDefault>
|
||||
</category>
|
||||
|
||||
<thread dsq:id="247918464">
|
||||
<id/>
|
||||
<forum>radiot</forum>
|
||||
<category dsq:id="707279"/>
|
||||
<link>http://radio-t.umputun.com/2011/03/229_8880.html</link>
|
||||
<title>Радио-Т: Радио-Т 229</title>
|
||||
<message/>
|
||||
<createdAt>2011-03-07T20:46:25Z</createdAt>
|
||||
<author>
|
||||
<email>umputun@gmail.com</email>
|
||||
<name>Umputun</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>umputun</username>
|
||||
</author>
|
||||
<ipAddress>98.212.28.115</ipAddress>
|
||||
<isClosed>false</isClosed>
|
||||
<isDeleted>false</isDeleted>
|
||||
</thread>
|
||||
|
||||
<thread dsq:id="247937687">
|
||||
<id>http://www.radio-t.com/p/2011/03/05/podcast-229/</id>
|
||||
<forum>radiot</forum>
|
||||
<category dsq:id="707279"/>
|
||||
<link>https://radio-t.com/p/2011/03/05/podcast-229/</link>
|
||||
<title>Радио-Т: Радио-Т 229</title>
|
||||
<message/>
|
||||
<createdAt>2011-03-07T21:17:17Z</createdAt>
|
||||
<author>
|
||||
<email>umputun@gmail.com</email>
|
||||
<name>Umputun</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>umputun</username>
|
||||
</author>
|
||||
<ipAddress>80.250.214.235</ipAddress>
|
||||
<isClosed>true</isClosed>
|
||||
<isDeleted>false</isDeleted>
|
||||
</thread>
|
||||
|
||||
|
||||
<post dsq:id="299619020">
|
||||
<id>3565798471341011339</id>
|
||||
<message>
|
||||
<![CDATA[<p>The quick brown fox jumps over the lazy dog.</p><p><a href="https://https://radio-t.com" rel="nofollow noopener" title="radio-t">some link</a></p>]]>
|
||||
</message>
|
||||
<createdAt>2011-08-31T15:16:29Z</createdAt>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>false</isSpam>
|
||||
<author>
|
||||
<email/>
|
||||
<name>Alexander Blah</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>facebook-1787732238</username>
|
||||
</author>
|
||||
<ipAddress>178.178.178.178</ipAddress>
|
||||
<thread dsq:id="247937687"/>
|
||||
</post>
|
||||
|
||||
<post dsq:id="299744309">
|
||||
<id>3029154520436241933</id>
|
||||
<message>
|
||||
<![CDATA[<p>Microsoft показал проводник Windows 8 с ленточным интерфейсом.</p><p><a href="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx" rel="nofollow noopener" title="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx">http://blogs.msdn.com/b/b8/...</a> </p>]]>
|
||||
</message>
|
||||
<createdAt>2011-08-31T17:44:22Z</createdAt>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>false</isSpam>
|
||||
<author>
|
||||
<email>mihail.noname@gmail.com</email>
|
||||
<name>mikhail</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>mikhail-noname</username>
|
||||
</author>
|
||||
<ipAddress>195.195.195.139</ipAddress>
|
||||
<thread dsq:id="247937687"/>
|
||||
</post>
|
||||
|
||||
<post dsq:id="299986072">
|
||||
<id>6580890074280459209</id>
|
||||
<message>
|
||||
<![CDATA[<p>Google App Engine скоро выходит из превью статуса.</p><p>Сейчас письмо пришло от гугла.</p><p>Для платных приложений использущих High Replication Datastore (HRD) будет 99.95% uptime SLA.<br>Будут Премьер аккаунты за 500 баксов/месяц с оперативной поддержкой и любым количеством приложений на аккаунте (+ плата за потребленные ресурсы).<br>В связи с переходом на новую систему оплаты, обещают снизить бесплатные квоты.<br>Всем кто включит биллинг до 31 октября, обещают 50 баксов :)</p>]]>
|
||||
</message>
|
||||
<createdAt>2011-08-31T22:48:43Z</createdAt>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>false</isSpam>
|
||||
<author>
|
||||
<email>dmitri.noname@gmail.com</email>
|
||||
<name>Dmitry Noname</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>google-74b9e7568ef6860e93862c5d77590123</username>
|
||||
</author>
|
||||
<ipAddress>89.89.89.139</ipAddress>
|
||||
<thread dsq:id="247918464"/>
|
||||
</post>
|
||||
|
||||
<post>
|
||||
<id>12345678890</id>
|
||||
<message>This comment had no ID</message>
|
||||
<createdAt>2011-08-31T22:49:43Z</createdAt>
|
||||
<forum>radiot</forum>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>false</isSpam>
|
||||
<author>
|
||||
<email>blah.noname@gmail.com</email>
|
||||
<name>Blah Noname</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>74b9e7568ef6860e93862c5d77590123</username>
|
||||
</author>
|
||||
<ipAddress>189.89.89.139</ipAddress>
|
||||
<thread dsq:id="247918464"/>
|
||||
</post>
|
||||
|
||||
<post dsq:id="299986073">
|
||||
<id>6580890074280459219</id>
|
||||
<message>some ugly spam</message>
|
||||
<createdAt>2011-09-30T22:48:43Z</createdAt>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>true</isSpam>
|
||||
<author>
|
||||
<email>spam.noname@gmail.com</email>
|
||||
<name>Spam Noname</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>google-2c5d77590123</username>
|
||||
</author>
|
||||
<ipAddress>189.89.89.139</ipAddress>
|
||||
<thread dsq:id="247937687"/>
|
||||
</post>
|
||||
|
||||
<post dsq:id="x299986073">
|
||||
<message>some bad comment</message>
|
||||
<createdAt>2011-x09-30T22:48:43Z</createdAt>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>123</isSpam>
|
||||
<author>
|
||||
<email>noname@gmail.com</email>
|
||||
<name>Noname</name>
|
||||
<isAnonymous>true</isAnonymous>
|
||||
<username>google-2c5d77590123</username>
|
||||
</author>
|
||||
<ipAddress>189.89.89.39</ipAddress>
|
||||
<thread dsq:id=247937687/>
|
||||
</post>
|
||||
|
||||
</disqus>
|
||||
`
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UrlMapper implements Mapper interface
|
||||
type UrlMapper struct {
|
||||
// URLMapper implements Mapper interface
|
||||
type URLMapper struct {
|
||||
rules map[string]string
|
||||
}
|
||||
|
||||
// NewUrlMapper reads rules from given reader and returns initialised UrlMapper
|
||||
// NewURLMapper reads rules from given reader and returns initialized URLMapper
|
||||
// if given rules are valid.
|
||||
func NewUrlMapper(reader io.Reader) (Mapper, error) {
|
||||
u := &UrlMapper{}
|
||||
func NewURLMapper(reader io.Reader) (Mapper, error) {
|
||||
u := &URLMapper{}
|
||||
if err := u.loadRules(reader); err != nil {
|
||||
return u, err
|
||||
}
|
||||
@@ -29,8 +28,8 @@ func NewUrlMapper(reader io.Reader) (Mapper, error) {
|
||||
// Example:
|
||||
// https://www.myblog.com/blog/1/ https://myblog.com/blog/1/
|
||||
// https://www.myblog.com/* https://myblog.com/*
|
||||
func (u *UrlMapper) loadRules(reader io.Reader) error {
|
||||
data, err := ioutil.ReadAll(reader)
|
||||
func (u *URLMapper) loadRules(reader io.Reader) error {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -43,7 +42,7 @@ func (u *UrlMapper) loadRules(reader io.Reader) error {
|
||||
row = strings.TrimSpace(row)
|
||||
urls := strings.Split(row, " ")
|
||||
if len(urls) != 2 {
|
||||
return errors.New("bad row " + row)
|
||||
return fmt.Errorf("bad row %s", row)
|
||||
}
|
||||
|
||||
from, to := strings.TrimSpace(urls[0]), strings.TrimSpace(urls[1])
|
||||
@@ -54,19 +53,19 @@ func (u *UrlMapper) loadRules(reader io.Reader) error {
|
||||
|
||||
// URL maps given url to another url according loaded url-rules.
|
||||
// If not matched returns given url.
|
||||
func (u *UrlMapper) URL(url string) string {
|
||||
if newUrl, ok := u.rules[url]; ok {
|
||||
return newUrl
|
||||
func (u *URLMapper) URL(url string) string {
|
||||
if newURL, ok := u.rules[url]; ok {
|
||||
return newURL
|
||||
}
|
||||
// try to match by prefix
|
||||
for oldUrl, newUrl := range u.rules {
|
||||
if !strings.HasSuffix(oldUrl, "*") {
|
||||
for oldURL, newURL := range u.rules {
|
||||
if !strings.HasSuffix(oldURL, "*") {
|
||||
continue
|
||||
}
|
||||
oldUrl = strings.TrimSuffix(oldUrl, "*")
|
||||
newUrl = strings.TrimSuffix(newUrl, "*")
|
||||
if strings.HasPrefix(url, oldUrl) {
|
||||
return newUrl + strings.TrimPrefix(url, oldUrl)
|
||||
oldURL = strings.TrimSuffix(oldURL, "*")
|
||||
newURL = strings.TrimSuffix(newURL, "*")
|
||||
if strings.HasPrefix(url, oldURL) {
|
||||
return newURL + strings.TrimPrefix(url, oldURL)
|
||||
}
|
||||
}
|
||||
// search failed, return given url
|
||||
|
||||
@@ -16,7 +16,7 @@ https://radio-t.com/p/2018/09/22////podcast-616/ https://www.radio-t.com/p/2018/
|
||||
https://radio-t.com/p/2018/09/22/podcast-616/?with_query=1 https://www.radio-t.com/p/2018/09/22/podcast-616/
|
||||
`)
|
||||
|
||||
mapper, err := NewUrlMapper(rules)
|
||||
mapper, err := NewURLMapper(rules)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// if url not matched mapper should return given url
|
||||
@@ -30,7 +30,7 @@ https://radio-t.com/p/2018/09/22/podcast-616/?with_query=1 https://www.radio-t.c
|
||||
|
||||
// want remap from http to https
|
||||
rules = strings.NewReader(`http://anysite.com/p/123 https://anysite.com/p/321`)
|
||||
mapper, err = NewUrlMapper(rules)
|
||||
mapper, err = NewURLMapper(rules)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://anysite.com/p/321", mapper.URL("http://anysite.com/p/123"))
|
||||
assert.Equal(t, "https://notexist", mapper.URL("https://notexist"))
|
||||
@@ -38,7 +38,7 @@ https://radio-t.com/p/2018/09/22/podcast-616/?with_query=1 https://www.radio-t.c
|
||||
|
||||
// want remap from http to https by pattern
|
||||
rules = strings.NewReader(`http://anysite.com* https://anysite.com*`)
|
||||
mapper, err = NewUrlMapper(rules)
|
||||
mapper, err = NewURLMapper(rules)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https://anysite.com/p/1", mapper.URL("http://anysite.com/p/1"))
|
||||
assert.Equal(t, "https://anysite.com/", mapper.URL("http://anysite.com/"))
|
||||
@@ -80,7 +80,7 @@ func TestUrlMapper_New(t *testing.T) {
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
_, err := NewUrlMapper(strings.NewReader(c.rules))
|
||||
_, err := NewURLMapper(strings.NewReader(c.rules))
|
||||
if c.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark/backend/app/store/service"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store/service"
|
||||
)
|
||||
|
||||
// Importer defines interface to convert posts from external sources
|
||||
@@ -38,7 +38,7 @@ type MapperMaker func(reader io.Reader) (Mapper, error)
|
||||
type Store interface {
|
||||
Create(comment store.Comment) (commentID string, err error)
|
||||
Find(locator store.Locator, sort string, user store.User) ([]store.Comment, error)
|
||||
List(siteID string, limit int, skip int) ([]store.PostInfo, error)
|
||||
List(siteID string, limit, skip int) ([]store.PostInfo, error)
|
||||
DeleteAll(siteID string) error
|
||||
Metas(siteID string) (umetas []service.UserMetaData, pmetas []service.PostMetaData, err error)
|
||||
SetMetas(siteID string, umetas []service.UserMetaData, pmetas []service.PostMetaData) error
|
||||
@@ -64,18 +64,20 @@ func ImportComments(p ImportParams) (int, error) {
|
||||
importer = &Disqus{DataStore: p.DataStore}
|
||||
case "wordpress":
|
||||
importer = &WordPress{DataStore: p.DataStore}
|
||||
case "commento":
|
||||
importer = &Commento{DataStore: p.DataStore}
|
||||
case "native":
|
||||
importer = &Native{DataStore: p.DataStore}
|
||||
default:
|
||||
return 0, errors.Errorf("unsupported import provider %s", p.Provider)
|
||||
return 0, fmt.Errorf("unsupported import provider %s", p.Provider)
|
||||
}
|
||||
|
||||
fh, err := os.Open(p.InputFile)
|
||||
if err != nil {
|
||||
return 0, errors.Wrapf(err, "can't open import file %s", p.InputFile)
|
||||
return 0, fmt.Errorf("can't open import file %s: %w", p.InputFile, err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
defer func() { //nolint:gosec // false positive on defer without error check when it's checked here
|
||||
if err = fh.Close(); err != nil {
|
||||
log.Printf("[WARN] can't close %s, %s", p.InputFile, err)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
bolt "github.com/coreos/bbolt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark/backend/app/store/admin"
|
||||
"github.com/umputun/remark/backend/app/store/engine"
|
||||
"github.com/umputun/remark/backend/app/store/service"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store/admin"
|
||||
"github.com/umputun/remark42/backend/app/store/engine"
|
||||
"github.com/umputun/remark42/backend/app/store/service"
|
||||
)
|
||||
|
||||
func TestMigrator_ImportDisqus(t *testing.T) {
|
||||
defer func() {
|
||||
os.Remove("/tmp/remark-test.db")
|
||||
os.Remove("/tmp/disqus-test.xml")
|
||||
}()
|
||||
|
||||
err := ioutil.WriteFile("/tmp/disqus-test.xml", []byte(xmlTestDisqus), 0600)
|
||||
require.NoError(t, err)
|
||||
defer os.Remove("/tmp/remark-test.db")
|
||||
|
||||
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
|
||||
require.NoError(t, err, "create store")
|
||||
@@ -31,7 +24,7 @@ func TestMigrator_ImportDisqus(t *testing.T) {
|
||||
defer dataStore.Close()
|
||||
size, err := ImportComments(ImportParams{
|
||||
DataStore: dataStore,
|
||||
InputFile: "/tmp/disqus-test.xml",
|
||||
InputFile: "testdata/disqus.xml",
|
||||
SiteID: "test",
|
||||
Provider: "disqus",
|
||||
})
|
||||
@@ -49,7 +42,7 @@ func TestMigrator_ImportWordPress(t *testing.T) {
|
||||
os.Remove("/tmp/wordpress-test.xml")
|
||||
}()
|
||||
|
||||
err := ioutil.WriteFile("/tmp/wordpress-test.xml", []byte(xmlTestWP), 0600)
|
||||
err := os.WriteFile("/tmp/wordpress-test.xml", []byte(xmlTestWP), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
|
||||
@@ -70,6 +63,27 @@ func TestMigrator_ImportWordPress(t *testing.T) {
|
||||
assert.Equal(t, 3, len(last), "3 comments imported")
|
||||
}
|
||||
|
||||
func TestMigrator_ImportCommento(t *testing.T) {
|
||||
defer os.Remove("/tmp/remark-test.db")
|
||||
|
||||
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "test"})
|
||||
require.NoError(t, err, "create store")
|
||||
dataStore := &service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
|
||||
defer dataStore.Close()
|
||||
size, err := ImportComments(ImportParams{
|
||||
DataStore: dataStore,
|
||||
InputFile: "testdata/commento.json",
|
||||
SiteID: "test",
|
||||
Provider: "commento",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, size)
|
||||
|
||||
last, err := dataStore.Last("test", 10, time.Time{}, store.User{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, len(last), "2 comments imported")
|
||||
}
|
||||
|
||||
func TestMigrator_ImportNative(t *testing.T) {
|
||||
defer func() {
|
||||
os.Remove("/tmp/remark-test.db")
|
||||
@@ -79,7 +93,7 @@ func TestMigrator_ImportNative(t *testing.T) {
|
||||
data := `{"version":1} {"id":"efbc17f177ee1a1c0ee6e1e025749966ec071adc","pid":"","text":"some text, <a href=\"http://radio-t.com\" rel=\"nofollow\">link</a>","user":{"name":"user name","id":"user1","picture":"","profile":"","admin":false},"locator":{"site":"radio-t","url":"https://radio-t.com"},"score":0,"votes":{},"time":"2017-12-20T15:18:22-06:00"}` + "\n" +
|
||||
`{"id":"afbc17f177ee1a1c0ee6e1e025749966ec071adc","pid":"efbc17f177ee1a1c0ee6e1e025749966ec071adc","text":"some text2, <a href=\"http://radio-t.com\" rel=\"nofollow\">link</a>","user":{"name":"user name","id":"user1","picture":"","profile":"","admin":false},"locator":{"site":"radio-t","url":"https://radio-t.com"},"score":0,"votes":{},"time":"2017-12-20T15:18:23-06:00"}` + "\n"
|
||||
|
||||
err := ioutil.WriteFile("/tmp/disqus-test.r42", []byte(data), 0600)
|
||||
err := os.WriteFile("/tmp/disqus-test.r42", []byte(data), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: "/tmp/remark-test.db", SiteID: "radio-t"})
|
||||
|
||||
@@ -4,15 +4,15 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync/atomic"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/go-pkgz/syncs"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark/backend/app/store/service"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store/service"
|
||||
)
|
||||
|
||||
const nativeVersion = 1
|
||||
@@ -35,9 +35,8 @@ type meta struct {
|
||||
// Export all comments to writer as json strings. Each comment is one string, separated by "\n"
|
||||
// The final file is a valid json
|
||||
func (n *Native) Export(w io.Writer, siteID string) (size int, err error) {
|
||||
|
||||
if err = n.exportMeta(siteID, w); err != nil {
|
||||
return 0, errors.Wrapf(err, "failed to export meta for site %s", siteID)
|
||||
return 0, fmt.Errorf("failed to export meta for site %s: %w", siteID, err)
|
||||
}
|
||||
|
||||
topics, err := n.DataStore.List(siteID, 0, 0)
|
||||
@@ -55,16 +54,15 @@ func (n *Native) Export(w io.Writer, siteID string) (size int, err error) {
|
||||
}
|
||||
|
||||
for _, comment := range comments {
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
enc := json.NewEncoder(buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
|
||||
if err = enc.Encode(comment); err != nil {
|
||||
return commentsCount, errors.Wrapf(err, "can't marshal %v", comments)
|
||||
return commentsCount, fmt.Errorf("can't marshal %v: %w", comments, err)
|
||||
}
|
||||
if _, err = w.Write(buf.Bytes()); err != nil {
|
||||
return commentsCount, errors.Wrap(err, "can't write comment data")
|
||||
return commentsCount, fmt.Errorf("can't write comment data: %w", err)
|
||||
}
|
||||
commentsCount++
|
||||
}
|
||||
@@ -78,11 +76,11 @@ func (n *Native) exportMeta(siteID string, w io.Writer) (err error) {
|
||||
m := meta{Version: nativeVersion}
|
||||
m.Users, m.Posts, err = n.DataStore.Metas(siteID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "can't get meta")
|
||||
return fmt.Errorf("can't get meta: %w", err)
|
||||
}
|
||||
|
||||
if err = json.NewEncoder(w).Encode(m); err != nil {
|
||||
return errors.Wrap(err, "can't encode meta")
|
||||
return fmt.Errorf("can't encode meta: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -133,15 +131,15 @@ func (n *Native) Import(reader io.Reader, siteID string) (size int, err error) {
|
||||
m := meta{}
|
||||
dec := json.NewDecoder(reader)
|
||||
if err = dec.Decode(&m); err != nil {
|
||||
return 0, errors.Wrapf(err, "failed to import meta for site %s", siteID)
|
||||
return 0, fmt.Errorf("failed to import meta for site %s: %w", siteID, err)
|
||||
}
|
||||
|
||||
if m.Version != nativeVersion && m.Version != 0 { // this version allows back compatibility with 0 version
|
||||
return 0, errors.Errorf("unexpected import file version %d", m.Version)
|
||||
return 0, fmt.Errorf("unexpected import file version %d", m.Version)
|
||||
}
|
||||
|
||||
if err = n.DataStore.DeleteAll(siteID); err != nil {
|
||||
return 0, err
|
||||
if e := n.DataStore.DeleteAll(siteID); e != nil {
|
||||
return 0, e
|
||||
}
|
||||
|
||||
var failed, total, comments int64
|
||||
@@ -155,6 +153,7 @@ func (n *Native) Import(reader io.Reader, siteID string) (size int, err error) {
|
||||
for {
|
||||
comment := store.Comment{}
|
||||
err = dec.Decode(&comment)
|
||||
comment.Imported = true
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
@@ -179,13 +178,12 @@ func (n *Native) Import(reader io.Reader, siteID string) (size int, err error) {
|
||||
log.Printf("[DEBUG] imported %d comments", num)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
grp.Wait()
|
||||
|
||||
if failed > 0 {
|
||||
return int(comments), errors.Errorf("failed to save %d comments", failed)
|
||||
return int(comments), fmt.Errorf("failed to save %d comments", failed)
|
||||
}
|
||||
log.Printf("[INFO] imported %d comments from %d records", comments, total)
|
||||
|
||||
|
||||
@@ -10,15 +10,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
bolt "github.com/coreos/bbolt"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark/backend/app/store/admin"
|
||||
"github.com/umputun/remark/backend/app/store/engine"
|
||||
"github.com/umputun/remark/backend/app/store/service"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store/admin"
|
||||
"github.com/umputun/remark42/backend/app/store/engine"
|
||||
"github.com/umputun/remark42/backend/app/store/service"
|
||||
)
|
||||
|
||||
func TestNative_Export(t *testing.T) {
|
||||
@@ -35,7 +34,7 @@ func TestNative_Export(t *testing.T) {
|
||||
assert.Equal(t, 2, size)
|
||||
|
||||
c1 := buf.String()
|
||||
log.Print(c1)
|
||||
t.Log(c1)
|
||||
|
||||
dec := json.NewDecoder(strings.NewReader(c1))
|
||||
|
||||
@@ -74,7 +73,7 @@ func TestNative_Import(t *testing.T) {
|
||||
|
||||
inp := `{"version":1,"users":[{"id":"user1","blocked":{"status":false,"until":"0001-01-01T00:00:00Z"},"verified":true},{"id":"user2","blocked":{"status":true,"until":"2018-12-23T02:55:22.472041-06:00"},"verified":false}],"posts":[{"url":"https://radio-t.com","read_only":true}]}
|
||||
{"id":"efbc17f177ee1a1c0ee6e1e025749966ec071adc","pid":"","text":"some text, <a href=\"http://radio-t.com\" rel=\"nofollow\">link</a>","user":{"name":"user name","id":"user1","picture":"","ip":"293ec5b0cf154855258824ec7fac5dc63d176915","admin":false},"locator":{"site":"radio-t","url":"https://radio-t.com"},"score":0,"votes":{},"time":"2017-12-20T15:18:22-06:00"}
|
||||
{"id":"f863bd79-fec6-4a75-b308-61fe5dd02aa1","pid":"1234","text":"some text2","user":{"name":"user name","id":"user2","picture":"","ip":"293ec5b0cf154855258824ec7fac5dc63d176915","admin":false},"locator":{"site":"radio-t","url":"https://radio-t.com/2"},"score":0,"votes":{},"time":"2017-12-20T15:18:23-06:00"}`
|
||||
{"id":"f863bd79-fec6-4a75-b308-61fe5dd02aa1","pid":"1234","text":"some text2","user":{"name":"user name","id":"user2","picture":"","ip":"293ec5b0cf154855258824ec7fac5dc63d176915","admin":false},"locator":{"site":"radio-t","url":"https://radio-t.com/2"},"score":0,"votes":{},"time":"2017-12-20T15:18:23-06:00","imported":false}`
|
||||
|
||||
b.AdminStore = admin.NewStaticStore("12345", nil, []string{}, "")
|
||||
r := Native{DataStore: b}
|
||||
@@ -88,10 +87,12 @@ func TestNative_Import(t *testing.T) {
|
||||
assert.Equal(t, "f863bd79-fec6-4a75-b308-61fe5dd02aa1", comments[0].ID)
|
||||
assert.Equal(t, "1234", comments[0].ParentID)
|
||||
assert.Equal(t, false, b.IsReadOnly(comments[0].Locator))
|
||||
assert.True(t, comments[0].Imported)
|
||||
|
||||
assert.Equal(t, "efbc17f177ee1a1c0ee6e1e025749966ec071adc", comments[1].ID)
|
||||
assert.Equal(t, "https://radio-t.com", comments[1].Locator.URL)
|
||||
assert.Equal(t, true, b.IsReadOnly(comments[1].Locator))
|
||||
assert.True(t, comments[1].Imported)
|
||||
|
||||
assert.Equal(t, false, b.IsBlocked("radio-t", "user1"))
|
||||
assert.Equal(t, true, b.IsVerified("radio-t", "user1"))
|
||||
@@ -106,7 +107,7 @@ func TestNative_ImportWithMapper(t *testing.T) {
|
||||
|
||||
// want to remap comments to https://rdt.c
|
||||
rules := `https://radio-t.com* https://rdt.c*`
|
||||
mapper, err := NewUrlMapper(strings.NewReader(rules))
|
||||
mapper, err := NewURLMapper(strings.NewReader(rules))
|
||||
assert.NoError(t, err)
|
||||
|
||||
inp := `{"version":1,"users":[{"id":"user1","blocked":{"status":false,"until":"0001-01-01T00:00:00Z"},"verified":true},{"id":"user2","blocked":{"status":true,"until":"2018-12-23T02:55:22.472041-06:00"},"verified":false}],"posts":[{"url":"https://radio-t.com","read_only":true}]}
|
||||
@@ -152,7 +153,6 @@ func TestNative_ImportWrongVersion(t *testing.T) {
|
||||
size, err := r.Import(strings.NewReader(inp), "radio-t")
|
||||
assert.EqualError(t, err, "unexpected import file version 2")
|
||||
assert.Equal(t, 0, size)
|
||||
|
||||
}
|
||||
func TestNative_ImportManyWithError(t *testing.T) {
|
||||
b, teardown := prep(t) // write 2 comments
|
||||
@@ -163,7 +163,7 @@ func TestNative_ImportManyWithError(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
buf.WriteString(`{"version":1, "users":[], "posts":[]}` + "\n")
|
||||
for i := 0; i < 100; i++ {
|
||||
buf.WriteString(fmt.Sprintf(goodRec, i))
|
||||
fmt.Fprintf(buf, goodRec, i)
|
||||
}
|
||||
buf.WriteString("{}\n")
|
||||
buf.WriteString("{}\n")
|
||||
@@ -179,11 +179,10 @@ func TestNative_ImportManyWithError(t *testing.T) {
|
||||
}
|
||||
|
||||
// makes new boltdb, put two records
|
||||
func prep(t *testing.T) (*service.DataStore, func()) {
|
||||
func prep(t *testing.T) (ds *service.DataStore, teardown func()) {
|
||||
testDB := fmt.Sprintf("/tmp/migrator-%d.db", rand.Intn(999999999))
|
||||
|
||||
testDb := fmt.Sprintf("/tmp/migrator-%d.db", rand.Intn(999999999))
|
||||
|
||||
boltStore, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{SiteID: "radio-t", FileName: testDb})
|
||||
boltStore, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{SiteID: "radio-t", FileName: testDB})
|
||||
assert.NoError(t, err)
|
||||
|
||||
b := &service.DataStore{Engine: boltStore, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
|
||||
@@ -208,6 +207,6 @@ func prep(t *testing.T) (*service.DataStore, func()) {
|
||||
|
||||
return b, func() {
|
||||
require.NoError(t, b.Close())
|
||||
_ = os.Remove(testDb)
|
||||
_ = os.Remove(testDB)
|
||||
}
|
||||
}
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"version": 1,
|
||||
"comments": [
|
||||
{
|
||||
"commentHex": "e7069a7dfcfaed43caf62300a9b0edb1c124ad79d0f5887c93649c15d7f69945",
|
||||
"domain": "example.com",
|
||||
"url": "https://example.com/blog/post/1",
|
||||
"commenterHex": "anonymous",
|
||||
"markdown": "Example comment created by user.",
|
||||
"html": "",
|
||||
"parentHex": "root",
|
||||
"score": 1,
|
||||
"state": "approved",
|
||||
"creationDate": "2021-03-12T11:21:56Z",
|
||||
"direction": 0,
|
||||
"deleted": false
|
||||
},
|
||||
{
|
||||
"commentHex": "7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854",
|
||||
"domain": "example.com",
|
||||
"url": "https://example.com/blog/post/1",
|
||||
"commenterHex": "a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
|
||||
"markdown": "Example 2 comment created by user.",
|
||||
"html": "",
|
||||
"parentHex": "root",
|
||||
"score": 0,
|
||||
"state": "approved",
|
||||
"creationDate": "2021-03-17T12:09:47.722181Z",
|
||||
"direction": 0,
|
||||
"deleted": false
|
||||
},
|
||||
{
|
||||
"commentHex": "ea5f7bcd6ac9bb7b657f7d0569831104e1bcf9c253d03c1e16bf9654c49a5ce9",
|
||||
"domain": "example.com",
|
||||
"url": "https://example.com/blog/post/1",
|
||||
"commenterHex": "bd1290ab5c858cf2a05903c2a9a61fd63399c6635db38cc6597002195e22e061",
|
||||
"markdown": "Great reply!",
|
||||
"html": "",
|
||||
"parentHex": "7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854",
|
||||
"score": 0,
|
||||
"state": "approved",
|
||||
"creationDate": "2021-05-11T15:43:01.852651Z",
|
||||
"direction": 0,
|
||||
"deleted": false
|
||||
}
|
||||
],
|
||||
"commenters": [
|
||||
{
|
||||
"commenterHex": "a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
|
||||
"email": "somegreatmail@gmail.com",
|
||||
"name": "User5276",
|
||||
"link": "https://example.com/profile/257",
|
||||
"photo": "https://secure.gravatar.com/avatar/8f279626d26175134b0d5c88648172f7",
|
||||
"provider": "sso:example.com",
|
||||
"joinDate": "2021-03-19T19:27:25.954285Z",
|
||||
"isModerator": false
|
||||
},
|
||||
{
|
||||
"commenterHex": "bd1290ab5c858cf2a05903c2a9a61fd63399c6635db38cc6597002195e22e061",
|
||||
"email": "moregreatmail@gmail.com",
|
||||
"name": "Saturnin Uf",
|
||||
"link": "https://example.com/profile/259",
|
||||
"photo": "https://secure.gravatar.com/avatar/6481228d190f0286a42bee9041f9b1a1",
|
||||
"provider": "sso:example.com",
|
||||
"joinDate": "2021-03-21T12:15:37.536035Z",
|
||||
"isModerator": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
`<?xml version="1.0" encoding="utf-8"?>
|
||||
<disqus xmlns="http://disqus.com" xmlns:dsq="http://disqus.com/disqus-internals" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://disqus.com/api/schemas/1.0/disqus.xsd http://disqus.com/api/schemas/1.0/disqus-internals.xsd">
|
||||
|
||||
<category dsq:id="707279">
|
||||
<forum>radiot</forum>
|
||||
<title>General</title>
|
||||
<isDefault>true</isDefault>
|
||||
</category>
|
||||
|
||||
<thread dsq:id="247918464">
|
||||
<id/>
|
||||
<forum>radiot</forum>
|
||||
<category dsq:id="707279"/>
|
||||
<link>http://radio-t.umputun.com/2011/03/229_8880.html</link>
|
||||
<title>Радио-Т: Радио-Т 229</title>
|
||||
<message/>
|
||||
<createdAt>2011-03-07T20:46:25Z</createdAt>
|
||||
<author>
|
||||
<email>umputun@gmail.com</email>
|
||||
<name>Umputun</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>umputun</username>
|
||||
</author>
|
||||
<ipAddress>98.212.28.115</ipAddress>
|
||||
<isClosed>false</isClosed>
|
||||
<isDeleted>false</isDeleted>
|
||||
</thread>
|
||||
|
||||
<thread dsq:id="247937687">
|
||||
<id>http://www.radio-t.com/p/2011/03/05/podcast-229/</id>
|
||||
<forum>radiot</forum>
|
||||
<category dsq:id="707279"/>
|
||||
<link>https://radio-t.com/p/2011/03/05/podcast-229/</link>
|
||||
<title>Радио-Т: Радио-Т 229</title>
|
||||
<message/>
|
||||
<createdAt>2011-03-07T21:17:17Z</createdAt>
|
||||
<author>
|
||||
<email>umputun@gmail.com</email>
|
||||
<name>Umputun</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>umputun</username>
|
||||
</author>
|
||||
<ipAddress>80.250.214.235</ipAddress>
|
||||
<isClosed>true</isClosed>
|
||||
<isDeleted>false</isDeleted>
|
||||
</thread>
|
||||
|
||||
|
||||
<post dsq:id="299619020">
|
||||
<id>3565798471341011339</id>
|
||||
<message>
|
||||
<![CDATA[<p>The quick brown fox jumps over the lazy dog.</p><p><a href="https://https://radio-t.com" rel="nofollow noopener" title="radio-t">some link</a></p>]]>
|
||||
</message>
|
||||
<createdAt>2011-08-31T15:16:29Z</createdAt>
|
||||
<isDeleted>true</isDeleted>
|
||||
<isSpam>false</isSpam>
|
||||
<author>
|
||||
<email/>
|
||||
<name>Alexander Blah</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>facebook-1787732238</username>
|
||||
</author>
|
||||
<ipAddress>178.178.178.178</ipAddress>
|
||||
<thread dsq:id="247937687"/>
|
||||
</post>
|
||||
|
||||
<post dsq:id="299744309">
|
||||
<id>3029154520436241933</id>
|
||||
<message>
|
||||
<![CDATA[<p>Microsoft показал проводник Windows 8 с ленточным интерфейсом.</p><p><a href="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx" rel="nofollow noopener" title="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx">http://blogs.msdn.com/b/b8/...</a> </p>]]>
|
||||
</message>
|
||||
<createdAt>2011-08-31T17:44:22Z</createdAt>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>false</isSpam>
|
||||
<author>
|
||||
<email>mihail.noname@gmail.com</email>
|
||||
<name>mikhail</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>mikhail-noname</username>
|
||||
</author>
|
||||
<ipAddress>195.195.195.139</ipAddress>
|
||||
<thread dsq:id="247937687"/>
|
||||
</post>
|
||||
|
||||
<post dsq:id="299986072">
|
||||
<id>6580890074280459209</id>
|
||||
<message>
|
||||
<![CDATA[<p>Google App Engine скоро выходит из превью статуса.</p><p>Сейчас письмо пришло от гугла.</p><p>Для платных приложений использущих High Replication Datastore (HRD) будет 99.95% uptime SLA.<br>Будут Премьер аккаунты за 500 баксов/месяц с оперативной поддержкой и любым количеством приложений на аккаунте (+ плата за потребленные ресурсы).<br>В связи с переходом на новую систему оплаты, обещают снизить бесплатные квоты.<br>Всем кто включит биллинг до 31 октября, обещают 50 баксов :)</p>]]>
|
||||
</message>
|
||||
<createdAt>2011-08-31T22:48:43Z</createdAt>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>false</isSpam>
|
||||
<author>
|
||||
<email>john.nousername@gmail.com</email>
|
||||
<name>No Username</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
</author>
|
||||
<ipAddress>89.89.89.139</ipAddress>
|
||||
<thread dsq:id="247918464"/>
|
||||
</post>
|
||||
|
||||
<post>
|
||||
<id>12345678890</id>
|
||||
<message>This comment had no ID</message>
|
||||
<createdAt>2011-08-31T22:49:43Z</createdAt>
|
||||
<forum>radiot</forum>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>false</isSpam>
|
||||
<author>
|
||||
<email>blah.noname@gmail.com</email>
|
||||
<name>Blah Noname</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>74b9e7568ef6860e93862c5d77590123</username>
|
||||
</author>
|
||||
<ipAddress>189.89.89.139</ipAddress>
|
||||
<thread dsq:id="247918464"/>
|
||||
</post>
|
||||
|
||||
<post dsq:id="299986073">
|
||||
<id>6580890074280459219</id>
|
||||
<message>some ugly spam</message>
|
||||
<createdAt>2011-09-30T22:48:43Z</createdAt>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>true</isSpam>
|
||||
<author>
|
||||
<email>spam.noname@gmail.com</email>
|
||||
<name>Spam Noname</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>google-2c5d77590123</username>
|
||||
</author>
|
||||
<ipAddress>189.89.89.139</ipAddress>
|
||||
<thread dsq:id="247937687"/>
|
||||
</post>
|
||||
|
||||
<post dsq:id="x299986073">
|
||||
<message>some bad comment</message>
|
||||
<createdAt>2011-x09-30T22:48:43Z</createdAt>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>123</isSpam>
|
||||
<author>
|
||||
<email>noname@gmail.com</email>
|
||||
<name>Noname</name>
|
||||
<isAnonymous>true</isAnonymous>
|
||||
<username>google-2c5d77590123</username>
|
||||
</author>
|
||||
<ipAddress>189.89.89.39</ipAddress>
|
||||
<thread dsq:id=247937687/>
|
||||
</post>
|
||||
|
||||
</disqus>
|
||||
@@ -0,0 +1,150 @@
|
||||
`<?xml version="1.0" encoding="utf-8"?>
|
||||
<disqus xmlns="http://disqus.com" xmlns:dsq="http://disqus.com/disqus-internals" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://disqus.com/api/schemas/1.0/disqus.xsd http://disqus.com/api/schemas/1.0/disqus-internals.xsd">
|
||||
|
||||
<category dsq:id="707279">
|
||||
<forum>radiot</forum>
|
||||
<title>General</title>
|
||||
<isDefault>true</isDefault>
|
||||
</category>
|
||||
|
||||
<thread dsq:id="247918464">
|
||||
<id/>
|
||||
<forum>radiot</forum>
|
||||
<category dsq:id="707279"/>
|
||||
<link>http://radio-t.umputun.com/2011/03/229_8880.html</link>
|
||||
<title>Радио-Т: Радио-Т 229</title>
|
||||
<message/>
|
||||
<createdAt>2011-03-07T20:46:25Z</createdAt>
|
||||
<author>
|
||||
<email>umputun@gmail.com</email>
|
||||
<name>Umputun</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>umputun</username>
|
||||
</author>
|
||||
<ipAddress>98.212.28.115</ipAddress>
|
||||
<isClosed>false</isClosed>
|
||||
<isDeleted>false</isDeleted>
|
||||
</thread>
|
||||
|
||||
<thread dsq:id="247937687">
|
||||
<id>http://www.radio-t.com/p/2011/03/05/podcast-229/</id>
|
||||
<forum>radiot</forum>
|
||||
<category dsq:id="707279"/>
|
||||
<link>https://radio-t.com/p/2011/03/05/podcast-229/</link>
|
||||
<title>Радио-Т: Радио-Т 229</title>
|
||||
<message/>
|
||||
<createdAt>2011-03-07T21:17:17Z</createdAt>
|
||||
<author>
|
||||
<email>umputun@gmail.com</email>
|
||||
<name>Umputun</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>umputun</username>
|
||||
</author>
|
||||
<ipAddress>80.250.214.235</ipAddress>
|
||||
<isClosed>true</isClosed>
|
||||
<isDeleted>true</isDeleted>
|
||||
</thread>
|
||||
|
||||
|
||||
<post dsq:id="299619020">
|
||||
<id>3565798471341011339</id>
|
||||
<message>
|
||||
<![CDATA[<p>The quick brown fox jumps over the lazy dog.</p><p><a href="https://https://radio-t.com" rel="nofollow noopener" title="radio-t">some link</a></p>]]>
|
||||
</message>
|
||||
<createdAt>2011-08-31T15:16:29Z</createdAt>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>false</isSpam>
|
||||
<author>
|
||||
<email/>
|
||||
<name>Alexander Blah</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>facebook-1787732238</username>
|
||||
</author>
|
||||
<ipAddress>178.178.178.178</ipAddress>
|
||||
<thread dsq:id="247937687"/>
|
||||
</post>
|
||||
|
||||
<post dsq:id="299744309">
|
||||
<id>3029154520436241933</id>
|
||||
<message>
|
||||
<![CDATA[<p>Microsoft показал проводник Windows 8 с ленточным интерфейсом.</p><p><a href="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx" rel="nofollow noopener" title="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx">http://blogs.msdn.com/b/b8/...</a> </p>]]>
|
||||
</message>
|
||||
<createdAt>2011-08-31T17:44:22Z</createdAt>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>false</isSpam>
|
||||
<author>
|
||||
<email>mihail.noname@gmail.com</email>
|
||||
<name>mikhail</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>mikhail-noname</username>
|
||||
</author>
|
||||
<ipAddress>195.195.195.139</ipAddress>
|
||||
<thread dsq:id="247937687"/>
|
||||
</post>
|
||||
|
||||
<post dsq:id="299986072">
|
||||
<id>6580890074280459209</id>
|
||||
<message>
|
||||
<![CDATA[<p>Google App Engine скоро выходит из превью статуса.</p><p>Сейчас письмо пришло от гугла.</p><p>Для платных приложений использущих High Replication Datastore (HRD) будет 99.95% uptime SLA.<br>Будут Премьер аккаунты за 500 баксов/месяц с оперативной поддержкой и любым количеством приложений на аккаунте (+ плата за потребленные ресурсы).<br>В связи с переходом на новую систему оплаты, обещают снизить бесплатные квоты.<br>Всем кто включит биллинг до 31 октября, обещают 50 баксов :)</p>]]>
|
||||
</message>
|
||||
<createdAt>2011-08-31T22:48:43Z</createdAt>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>false</isSpam>
|
||||
<author>
|
||||
<email>john.nousername@gmail.com</email>
|
||||
<name>No Username</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
</author>
|
||||
<ipAddress>89.89.89.139</ipAddress>
|
||||
<thread dsq:id="247918464"/>
|
||||
</post>
|
||||
|
||||
<post>
|
||||
<id>12345678890</id>
|
||||
<message>This comment had no ID</message>
|
||||
<createdAt>2011-08-31T22:49:43Z</createdAt>
|
||||
<forum>radiot</forum>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>false</isSpam>
|
||||
<author>
|
||||
<email>blah.noname@gmail.com</email>
|
||||
<name>Blah Noname</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>74b9e7568ef6860e93862c5d77590123</username>
|
||||
</author>
|
||||
<ipAddress>189.89.89.139</ipAddress>
|
||||
<thread dsq:id="247918464"/>
|
||||
</post>
|
||||
|
||||
<post dsq:id="299986073">
|
||||
<id>6580890074280459219</id>
|
||||
<message>some ugly spam</message>
|
||||
<createdAt>2011-09-30T22:48:43Z</createdAt>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>true</isSpam>
|
||||
<author>
|
||||
<email>spam.noname@gmail.com</email>
|
||||
<name>Spam Noname</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>google-2c5d77590123</username>
|
||||
</author>
|
||||
<ipAddress>189.89.89.139</ipAddress>
|
||||
<thread dsq:id="247937687"/>
|
||||
</post>
|
||||
|
||||
<post dsq:id="x299986073">
|
||||
<message>some bad comment</message>
|
||||
<createdAt>2011-x09-30T22:48:43Z</createdAt>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>123</isSpam>
|
||||
<author>
|
||||
<email>noname@gmail.com</email>
|
||||
<name>Noname</name>
|
||||
<isAnonymous>true</isAnonymous>
|
||||
<username>google-2c5d77590123</username>
|
||||
</author>
|
||||
<ipAddress>189.89.89.39</ipAddress>
|
||||
<thread dsq:id=247937687/>
|
||||
</post>
|
||||
|
||||
</disqus>
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
`<?xml version="1.0" encoding="utf-8"?>
|
||||
<disqus xmlns="http://disqus.com" xmlns:dsq="http://disqus.com/disqus-internals" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://disqus.com/api/schemas/1.0/disqus.xsd http://disqus.com/api/schemas/1.0/disqus-internals.xsd">
|
||||
|
||||
<category dsq:id="707279">
|
||||
<forum>radiot</forum>
|
||||
<title>General</title>
|
||||
<isDefault>true</isDefault>
|
||||
</category>
|
||||
|
||||
<thread dsq:id="247918464">
|
||||
<id/>
|
||||
<forum>radiot</forum>
|
||||
<category dsq:id="707279"/>
|
||||
<link>http://radio-t.umputun.com/2011/03/229_8880.html</link>
|
||||
<title>Радио-Т: Радио-Т 229</title>
|
||||
<message/>
|
||||
<createdAt>2011-03-07T20:46:25Z</createdAt>
|
||||
<author>
|
||||
<email>umputun@gmail.com</email>
|
||||
<name>Umputun</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>umputun</username>
|
||||
</author>
|
||||
<ipAddress>98.212.28.115</ipAddress>
|
||||
<isClosed>false</isClosed>
|
||||
<isDeleted>false</isDeleted>
|
||||
</thread>
|
||||
|
||||
<thread dsq:id="247937687">
|
||||
<id>http://www.radio-t.com/p/2011/03/05/podcast-229/</id>
|
||||
<forum>radiot</forum>
|
||||
<category dsq:id="707279"/>
|
||||
<link>https://radio-t.com/p/2011/03/05/podcast-229/</link>
|
||||
<title>Радио-Т: Радио-Т 229</title>
|
||||
<message/>
|
||||
<createdAt>2011-03-07T21:17:17Z</createdAt>
|
||||
<author>
|
||||
<email>umputun@gmail.com</email>
|
||||
<name>Umputun</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>umputun</username>
|
||||
</author>
|
||||
<ipAddress>80.250.214.235</ipAddress>
|
||||
<isClosed>true</isClosed>
|
||||
<isDeleted>false</isDeleted>
|
||||
</thread>
|
||||
|
||||
|
||||
<post dsq:id="299619020">
|
||||
<id>3565798471341011339</id>
|
||||
<message>
|
||||
<![CDATA[<p>The quick brown fox jumps over the lazy dog.</p><p><a href="https://https://radio-t.com" rel="nofollow noopener" title="radio-t">some link</a></p>]]>
|
||||
</message>
|
||||
<createdAt>2011-08-31T15:16:29Z</createdAt>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>false</isSpam>
|
||||
<author>
|
||||
<email/>
|
||||
<name>Alexander Blah</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>facebook-1787732238</username>
|
||||
</author>
|
||||
<ipAddress>178.178.178.178</ipAddress>
|
||||
<thread dsq:id="247937687"/>
|
||||
</post>
|
||||
|
||||
<post dsq:id="299744309">
|
||||
<id>3029154520436241933</id>
|
||||
<message>
|
||||
<![CDATA[<p>Microsoft показал проводник Windows 8 с ленточным интерфейсом.</p><p><a href="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx" rel="nofollow noopener" title="http://blogs.msdn.com/b/b8/archive/2011/08/29/improvements-in-windows-explorer.aspx">http://blogs.msdn.com/b/b8/...</a> </p>]]>
|
||||
</message>
|
||||
<createdAt>2011-08-31T17:44:22Z</createdAt>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>false</isSpam>
|
||||
<author>
|
||||
<email>mihail.noname@gmail.com</email>
|
||||
<name>mikhail</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>mikhail-noname</username>
|
||||
</author>
|
||||
<ipAddress>195.195.195.139</ipAddress>
|
||||
<thread dsq:id="247937687"/>
|
||||
</post>
|
||||
|
||||
<post dsq:id="299986072">
|
||||
<id>6580890074280459209</id>
|
||||
<message>
|
||||
<![CDATA[<p>Google App Engine скоро выходит из превью статуса.</p><p>Сейчас письмо пришло от гугла.</p><p>Для платных приложений использущих High Replication Datastore (HRD) будет 99.95% uptime SLA.<br>Будут Премьер аккаунты за 500 баксов/месяц с оперативной поддержкой и любым количеством приложений на аккаунте (+ плата за потребленные ресурсы).<br>В связи с переходом на новую систему оплаты, обещают снизить бесплатные квоты.<br>Всем кто включит биллинг до 31 октября, обещают 50 баксов :)</p>]]>
|
||||
</message>
|
||||
<createdAt>2011-08-31T22:48:43Z</createdAt>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>false</isSpam>
|
||||
<author>
|
||||
<email>john.nousername@gmail.com</email>
|
||||
<name>No Username</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
</author>
|
||||
<ipAddress>89.89.89.139</ipAddress>
|
||||
<thread dsq:id="247918464"/>
|
||||
</post>
|
||||
|
||||
<post>
|
||||
<id>12345678890</id>
|
||||
<message>This comment had no ID</message>
|
||||
<createdAt>2011-08-31T22:49:43Z</createdAt>
|
||||
<forum>radiot</forum>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>false</isSpam>
|
||||
<author>
|
||||
<email>blah.noname@gmail.com</email>
|
||||
<name>Blah Noname</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>74b9e7568ef6860e93862c5d77590123</username>
|
||||
</author>
|
||||
<ipAddress>189.89.89.139</ipAddress>
|
||||
<thread dsq:id="247918464"/>
|
||||
</post>
|
||||
|
||||
<post dsq:id="299986073">
|
||||
<id>6580890074280459219</id>
|
||||
<message>some ugly spam</message>
|
||||
<createdAt>2011-09-30T22:48:43Z</createdAt>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>true</isSpam>
|
||||
<author>
|
||||
<email>spam.noname@gmail.com</email>
|
||||
<name>Spam Noname</name>
|
||||
<isAnonymous>false</isAnonymous>
|
||||
<username>google-2c5d77590123</username>
|
||||
</author>
|
||||
<ipAddress>189.89.89.139</ipAddress>
|
||||
<thread dsq:id="247937687"/>
|
||||
</post>
|
||||
|
||||
<post dsq:id="x299986073">
|
||||
<message>some bad comment</message>
|
||||
<createdAt>2011-x09-30T22:48:43Z</createdAt>
|
||||
<isDeleted>false</isDeleted>
|
||||
<isSpam>123</isSpam>
|
||||
<author>
|
||||
<email>noname@gmail.com</email>
|
||||
<name>Noname</name>
|
||||
<isAnonymous>true</isAnonymous>
|
||||
<username>google-2c5d77590123</username>
|
||||
</author>
|
||||
<ipAddress>189.89.89.39</ipAddress>
|
||||
<thread dsq:id=247937687/>
|
||||
</post>
|
||||
|
||||
</disqus>
|
||||
@@ -2,14 +2,14 @@ package migrator
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
)
|
||||
|
||||
const wpTimeLayout = "2006-01-02 15:04:05"
|
||||
@@ -60,9 +60,8 @@ func (w *WordPress) Convert(text string) string {
|
||||
|
||||
// Import comments from WP and save to store
|
||||
func (w *WordPress) Import(r io.Reader, siteID string) (size int, err error) {
|
||||
|
||||
if err = w.DataStore.DeleteAll(siteID); err != nil {
|
||||
return 0, err
|
||||
if e := w.DataStore.DeleteAll(siteID); e != nil {
|
||||
return 0, e
|
||||
}
|
||||
|
||||
commentsCh := w.convert(r, siteID)
|
||||
@@ -76,9 +75,9 @@ func (w *WordPress) Import(r io.Reader, siteID string) (size int, err error) {
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
err = errors.Errorf("failed to save %d comments", failed)
|
||||
err = fmt.Errorf("failed to save %d comments", failed)
|
||||
if passed == 0 {
|
||||
err = errors.New("import failed")
|
||||
err = fmt.Errorf("import failed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +87,6 @@ func (w *WordPress) Import(r io.Reader, siteID string) (size int, err error) {
|
||||
}
|
||||
|
||||
func (w *WordPress) convert(r io.Reader, siteID string) chan store.Comment {
|
||||
|
||||
decoder := xml.NewDecoder(r)
|
||||
commentsCh := make(chan store.Comment)
|
||||
|
||||
@@ -107,8 +105,7 @@ func (w *WordPress) convert(r io.Reader, siteID string) chan store.Comment {
|
||||
break
|
||||
}
|
||||
|
||||
switch el := t.(type) {
|
||||
case xml.StartElement:
|
||||
if el, ok := t.(xml.StartElement); ok {
|
||||
if el.Name.Local == "item" {
|
||||
stats.inpItems++
|
||||
item := wpItem{}
|
||||
@@ -139,6 +136,7 @@ func (w *WordPress) convert(r io.Reader, siteID string) chan store.Comment {
|
||||
Text: comment.Content,
|
||||
Timestamp: comment.Date.time,
|
||||
ParentID: comment.PID,
|
||||
Imported: true,
|
||||
}
|
||||
commentsCh <- commentFormatter.Format(c)
|
||||
stats.inpComments++
|
||||
|
||||
@@ -6,14 +6,14 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
bolt "github.com/coreos/bbolt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark/backend/app/store/admin"
|
||||
"github.com/umputun/remark/backend/app/store/engine"
|
||||
"github.com/umputun/remark/backend/app/store/service"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store/admin"
|
||||
"github.com/umputun/remark42/backend/app/store/engine"
|
||||
"github.com/umputun/remark42/backend/app/store/service"
|
||||
)
|
||||
|
||||
func TestWordPress_Import(t *testing.T) {
|
||||
@@ -42,6 +42,7 @@ func TestWordPress_Import(t *testing.T) {
|
||||
ts, _ := time.Parse(wpTimeLayout, "2010-08-18 15:19:14")
|
||||
assert.Equal(t, ts, c.Timestamp)
|
||||
assert.Equal(t, c.Text, "<p>Mekkatorque was over in that tent up to the right</p>\n")
|
||||
assert.True(t, c.Imported)
|
||||
|
||||
posts, err := dataStore.List(siteID, 0, 0)
|
||||
assert.NoError(t, err)
|
||||
@@ -77,6 +78,7 @@ func TestWordPress_Convert(t *testing.T) {
|
||||
ID: "wordpress_" + store.EncodeID("Wednesday Reading « Cynwise's Battlefield Manual"),
|
||||
IP: "74.200.244.101",
|
||||
},
|
||||
Imported: true,
|
||||
}
|
||||
exp1.Timestamp, _ = time.Parse(wpTimeLayout, "2010-07-21 14:02:08")
|
||||
assert.Equal(t, exp1, comments[1])
|
||||
@@ -190,7 +192,7 @@ var xmlTestWP = `
|
||||
<category domain="post_tag" nicename="alts"><![CDATA[alts]]></category>
|
||||
<category domain="post_tag" nicename="role-playing"><![CDATA[role playing]]></category>
|
||||
<category domain="category" nicename="stuff"><![CDATA[Stuff]]></category>
|
||||
<category domain="post_tag" nicename="wierd-in-a-cant-quite-help-myself-way"><![CDATA[wierd in a can't quite help myself way]]></category>
|
||||
<category domain="post_tag" nicename="weird-in-a-cant-quite-help-myself-way"><![CDATA[weird in a can't quite help myself way]]></category>
|
||||
<wp:postmeta>
|
||||
<wp:meta_key><![CDATA[_edit_last]]></wp:meta_key>
|
||||
<wp:meta_value><![CDATA[2]]></wp:meta_value>
|
||||
|
||||
+175
-346
@@ -3,76 +3,41 @@ package notify
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"net/url"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
ntf "github.com/go-pkgz/notify"
|
||||
"github.com/go-pkgz/repeater"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/templates"
|
||||
)
|
||||
|
||||
// EmailParams contain settings for email notifications
|
||||
type EmailParams struct {
|
||||
From string // from email address
|
||||
MsgTemplate string // request message template
|
||||
VerificationSubject string // verification message subject
|
||||
VerificationTemplate string // verification message template
|
||||
SubscribeURL string // full subscribe handler URL
|
||||
UnsubscribeURL string // full unsubscribe handler URL
|
||||
From string // from email address
|
||||
AdminEmails []string // administrator emails to send copy of comment notification to
|
||||
MsgTemplatePath string // path to request message template
|
||||
VerificationSubject string // verification message sub
|
||||
VerificationTemplatePath string // path to verification template
|
||||
SubscribeURL string // full subscribe handler URL
|
||||
UnsubscribeURL string // full unsubscribe handler URL
|
||||
|
||||
TokenGenFn func(userID, email, site string) (string, error) // Unsubscribe token generation function
|
||||
}
|
||||
|
||||
// SmtpParams contain settings for smtp server connection
|
||||
type SmtpParams struct {
|
||||
Host string // SMTP host
|
||||
Port int // SMTP port
|
||||
TLS bool // TLS auth
|
||||
Username string // user name
|
||||
Password string // password
|
||||
TimeOut time.Duration // TCP connection timeout
|
||||
}
|
||||
|
||||
// Email implements notify.Destination for email
|
||||
type Email struct {
|
||||
EmailParams
|
||||
SmtpParams
|
||||
*ntf.Email
|
||||
|
||||
smtp smtpClientCreator
|
||||
EmailParams
|
||||
msgTmpl *template.Template // parsed request message template
|
||||
verifyTmpl *template.Template // parsed verification message template
|
||||
}
|
||||
|
||||
// default email client implementation
|
||||
type emailClient struct{ smtpClientCreator }
|
||||
|
||||
// smtpClient interface defines subset of net/smtp used by email client
|
||||
type smtpClient interface {
|
||||
Mail(string) error
|
||||
Auth(smtp.Auth) error
|
||||
Rcpt(string) error
|
||||
Data() (io.WriteCloser, error)
|
||||
Quit() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// smtpClientCreator interface defines function for creating new smtpClients
|
||||
type smtpClientCreator interface {
|
||||
Create(SmtpParams) (smtpClient, error)
|
||||
}
|
||||
|
||||
type emailMessage struct {
|
||||
from string
|
||||
to string
|
||||
message string
|
||||
}
|
||||
|
||||
// msgTmplData store data for message from request template execution
|
||||
type msgTmplData struct {
|
||||
UserName string
|
||||
@@ -88,6 +53,7 @@ type msgTmplData struct {
|
||||
PostTitle string
|
||||
Email string
|
||||
UnsubscribeLink string
|
||||
ForAdmin bool
|
||||
}
|
||||
|
||||
// verifyTmplData store data for verification message template execution
|
||||
@@ -100,183 +66,153 @@ type verifyTmplData struct {
|
||||
}
|
||||
|
||||
const (
|
||||
defaultVerificationSubject = "Email verification"
|
||||
defaultEmailTimeout = 10 * time.Second
|
||||
defaultEmailTemplate = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<style type="text/css">
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 250px;
|
||||
margin: 5px 0;
|
||||
display: block;
|
||||
color: #000;
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #0aa;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<!-- Some of blocks on this page have color: #000 because GMail can wrap block in his own tags which can change text color -->
|
||||
<body>
|
||||
<div style="font-family: Helvetica, Arial, sans-serif; font-size: 18px; width: 100%; max-width: 640px; margin: auto;">
|
||||
<h1 style="text-align: center; position: relative; color: #4fbbd6; margin-top: 10px; margin-bottom: 10px;">Remark42</h1>
|
||||
<div style="font-size: 16px; text-align: center; margin-bottom: 10px; color:#000!important;">New reply from {{.UserName}} on your comment{{if .PostTitle}} to «{{.PostTitle}}»{{ end }}</div>
|
||||
<div style="background-color: #eee; padding: 15px 20px 20px 20px; border-radius: 3px;">
|
||||
<div>
|
||||
<div style="margin-bottom: 12px; line-height: 24px;">
|
||||
<img src="{{.ParentUserPicture}}" style="width: 24px; height: 24px; float: left; margin: 0 8px 0 0; border-radius: 3px; background-color: #ccc;"/>
|
||||
<span style="font-size: 14px; font-weight: bold; color: #777">{{.ParentUserName}}</span>
|
||||
<span style="color: #999; font-size: 14px; margin: 0 8px;">{{.ParentCommentDate.Format "02.01.2006 at 15:04"}}</span>
|
||||
<a href="{{.ParentCommentLink}}" style="color: #0aa; font-size: 14px;"><b>Show</b></a>
|
||||
</div>
|
||||
<div style="font-size: 14px; color:#333!important; padding: 0 14px 0 2px; border-radius: 3px; line-height: 1.4;">
|
||||
{{.ParentCommentText}}
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding-left: 20px; border-left: 1px dotted rgba(0,0,0,0.15); margin-top: 15px; padding-top: 5px; line-height: 24px;">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<img src="{{.UserPicture}}" style="width: 24px; height: 24px; float: left; margin: 0 8px 0 0; border-radius: 3px; background-color: #ccc;"/>
|
||||
<div style="float: left; font-size: 14px; font-weight: bold; color: #777">
|
||||
{{.UserName}}
|
||||
</div>
|
||||
<div style="color: #999; font-size: 14px; margin: 0 8px; float: left;">
|
||||
{{.CommentDate.Format "02.01.2006 at 15:04"}}
|
||||
</div>
|
||||
<a href="{{.CommentLink}}" style="color: #0aa; font-size: 14px;"><b>Reply</b></a>
|
||||
</div>
|
||||
<div style="font-size: 16px; background-color: #fff; color:#000!important; padding: 14px 14px 2px 14px; border-radius: 3px; line-height: 1.4;">
|
||||
{{.CommentText}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align: center; font-size: 14px; margin-top: 32px;">
|
||||
<i style="color: #000!important;">Sent to <a style="color:inherit; text-decoration: none" href="mailto:{{.Email}}">{{.Email}}</a> for {{.ParentUserName}}</i>
|
||||
<div style="margin: auto; width: 150px; border-top: 1px solid rgba(0, 0, 0, 0.15); padding-top: 15px; margin-top: 15px;">
|
||||
<a style="color: #0aa;" href="{{.UnsubscribeLink}}">Unsubscribe</a>
|
||||
<!-- This is hack for remove collapser in Gmail which can collapse end of the message -->
|
||||
<div style="opacity: 0;">[{{.CommentDate.Format "02.01.2006 at 15:04"}}]</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
defaultEmailVerificationTemplate = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Some of blocks on this page have color: #000 because GMail can wrap block in his own tags which can change text color -->
|
||||
<div style="text-align: center; font-family: Helvetica, Arial, sans-serif; font-size: 18px;">
|
||||
<h1 style="position: relative; color: #4fbbd6; margin-top: 0.2em;">Remark42</h1>
|
||||
<p style="position: relative; max-width: 20em; margin: 0 auto 1em auto; line-height: 1.4em; color:#000!important;">Confirmation for <b>{{.User}}</b> on site <b>{{.Site}}</b></p>
|
||||
{{if .SubscribeURL}}
|
||||
<p style="position: relative; margin: 0 0 0.5em 0;color:#000!important;"><a href="{{.SubscribeURL}}{{.Token}}">Click here to subscribe to email notifications</a></p>
|
||||
<p style="position: relative; margin: 0 0 0.5em 0;color:#000!important;">Alternatively, you can use code below for subscription.</p>
|
||||
{{ end }}
|
||||
<div style="background-color: #eee; max-width: 20em; margin: 0 auto; border-radius: 0.4em; padding: 0.5em;">
|
||||
<p style="position: relative; margin: 0 0 0.5em 0;color:#000!important;">TOKEN</p>
|
||||
<p style="position: relative; font-size: 0.7em; opacity: 0.8;"><i style="color:#000!important;">Copy and paste this text into “token” field on comments page</i></p>
|
||||
<p style="position: relative; font-family: monospace; background-color: #fff; margin: 0; padding: 0.5em; word-break: break-all; text-align: left; border-radius: 0.2em; -webkit-user-select: all; user-select: all;">{{.Token}}</p>
|
||||
</div>
|
||||
<p style="position: relative; margin-top: 2em; font-size: 0.8em; opacity: 0.8;"><i style="color:#000!important;">Sent to {{.Email}}</i></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
defaultVerificationSubject = "Email verification"
|
||||
defaultEmailTimeout = 10 * time.Second
|
||||
defaultEmailTemplatePath = "email_reply.html.tmpl"
|
||||
defaultEmailVerificationTemplatePath = "email_confirmation_subscription.html.tmpl"
|
||||
)
|
||||
|
||||
// NewEmail makes new Email object, returns error in case of e.MsgTemplate or e.VerificationTemplate parsing error
|
||||
func NewEmail(emailParams EmailParams, smtpParams SmtpParams) (*Email, error) {
|
||||
func NewEmail(emailParams EmailParams, smtpParams ntf.SMTPParams) (*Email, error) {
|
||||
// set up Email emailParams
|
||||
res := Email{EmailParams: emailParams}
|
||||
if res.MsgTemplate == "" {
|
||||
res.MsgTemplate = defaultEmailTemplate
|
||||
}
|
||||
if res.VerificationTemplate == "" {
|
||||
res.VerificationTemplate = defaultEmailVerificationTemplate
|
||||
if smtpParams.TimeOut <= 0 {
|
||||
smtpParams.TimeOut = defaultEmailTimeout
|
||||
}
|
||||
|
||||
res := Email{Email: ntf.NewEmail(smtpParams), EmailParams: emailParams}
|
||||
|
||||
if res.VerificationSubject == "" {
|
||||
res.VerificationSubject = defaultVerificationSubject
|
||||
}
|
||||
|
||||
// set up SMTP emailParams
|
||||
res.smtp = &emailClient{}
|
||||
res.SmtpParams = smtpParams
|
||||
if res.TimeOut <= 0 {
|
||||
res.TimeOut = defaultEmailTimeout
|
||||
// initialize templates
|
||||
err := res.setTemplates()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't set templates: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[DEBUG] Create new email notifier for server %s with user %s, timeout=%s",
|
||||
res.Host, res.Username, res.TimeOut)
|
||||
|
||||
// initialise templates
|
||||
var err error
|
||||
if res.msgTmpl, err = template.New("messageFromRequest").Parse(res.MsgTemplate); err != nil {
|
||||
return nil, errors.Wrapf(err, "can't parse message template")
|
||||
}
|
||||
if res.verifyTmpl, err = template.New("messageFromRequest").Parse(res.VerificationTemplate); err != nil {
|
||||
return nil, errors.Wrapf(err, "can't parse verification template")
|
||||
}
|
||||
return &res, err
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
// Send email about reply to Request.Email if it's set, otherwise do nothing and return nil, thread safe
|
||||
// do not returns sending error, only following:
|
||||
// 1. (likely impossible) template execution error from email message creation from Request
|
||||
// 2. message dropped without sending in case of closed ctx
|
||||
func (e *Email) Send(ctx context.Context, req Request) (err error) {
|
||||
func (e *Email) setTemplates() error {
|
||||
var err error
|
||||
var msgTmplFile, verifyTmplFile []byte
|
||||
|
||||
if e.VerificationTemplatePath == "" {
|
||||
e.VerificationTemplatePath = defaultEmailVerificationTemplatePath
|
||||
}
|
||||
|
||||
if e.MsgTemplatePath == "" {
|
||||
e.MsgTemplatePath = defaultEmailTemplatePath
|
||||
}
|
||||
|
||||
if msgTmplFile, err = templates.Read(e.MsgTemplatePath); err != nil {
|
||||
return fmt.Errorf("can't read message template: %w", err)
|
||||
}
|
||||
if verifyTmplFile, err = templates.Read(e.VerificationTemplatePath); err != nil {
|
||||
return fmt.Errorf("can't read verification template: %w", err)
|
||||
}
|
||||
if e.msgTmpl, err = template.New("msgTmpl").Parse(string(msgTmplFile)); err != nil {
|
||||
return fmt.Errorf("can't parse message template: %w", err)
|
||||
}
|
||||
if e.verifyTmpl, err = template.New("verifyTmpl").Parse(string(verifyTmplFile)); err != nil {
|
||||
return fmt.Errorf("can't parse verification template: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send email about comment reply to Request.Emails and Email.AdminEmails
|
||||
// if they're set.
|
||||
// Thread safe
|
||||
func (e *Email) Send(ctx context.Context, req Request) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("sending email messages about comment %q aborted due to canceled context", req.Comment.ID)
|
||||
default:
|
||||
}
|
||||
|
||||
result := new(multierror.Error)
|
||||
|
||||
for _, email := range req.Emails {
|
||||
err := e.buildAndSendMessage(ctx, req, email, false)
|
||||
if err != nil {
|
||||
result = multierror.Append(fmt.Errorf("problem sending user email notification to %q: %w", email, err))
|
||||
}
|
||||
}
|
||||
|
||||
for _, email := range e.AdminEmails {
|
||||
err := e.buildAndSendMessage(ctx, req, email, true)
|
||||
if err != nil {
|
||||
result = multierror.Append(fmt.Errorf("problem sending admin email notification to %q: %w", email, err))
|
||||
}
|
||||
}
|
||||
|
||||
return result.ErrorOrNil()
|
||||
}
|
||||
|
||||
func (e *Email) buildAndSendMessage(ctx context.Context, req Request, email string, forAdmin bool) error {
|
||||
log.Printf("[DEBUG] send notification via %s, comment id %s", e, req.Comment.ID)
|
||||
msg, err := e.buildMessageFromRequest(req, email, forAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return repeater.NewDefault(5, time.Millisecond*250).Do(
|
||||
ctx,
|
||||
func() error {
|
||||
return e.Email.Send(
|
||||
ctx,
|
||||
fmt.Sprintf("mailto:%s?from=%s&unsubscribeLink=%s&subject=%s",
|
||||
email,
|
||||
e.From,
|
||||
url.QueryEscape(msg.unsubscribeLink),
|
||||
url.QueryEscape(msg.subject),
|
||||
),
|
||||
msg.body,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// SendVerification email verification VerificationRequest.Email if it's set.
|
||||
// Thread safe
|
||||
func (e *Email) SendVerification(ctx context.Context, req VerificationRequest) error {
|
||||
if req.Email == "" {
|
||||
// this means we can't send this request via Email
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return errors.Errorf("sending message to %q aborted due to canceled context", req.Email)
|
||||
return fmt.Errorf("sending message to %q aborted due to canceled context", req.User)
|
||||
default:
|
||||
}
|
||||
var msg string
|
||||
|
||||
if req.Verification.Token != "" {
|
||||
log.Printf("[DEBUG] send verification via %s, user %s", e, req.Verification.User)
|
||||
msg, err = e.buildVerificationMessage(req.Verification.User, req.Email, req.Verification.Token, req.Verification.SiteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if req.Comment.ID != "" {
|
||||
if req.parent.User.ID == req.Comment.User.ID {
|
||||
// don't send anything if if user replied to their own comment
|
||||
return nil
|
||||
}
|
||||
log.Printf("[DEBUG] send notification via %s, comment id %s", e, req.Comment.ID)
|
||||
msg, err = e.buildMessageFromRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[DEBUG] send verification via %s, user %s", e, req.User)
|
||||
msg, err := e.buildVerificationMessage(req.User, req.Email, req.Token, req.SiteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return repeater.NewDefault(5, time.Millisecond*250).Do(
|
||||
ctx,
|
||||
func() error {
|
||||
return e.sendMessage(emailMessage{from: e.From, to: req.Email, message: msg})
|
||||
return e.Email.Send(
|
||||
ctx,
|
||||
fmt.Sprintf("mailto:%s?from=%s&subject=%s",
|
||||
req.Email,
|
||||
e.From,
|
||||
url.QueryEscape(e.VerificationSubject),
|
||||
),
|
||||
msg,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// buildVerificationMessage generates verification email message based on given input
|
||||
func (e *Email) buildVerificationMessage(user, email, token, site string) (string, error) {
|
||||
subject := e.VerificationSubject
|
||||
msg := bytes.Buffer{}
|
||||
err := e.verifyTmpl.Execute(&msg, verifyTmplData{
|
||||
User: user,
|
||||
@@ -286,171 +222,64 @@ func (e *Email) buildVerificationMessage(user, email, token, site string) (strin
|
||||
SubscribeURL: e.SubscribeURL,
|
||||
})
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "error executing template to build verification message")
|
||||
return "", fmt.Errorf("error executing template to build verification message: %w", err)
|
||||
}
|
||||
return e.buildMessage(subject, msg.String(), email, "text/html", "")
|
||||
return msg.String(), nil
|
||||
}
|
||||
|
||||
type commentMessage struct {
|
||||
subject string
|
||||
body string
|
||||
unsubscribeLink string
|
||||
}
|
||||
|
||||
// buildMessageFromRequest generates email message based on Request using e.MsgTemplate
|
||||
func (e *Email) buildMessageFromRequest(req Request) (string, error) {
|
||||
func (e *Email) buildMessageFromRequest(req Request, email string, forAdmin bool) (commentMessage, error) {
|
||||
subject := "New reply to your comment"
|
||||
if forAdmin {
|
||||
subject = "New comment to your site"
|
||||
}
|
||||
if req.Comment.PostTitle != "" {
|
||||
subject += fmt.Sprintf(" for \"%s\"", req.Comment.PostTitle)
|
||||
subject += fmt.Sprintf(" for %q", req.Comment.PostTitle)
|
||||
}
|
||||
|
||||
token, err := e.TokenGenFn(req.parent.User.ID, email, req.Comment.Locator.SiteID)
|
||||
if err != nil {
|
||||
return commentMessage{}, fmt.Errorf("error creating token for unsubscribe link: %w", err)
|
||||
}
|
||||
token, err := e.TokenGenFn(req.parent.User.ID, req.Email, req.Comment.Locator.SiteID)
|
||||
unsubscribeLink := e.UnsubscribeURL + "?site=" + req.Comment.Locator.SiteID + "&tkn=" + token
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "error creating token for unsubscribe link")
|
||||
if forAdmin {
|
||||
unsubscribeLink = ""
|
||||
}
|
||||
commentUrlPrefix := req.Comment.Locator.URL + uiNav
|
||||
|
||||
commentURLPrefix := req.Comment.Locator.URL + uiNav
|
||||
msg := bytes.Buffer{}
|
||||
err = e.msgTmpl.Execute(&msg, msgTmplData{
|
||||
UserName: req.Comment.User.Name,
|
||||
UserPicture: req.Comment.User.Picture,
|
||||
CommentText: req.Comment.Text,
|
||||
CommentLink: commentUrlPrefix + req.Comment.ID,
|
||||
CommentDate: req.Comment.Timestamp,
|
||||
ParentUserName: req.parent.User.Name,
|
||||
ParentUserPicture: req.parent.User.Picture,
|
||||
ParentCommentText: req.parent.Text,
|
||||
ParentCommentLink: commentUrlPrefix + req.parent.ID,
|
||||
ParentCommentDate: req.parent.Timestamp,
|
||||
PostTitle: req.Comment.PostTitle,
|
||||
Email: req.Email,
|
||||
UnsubscribeLink: unsubscribeLink,
|
||||
})
|
||||
tmplData := msgTmplData{
|
||||
UserName: req.Comment.User.Name,
|
||||
UserPicture: req.Comment.User.Picture,
|
||||
CommentText: req.Comment.Text,
|
||||
CommentLink: commentURLPrefix + req.Comment.ID,
|
||||
CommentDate: req.Comment.Timestamp,
|
||||
PostTitle: req.Comment.PostTitle,
|
||||
Email: email,
|
||||
UnsubscribeLink: unsubscribeLink,
|
||||
ForAdmin: forAdmin,
|
||||
}
|
||||
// in case of message to admin, parent message might be empty
|
||||
if req.Comment.ParentID != "" {
|
||||
tmplData.ParentUserName = req.parent.User.Name
|
||||
tmplData.ParentUserPicture = req.parent.User.Picture
|
||||
tmplData.ParentCommentText = req.parent.Text
|
||||
tmplData.ParentCommentLink = commentURLPrefix + req.parent.ID
|
||||
tmplData.ParentCommentDate = req.parent.Timestamp
|
||||
}
|
||||
err = e.msgTmpl.Execute(&msg, tmplData)
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "error executing template to build comment reply message")
|
||||
return commentMessage{}, fmt.Errorf("error executing template to build comment reply message: %w", err)
|
||||
}
|
||||
return e.buildMessage(subject, msg.String(), req.Email, "text/html", unsubscribeLink)
|
||||
}
|
||||
|
||||
// buildMessage generates email message to send using net/smtp.Data()
|
||||
func (e *Email) buildMessage(subject, body, to, contentType, unsubscribeLink string) (message string, err error) {
|
||||
addHeader := func(msg, h, v string) string {
|
||||
msg += fmt.Sprintf("%s: %s\n", h, v)
|
||||
return msg
|
||||
}
|
||||
message = addHeader(message, "From", e.From)
|
||||
message = addHeader(message, "To", to)
|
||||
message = addHeader(message, "Subject", subject)
|
||||
message = addHeader(message, "Content-Transfer-Encoding", "quoted-printable")
|
||||
|
||||
if contentType != "" {
|
||||
message = addHeader(message, "MIME-version", "1.0")
|
||||
message = addHeader(message, "Content-Type", contentType+`; charset="UTF-8"`)
|
||||
}
|
||||
|
||||
if unsubscribeLink != "" {
|
||||
// https://support.google.com/mail/answer/81126 -> "Include option to unsubscribe"
|
||||
message = addHeader(message, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
|
||||
message = addHeader(message, "List-Unsubscribe", "<"+unsubscribeLink+">")
|
||||
}
|
||||
|
||||
message = addHeader(message, "Date", time.Now().Format(time.RFC1123Z))
|
||||
|
||||
buff := &bytes.Buffer{}
|
||||
qp := quotedprintable.NewWriter(buff)
|
||||
if _, err := qp.Write([]byte(body)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer qp.Close()
|
||||
m := buff.String()
|
||||
message += "\n" + m
|
||||
return message, nil
|
||||
}
|
||||
|
||||
// sendMessage sends messages to server in a new connection, closing the connection after finishing.
|
||||
// Thread safe.
|
||||
func (e *Email) sendMessage(m emailMessage) error {
|
||||
if e.smtp == nil {
|
||||
return errors.New("sendMessage called without smtpClient set")
|
||||
}
|
||||
smtpClient, err := e.smtp.Create(e.SmtpParams)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to make smtp Create")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := smtpClient.Quit(); err != nil {
|
||||
log.Printf("[WARN] failed to send quit command to %s:%d, %v", e.Host, e.Port, err)
|
||||
if err := smtpClient.Close(); err != nil {
|
||||
log.Printf("[WARN] can't close smtp connection, %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err := smtpClient.Mail(m.from); err != nil {
|
||||
return errors.Wrapf(err, "bad from address %q", m.from)
|
||||
}
|
||||
if err := smtpClient.Rcpt(m.to); err != nil {
|
||||
return errors.Wrapf(err, "bad to address %q", m.to)
|
||||
}
|
||||
|
||||
writer, err := smtpClient.Data()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "can't make email writer")
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err = writer.Close(); err != nil {
|
||||
log.Printf("[WARN] can't close smtp body writer, %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
buf := bytes.NewBufferString(m.message)
|
||||
if _, err = buf.WriteTo(writer); err != nil {
|
||||
return errors.Wrapf(err, "failed to send email body to %q", m.to)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// String representation of Email object
|
||||
func (e *Email) String() string {
|
||||
return fmt.Sprintf("email: from %q with username '%s' at server %s:%d", e.From, e.Username, e.Host, e.Port)
|
||||
}
|
||||
|
||||
// Create establish SMTP connection with server using credentials in smtpClientWithCreator.SmtpParams
|
||||
// and returns pointer to it. Thread safe.
|
||||
func (s *emailClient) Create(params SmtpParams) (smtpClient, error) {
|
||||
authenticate := func(c *smtp.Client) error {
|
||||
if params.Username == "" || params.Password == "" {
|
||||
return nil
|
||||
}
|
||||
auth := smtp.PlainAuth("", params.Username, params.Password, params.Host)
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return errors.Wrapf(err, "failed to auth to smtp %s:%d", params.Host, params.Port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var c *smtp.Client
|
||||
srvAddress := fmt.Sprintf("%s:%d", params.Host, params.Port)
|
||||
if params.TLS {
|
||||
tlsConf := &tls.Config{
|
||||
InsecureSkipVerify: false,
|
||||
ServerName: params.Host,
|
||||
}
|
||||
conn, err := tls.Dial("tcp", srvAddress, tlsConf)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to dial smtp tls to %s", srvAddress)
|
||||
}
|
||||
if c, err = smtp.NewClient(conn, params.Host); err != nil {
|
||||
return nil, errors.Wrapf(err, "failed to make smtp client for %s", srvAddress)
|
||||
}
|
||||
return c, authenticate(c)
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", srvAddress, params.TimeOut)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "timeout connecting to %s", srvAddress)
|
||||
}
|
||||
|
||||
c, err = smtp.NewClient(conn, srvAddress)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to dial")
|
||||
}
|
||||
|
||||
return c, authenticate(c)
|
||||
return commentMessage{
|
||||
subject: subject,
|
||||
body: msg.String(),
|
||||
unsubscribeLink: unsubscribeLink,
|
||||
}, err
|
||||
}
|
||||
|
||||
+161
-274
@@ -1,94 +1,99 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/smtp"
|
||||
"sync"
|
||||
"fmt"
|
||||
"testing"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
ntf "github.com/go-pkgz/notify"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
)
|
||||
|
||||
func TestEmailNew(t *testing.T) {
|
||||
var testSet = []struct {
|
||||
emailParams := EmailParams{
|
||||
From: "test@from",
|
||||
VerificationTemplatePath: "testdata/verification.html.tmpl",
|
||||
MsgTemplatePath: "testdata/msg.html.tmpl",
|
||||
}
|
||||
smtpParams := ntf.SMTPParams{
|
||||
Host: "test@host",
|
||||
Port: 1000,
|
||||
TLS: true,
|
||||
StartTLS: true,
|
||||
Username: "test@username",
|
||||
Password: "test@password",
|
||||
}
|
||||
|
||||
email, err := NewEmail(emailParams, smtpParams)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, email, "email returned")
|
||||
|
||||
assert.NotNil(t, email.msgTmpl, "e.template is set")
|
||||
assert.Equal(t, emailParams.From, email.EmailParams.From, "emailParams.From unchanged after creation")
|
||||
if smtpParams.TimeOut == 0 {
|
||||
assert.Equal(t, defaultEmailTimeout, email.TimeOut, "empty emailParams.TimeOut changed to default")
|
||||
} else {
|
||||
assert.Equal(t, smtpParams.TimeOut, email.TimeOut, "emailParams.TimOut unchanged after creation")
|
||||
}
|
||||
assert.Equal(t, smtpParams.Host, email.Host, "emailParams.Host unchanged after creation")
|
||||
assert.Equal(t, smtpParams.Username, email.Username, "emailParams.Username unchanged after creation")
|
||||
assert.Equal(t, smtpParams.Password, email.Password, "emailParams.Password unchanged after creation")
|
||||
assert.Equal(t, smtpParams.Port, email.Port, "emailParams.Port unchanged after creation")
|
||||
assert.Equal(t, smtpParams.TLS, email.TLS, "emailParams.TLS unchanged after creation")
|
||||
assert.Equal(t, smtpParams.StartTLS, email.StartTLS, "emailParams.TLS unchanged after creation")
|
||||
assert.Equal(t, "email: with username 'test@username' at server test@host:1000 with TLS with StartTLS", email.String())
|
||||
}
|
||||
|
||||
func Test_initTemplatesErr(t *testing.T) {
|
||||
testSet := []struct {
|
||||
name string
|
||||
err bool
|
||||
errText string
|
||||
emailParams EmailParams
|
||||
smtpParams SmtpParams
|
||||
}{
|
||||
{name: "empty"},
|
||||
{name: "with template parse error",
|
||||
err: true, errText: "can't parse message template: template: messageFromRequest:1: unexpected unclosed action in command",
|
||||
{
|
||||
name: "with wrong path to verification template",
|
||||
errText: "notfound.tmpl: file does not exist",
|
||||
emailParams: EmailParams{
|
||||
MsgTemplate: "{{",
|
||||
}},
|
||||
{name: "with verification template parse error",
|
||||
err: true, errText: "can't parse verification template: template: messageFromRequest:1: unexpected unclosed action in command",
|
||||
emailParams: EmailParams{
|
||||
From: "test@from",
|
||||
VerificationTemplate: "{{",
|
||||
},
|
||||
smtpParams: SmtpParams{
|
||||
Host: "test@host",
|
||||
Port: 1000,
|
||||
TLS: true,
|
||||
Username: "test@username",
|
||||
Password: "test@password",
|
||||
TimeOut: time.Second,
|
||||
VerificationTemplatePath: "notfound.tmpl",
|
||||
},
|
||||
},
|
||||
{name: "normal creation",
|
||||
err: false, errText: "can't parse verification template: template: messageFromRequest:1: unexpected unclosed action in command",
|
||||
{
|
||||
name: "with wrong path to message template",
|
||||
errText: "notfound.tmpl: file does not exist",
|
||||
emailParams: EmailParams{
|
||||
From: "test@from",
|
||||
MsgTemplatePath: "notfound.tmpl",
|
||||
},
|
||||
smtpParams: SmtpParams{
|
||||
Host: "test@host",
|
||||
Port: 1000,
|
||||
TLS: true,
|
||||
Username: "test@username",
|
||||
Password: "test@password",
|
||||
TimeOut: time.Second,
|
||||
},
|
||||
{
|
||||
name: "with error on read verification template",
|
||||
errText: "can't parse verification template: template: verifyTmpl",
|
||||
emailParams: EmailParams{
|
||||
VerificationTemplatePath: "testdata/bad.html.tmpl",
|
||||
MsgTemplatePath: "testdata/msg.html.tmpl",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with error on read message template",
|
||||
errText: "can't parse message template: template: msgTmpl",
|
||||
emailParams: EmailParams{
|
||||
VerificationTemplatePath: "testdata/verification.html.tmpl",
|
||||
MsgTemplatePath: "testdata/bad.html.tmpl",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, d := range testSet {
|
||||
d := d
|
||||
t.Run(d.name, func(t *testing.T) {
|
||||
email, err := NewEmail(d.emailParams, d.smtpParams)
|
||||
|
||||
if d.err && d.errText == "" {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, email)
|
||||
} else if d.err && d.errText != "" {
|
||||
assert.EqualError(t, err, d.errText)
|
||||
assert.Nil(t, email)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, email, "email returned")
|
||||
|
||||
assert.NotNil(t, email.msgTmpl, "e.template is set")
|
||||
assert.Equal(t, defaultEmailTemplate, email.EmailParams.MsgTemplate, "empty emailParams.MsgTemplate changed to default")
|
||||
assert.Equal(t, defaultEmailVerificationTemplate, email.EmailParams.VerificationTemplate, "empty emailParams.VerificationTemplate changed to default")
|
||||
assert.Equal(t, d.emailParams.From, email.EmailParams.From, "emailParams.From unchanged after creation")
|
||||
if d.smtpParams.TimeOut == 0 {
|
||||
assert.Equal(t, defaultEmailTimeout, email.TimeOut, "empty emailParams.TimeOut changed to default")
|
||||
} else {
|
||||
assert.Equal(t, d.smtpParams.TimeOut, email.TimeOut, "emailParams.TimOut unchanged after creation")
|
||||
}
|
||||
assert.Equal(t, d.smtpParams.Host, email.Host, "emailParams.Host unchanged after creation")
|
||||
assert.Equal(t, d.smtpParams.Username, email.Username, "emailParams.Username unchanged after creation")
|
||||
assert.Equal(t, d.smtpParams.Password, email.Password, "emailParams.Password unchanged after creation")
|
||||
assert.Equal(t, d.smtpParams.Port, email.Port, "emailParams.Port unchanged after creation")
|
||||
assert.Equal(t, d.smtpParams.TLS, email.TLS, "emailParams.TLS unchanged after creation")
|
||||
}
|
||||
e, err := NewEmail(d.emailParams, ntf.SMTPParams{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, e)
|
||||
assert.Contains(t, err.Error(), d.errText)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -100,260 +105,142 @@ func TestEmailSendErrors(t *testing.T) {
|
||||
|
||||
e.verifyTmpl, err = template.New("test").Parse("{{.Test}}")
|
||||
assert.NoError(t, err)
|
||||
assert.EqualError(t, e.Send(context.Background(), Request{Email: "bad@example.org", Verification: VerificationMetadata{Token: "some"}}),
|
||||
assert.EqualError(t, e.SendVerification(context.Background(), VerificationRequest{Email: "bad@example.org", Token: "some"}),
|
||||
"error executing template to build verification message: template: test:1:2: executing \"test\" at <.Test>: can't evaluate field Test in type notify.verifyTmplData")
|
||||
e.verifyTmpl, err = template.New("test").Parse(defaultEmailVerificationTemplate)
|
||||
assert.NoError(t, err)
|
||||
|
||||
e.msgTmpl, err = template.New("test").Parse("{{.Test}}")
|
||||
assert.NoError(t, err)
|
||||
assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Email: "bad@example.org"}),
|
||||
"error executing template to build comment reply message: template: test:1:2: executing \"test\" at <.Test>: can't evaluate field Test in type notify.msgTmplData")
|
||||
e.msgTmpl, err = template.New("test").Parse(defaultEmailTemplate)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Emails: []string{"bad@example.org"}}),
|
||||
"1 error occurred:\n\t* problem sending user email notification to \"bad@example.org\": "+
|
||||
"error executing template to build comment reply message: "+
|
||||
"template: test:1:2: executing \"test\" at <.Test>: "+
|
||||
"can't evaluate field Test in type notify.msgTmplData\n\n")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
assert.EqualError(t, e.Send(ctx, Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Email: "bad@example.org"}),
|
||||
"sending message to \"bad@example.org\" aborted due to canceled context")
|
||||
assert.EqualError(t, e.Send(ctx, Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Emails: []string{"bad@example.org"}}),
|
||||
"sending email messages about comment \"999\" aborted due to canceled context")
|
||||
|
||||
e.smtp = &fakeTestSMTP{}
|
||||
assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "error"}}, Email: "bad@example.org"}),
|
||||
"error creating token for unsubscribe link: token generation error")
|
||||
e.msgTmpl, err = template.New("test").Parse(defaultEmailTemplate)
|
||||
assert.NoError(t, err)
|
||||
assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "error"}}, Emails: []string{"bad@example.org"}}),
|
||||
"1 error occurred:\n\t* problem sending user email notification to \"bad@example.org\":"+
|
||||
" error creating token for unsubscribe link: token generation error\n\n")
|
||||
}
|
||||
|
||||
func TestEmailSend_ExitConditions(t *testing.T) {
|
||||
email, err := NewEmail(EmailParams{}, SmtpParams{})
|
||||
email, err := NewEmail(EmailParams{
|
||||
VerificationTemplatePath: "testdata/verification.html.tmpl",
|
||||
MsgTemplatePath: "testdata/msg.html.tmpl",
|
||||
}, ntf.SMTPParams{})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, email, "expecting email returned")
|
||||
// prevent triggering e.autoFlush creation
|
||||
emptyRequest := Request{Comment: store.Comment{ID: "999"}}
|
||||
assert.NoError(t, email.Send(context.Background(), emptyRequest),
|
||||
"Message without parent comment User.Email is not sent and returns nil")
|
||||
requestWithEqualUsersWithEmails := Request{Comment: store.Comment{ID: "999"}, Email: "good_example@example.org"}
|
||||
assert.NoError(t, email.Send(context.Background(), requestWithEqualUsersWithEmails),
|
||||
"Message with parent comment User equals comment User is not sent and returns nil")
|
||||
}
|
||||
|
||||
func TestEmailSendClientError(t *testing.T) {
|
||||
var testSet = []struct {
|
||||
name string
|
||||
smtp *fakeTestSMTP
|
||||
err string
|
||||
}{
|
||||
{name: "failed to verify receiver", smtp: &fakeTestSMTP{fail: map[string]bool{"mail": true}},
|
||||
err: "bad from address \"\": failed to verify sender"},
|
||||
{name: "failed to verify sender", smtp: &fakeTestSMTP{fail: map[string]bool{"rcpt": true}},
|
||||
err: "bad to address \"\": failed to verify receiver"},
|
||||
{name: "failed to close connection", smtp: &fakeTestSMTP{fail: map[string]bool{"quit": true, "close": true}}},
|
||||
{name: "failed to make email writer", smtp: &fakeTestSMTP{fail: map[string]bool{"data": true}},
|
||||
err: "can't make email writer: failed to send"},
|
||||
}
|
||||
for _, d := range testSet {
|
||||
t.Run(d.name, func(t *testing.T) {
|
||||
e := Email{smtp: d.smtp}
|
||||
if d.err != "" {
|
||||
assert.EqualError(t, e.sendMessage(emailMessage{}), d.err,
|
||||
"expected error for e.sendMessage")
|
||||
} else {
|
||||
assert.NoError(t, e.sendMessage(emailMessage{}),
|
||||
"expected no error for e.sendMessage")
|
||||
}
|
||||
})
|
||||
}
|
||||
e := Email{}
|
||||
e.smtp = nil
|
||||
assert.Error(t, e.sendMessage(emailMessage{}),
|
||||
"nil e.smtp should return error")
|
||||
e.smtp = &fakeTestSMTP{}
|
||||
assert.NoError(t, e.sendMessage(emailMessage{}), "",
|
||||
"no error expected for e.sendMessage in normal flow")
|
||||
e.smtp = &fakeTestSMTP{fail: map[string]bool{"quit": true}}
|
||||
assert.NoError(t, e.sendMessage(emailMessage{}), "",
|
||||
"no error expected for e.sendMessage with failed smtpClient.Quit but successful smtpClient.Close")
|
||||
e.smtp = &fakeTestSMTP{fail: map[string]bool{"create": true}}
|
||||
assert.EqualError(t, e.sendMessage(emailMessage{}), "failed to make smtp Create: failed to create client",
|
||||
"e.send called without smtpClient set returns error")
|
||||
"Message without Emails and AdminEmails is not sent and returns nil")
|
||||
}
|
||||
|
||||
func TestEmail_Send(t *testing.T) {
|
||||
email, err := NewEmail(EmailParams{From: "from@example.org"}, SmtpParams{})
|
||||
email, err := NewEmail(EmailParams{
|
||||
From: "from@example.org",
|
||||
VerificationTemplatePath: "testdata/verification.html.tmpl",
|
||||
MsgTemplatePath: "testdata/msg.html.tmpl",
|
||||
}, ntf.SMTPParams{})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, email)
|
||||
fakeSmtp := fakeTestSMTP{}
|
||||
email.smtp = &fakeSmtp
|
||||
email.TokenGenFn = TokenGenFn
|
||||
email.UnsubscribeURL = "https://remark42.com/api/v1/email/unsubscribe"
|
||||
req := Request{
|
||||
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, PostTitle: "test_title"},
|
||||
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, ParentID: "1", PostTitle: "test_title"},
|
||||
parent: store.Comment{ID: "1", User: store.User{ID: "999", Name: "parent_user"}},
|
||||
Email: "test@example.org",
|
||||
Emails: []string{"test@example.org"},
|
||||
}
|
||||
assert.NoError(t, email.Send(context.TODO(), req))
|
||||
assert.Equal(t, "from@example.org", fakeSmtp.readMail())
|
||||
assert.Equal(t, 1, fakeSmtp.readQuitCount())
|
||||
assert.Equal(t, "test@example.org", fakeSmtp.readRcpt())
|
||||
assert.Contains(t, email.Send(context.Background(), req).Error(), "problem sending user email notification to \"test@example.org\"")
|
||||
// test buildMessageFromRequest separately for message text
|
||||
res, err := email.buildMessageFromRequest(req)
|
||||
msg, err := email.buildMessageFromRequest(req, req.Emails[0], false)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, res, `From: from@example.org
|
||||
To: test@example.org
|
||||
Subject: New reply to your comment for "test_title"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
MIME-version: 1.0
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
List-Unsubscribe-Post: List-Unsubscribe=One-Click
|
||||
List-Unsubscribe: <https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token>
|
||||
Date: `)
|
||||
assert.Equal(t, `
|
||||
New reply from test_user on your comment to «test_title»
|
||||
|
||||
User: test_user
|
||||
01.01.0001 at 00:00
|
||||
Comment:
|
||||
test@example.org for parent_user
|
||||
Unsubscribe link: https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token
|
||||
`, msg.body)
|
||||
assert.Equal(t, "https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token", msg.unsubscribeLink)
|
||||
assert.Equal(t, `New reply to your comment for "test_title"`, msg.subject)
|
||||
|
||||
// send email to both user and admin, without parent set
|
||||
email.AdminEmails = []string{"admin@example.org"}
|
||||
req = Request{
|
||||
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, PostTitle: "test_title"},
|
||||
Emails: []string{"test@example.org"},
|
||||
}
|
||||
assert.Error(t, email.Send(context.Background(), req))
|
||||
msg, err = email.buildMessageFromRequest(req, email.AdminEmails[0], true)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `
|
||||
New comment from test_user on your site to «test_title»
|
||||
|
||||
User: test_user
|
||||
01.01.0001 at 00:00
|
||||
Comment:
|
||||
admin@example.org
|
||||
`, msg.body)
|
||||
assert.Equal(t, `New comment to your site for "test_title"`, msg.subject)
|
||||
assert.Empty(t, msg.unsubscribeLink)
|
||||
}
|
||||
|
||||
func TestEmail_SendVerification(t *testing.T) {
|
||||
email, err := NewEmail(EmailParams{From: "from@example.org"}, SmtpParams{})
|
||||
email, err := NewEmail(EmailParams{
|
||||
From: "from@example.org",
|
||||
VerificationTemplatePath: "testdata/verification.html.tmpl",
|
||||
MsgTemplatePath: "testdata/msg.html.tmpl",
|
||||
}, ntf.SMTPParams{})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, email)
|
||||
fakeSmtp := fakeTestSMTP{}
|
||||
email.smtp = &fakeSmtp
|
||||
email.TokenGenFn = TokenGenFn
|
||||
req := Request{
|
||||
Email: "test@example.org",
|
||||
Verification: VerificationMetadata{
|
||||
SiteID: "remark",
|
||||
User: "test_username",
|
||||
Token: "secret_",
|
||||
},
|
||||
// proper VerificationRequest without email
|
||||
req := VerificationRequest{
|
||||
SiteID: "remark",
|
||||
User: "test_username",
|
||||
Token: "secret_",
|
||||
}
|
||||
assert.NoError(t, email.Send(context.TODO(), req))
|
||||
assert.Equal(t, "from@example.org", fakeSmtp.readMail())
|
||||
assert.Equal(t, 1, fakeSmtp.readQuitCount())
|
||||
assert.Equal(t, "test@example.org", fakeSmtp.readRcpt())
|
||||
// test buildMessageFromRequest separately for message text
|
||||
res, err := email.buildVerificationMessage(req.Verification.User, req.Email, req.Verification.Token, req.Verification.SiteID)
|
||||
assert.NoError(t, email.SendVerification(context.Background(), req))
|
||||
|
||||
// proper VerificationRequest with email
|
||||
req.Email = "test@example.org"
|
||||
assert.Error(t, email.SendVerification(context.Background(), req), "failed to make smtp client")
|
||||
|
||||
// VerificationRequest with canceled context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
assert.EqualError(t, email.SendVerification(ctx, req), "sending message to \"test_username\" aborted due to canceled context")
|
||||
|
||||
// test buildVerificationMessage separately for message text
|
||||
res, err := email.buildVerificationMessage(req.User, req.Email, req.Token, req.SiteID)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, res, `From: from@example.org
|
||||
To: test@example.org
|
||||
Subject: Email verification
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
MIME-version: 1.0
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
Date: `)
|
||||
assert.Equal(t, res, `Confirmation for test_username on site remark
|
||||
Token:secret_
|
||||
Sent to test@example.org
|
||||
|
||||
`)
|
||||
assert.Contains(t, res, `secret_`)
|
||||
assert.NotContains(t, res, `https://example.org/`)
|
||||
email.SubscribeURL = "https://example.org/subscribe.html?token="
|
||||
res, err = email.buildVerificationMessage(req.Verification.User, req.Email, req.Verification.Token, req.Verification.SiteID)
|
||||
res, err = email.buildVerificationMessage(req.User, req.Email, req.Token, req.SiteID)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, res, `From: from@example.org
|
||||
To: test@example.org
|
||||
Subject: Email verification
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
MIME-version: 1.0
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
Date: `)
|
||||
assert.Contains(t, res, `https://example.org/subscribe.html?token=3Dsecret_`)
|
||||
}
|
||||
assert.Equal(t, res, `Confirmation for test_username on site remark
|
||||
Subscribe url: https://example.org/subscribe.html?token=secret_
|
||||
Token:secret_
|
||||
Sent to test@example.org
|
||||
|
||||
func Test_emailClient_Create(t *testing.T) {
|
||||
creator := emailClient{}
|
||||
client, err := creator.Create(SmtpParams{})
|
||||
assert.Error(t, err, "absence of address to connect results in error")
|
||||
assert.Nil(t, client, "no client returned in case of error")
|
||||
}
|
||||
|
||||
type fakeTestSMTP struct {
|
||||
fail map[string]bool
|
||||
|
||||
buff bytes.Buffer
|
||||
mail, rcpt string
|
||||
auth bool
|
||||
close bool
|
||||
quitCount int
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
func (f *fakeTestSMTP) Create(SmtpParams) (smtpClient, error) {
|
||||
if f.fail["create"] {
|
||||
return nil, errors.New("failed to create client")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (f *fakeTestSMTP) Auth(smtp.Auth) error { f.auth = true; return nil }
|
||||
|
||||
func (f *fakeTestSMTP) Mail(m string) error {
|
||||
f.lock.Lock()
|
||||
f.mail = m
|
||||
f.lock.Unlock()
|
||||
if f.fail["mail"] {
|
||||
return errors.New("failed to verify sender")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeTestSMTP) Rcpt(r string) error {
|
||||
f.lock.Lock()
|
||||
f.rcpt = r
|
||||
f.lock.Unlock()
|
||||
if f.fail["rcpt"] {
|
||||
return errors.New("failed to verify receiver")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeTestSMTP) Quit() error {
|
||||
f.lock.Lock()
|
||||
f.quitCount++
|
||||
f.lock.Unlock()
|
||||
if f.fail["quit"] {
|
||||
return errors.New("failed to quit")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeTestSMTP) Close() error {
|
||||
f.close = true
|
||||
if f.fail["close"] {
|
||||
return errors.New("failed to close")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeTestSMTP) Data() (io.WriteCloser, error) {
|
||||
if f.fail["data"] {
|
||||
return nil, errors.New("failed to send")
|
||||
}
|
||||
return nopCloser{&f.buff}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTestSMTP) readRcpt() string {
|
||||
f.lock.RLock()
|
||||
defer f.lock.RUnlock()
|
||||
return f.rcpt
|
||||
}
|
||||
|
||||
func (f *fakeTestSMTP) readMail() string {
|
||||
f.lock.RLock()
|
||||
defer f.lock.RUnlock()
|
||||
return f.mail
|
||||
}
|
||||
|
||||
func (f *fakeTestSMTP) readQuitCount() int {
|
||||
f.lock.RLock()
|
||||
defer f.lock.RUnlock()
|
||||
return f.quitCount
|
||||
`)
|
||||
}
|
||||
|
||||
func TokenGenFn(user, _, _ string) (string, error) {
|
||||
if user == "error" {
|
||||
return "", errors.New("token generation error")
|
||||
return "", fmt.Errorf("token generation error")
|
||||
}
|
||||
return "token", nil
|
||||
}
|
||||
|
||||
type nopCloser struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func (nopCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
+126
-35
@@ -9,14 +9,15 @@ import (
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
)
|
||||
|
||||
// Service delivers notifications to multiple destinations
|
||||
type Service struct {
|
||||
dataService Store
|
||||
destinations []Destination
|
||||
queue chan Request
|
||||
dataService Store
|
||||
destinations []Destination
|
||||
queue chan Request
|
||||
verificationQueue chan VerificationRequest
|
||||
|
||||
closed uint32 // non-zero means closed. uses uint instead of bool for atomic
|
||||
ctx context.Context
|
||||
@@ -26,27 +27,33 @@ type Service struct {
|
||||
// Destination defines interface for a given destination service, like telegram, email and so on
|
||||
type Destination interface {
|
||||
fmt.Stringer
|
||||
Send(ctx context.Context, req Request) error
|
||||
Send(context.Context, Request) error
|
||||
SendVerification(context.Context, VerificationRequest) error
|
||||
}
|
||||
|
||||
// Store defines the minimal interface accessing stored comments used by notifier
|
||||
type Store interface {
|
||||
Get(locator store.Locator, id string, user store.User) (store.Comment, error)
|
||||
GetUserEmail(siteID string, userID string) (string, error)
|
||||
GetUserEmail(siteID, userID string) (string, error)
|
||||
GetUserTelegram(siteID, userID string) (string, error)
|
||||
}
|
||||
|
||||
// Request notification either about comment or about particular user verification
|
||||
// used for email and telegram retrieval from user details
|
||||
type getUserDetail func(string, string) (string, error)
|
||||
|
||||
// Request notification for a Comment
|
||||
type Request struct {
|
||||
Comment store.Comment // if set sent notifications about new comment
|
||||
parent store.Comment // fetched only in case Comment is set
|
||||
Email string // if set (also) send email
|
||||
Verification VerificationMetadata // if set sent verification notification
|
||||
Comment store.Comment
|
||||
parent store.Comment
|
||||
Emails []string
|
||||
Telegrams []string
|
||||
}
|
||||
|
||||
// VerificationMetadata required to send notify method verification message
|
||||
type VerificationMetadata struct {
|
||||
// VerificationRequest notification for user
|
||||
type VerificationRequest struct {
|
||||
SiteID string
|
||||
User string
|
||||
Email string // if set, send email only
|
||||
Token string
|
||||
}
|
||||
|
||||
@@ -60,11 +67,12 @@ func NewService(dataService Store, size int, destinations ...Destination) *Servi
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
res := Service{
|
||||
dataService: dataService,
|
||||
queue: make(chan Request, size),
|
||||
destinations: destinations,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
dataService: dataService,
|
||||
queue: make(chan Request, size),
|
||||
verificationQueue: make(chan VerificationRequest, size),
|
||||
destinations: destinations,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
if len(destinations) > 0 {
|
||||
go res.do()
|
||||
@@ -78,14 +86,11 @@ func (s *Service) Submit(req Request) {
|
||||
if len(s.destinations) == 0 || atomic.LoadUint32(&s.closed) != 0 {
|
||||
return
|
||||
}
|
||||
// parent comment is fetched only if comment is present in the Request
|
||||
if s.dataService != nil && req.Comment.ParentID != "" {
|
||||
if p, err := s.dataService.Get(req.Comment.Locator, req.Comment.ParentID, store.User{}); err == nil {
|
||||
req.parent = p
|
||||
req.Email, err = s.dataService.GetUserEmail(req.Comment.Locator.SiteID, p.User.ID)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] can't read email for %s, %v", p.User.ID, err)
|
||||
}
|
||||
req.Emails = s.getNotificationTargets(req, p, s.dataService.GetUserEmail)
|
||||
req.Telegrams = s.getNotificationTargets(req, p, s.dataService.GetUserTelegram)
|
||||
}
|
||||
}
|
||||
select {
|
||||
@@ -95,11 +100,58 @@ func (s *Service) Submit(req Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// getNotificationTargets returns list of notification targets (like email or telegram username) for users
|
||||
// interested in notifications for provided comment.
|
||||
// Targets are not added to the returned list in case the original message
|
||||
// is from the same user as the notification receiver.
|
||||
// Results are deduplicated.
|
||||
func (s *Service) getNotificationTargets(
|
||||
req Request,
|
||||
notifyComment store.Comment,
|
||||
getUserDetail getUserDetail,
|
||||
) (result []string) {
|
||||
// add current user email only if the user is not the one who wrote the original comment
|
||||
if notifyComment.User.ID != req.Comment.User.ID {
|
||||
detail, err := getUserDetail(req.Comment.Locator.SiteID, notifyComment.User.ID)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] can't read notification detail for %s, %v", notifyComment.User.ID, err)
|
||||
}
|
||||
if detail != "" {
|
||||
result = append(result, detail)
|
||||
}
|
||||
}
|
||||
if notifyComment.ParentID != "" {
|
||||
if p, err := s.dataService.Get(req.Comment.Locator, notifyComment.ParentID, store.User{}); err == nil {
|
||||
result = append(result, s.getNotificationTargets(req, p, getUserDetail)...)
|
||||
}
|
||||
}
|
||||
return deduplicateStrings(result)
|
||||
}
|
||||
|
||||
// SubmitVerification to internal channel if not busy, drop if can't send
|
||||
func (s *Service) SubmitVerification(req VerificationRequest) {
|
||||
if len(s.destinations) == 0 || atomic.LoadUint32(&s.closed) != 0 {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case s.verificationQueue <- req:
|
||||
default:
|
||||
log.Printf("[WARN] can't send verification to queue, %s for %s", req.User, req.Email)
|
||||
}
|
||||
}
|
||||
|
||||
// Close queue channel and wait for completion
|
||||
func (s *Service) Close() {
|
||||
if s.queue != nil {
|
||||
// don't panic in case service is already closed
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
log.Print("[DEBUG] close notifier")
|
||||
close(s.queue)
|
||||
close(s.verificationQueue)
|
||||
s.cancel()
|
||||
<-s.ctx.Done()
|
||||
}
|
||||
@@ -107,21 +159,60 @@ func (s *Service) Close() {
|
||||
}
|
||||
|
||||
func (s *Service) do() {
|
||||
for c := range s.queue {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(s.destinations))
|
||||
for _, dest := range s.destinations {
|
||||
go func(d Destination) {
|
||||
if err := d.Send(s.ctx, c); err != nil {
|
||||
log.Printf("[WARN] failed to send to %s, %s", d, err)
|
||||
}
|
||||
wg.Done()
|
||||
}(dest)
|
||||
defer log.Print("[WARN] terminated notifier")
|
||||
var wg sync.WaitGroup
|
||||
for {
|
||||
select {
|
||||
case c, ok := <-s.queue:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
wg.Add(len(s.destinations))
|
||||
for _, dest := range s.destinations {
|
||||
go func(d Destination) {
|
||||
if err := d.Send(s.ctx, c); err != nil {
|
||||
log.Printf("[WARN] failed to send to %s, %s", d, err)
|
||||
}
|
||||
wg.Done()
|
||||
}(dest)
|
||||
}
|
||||
wg.Wait()
|
||||
case v, ok := <-s.verificationQueue:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
wg.Add(len(s.destinations))
|
||||
for _, dest := range s.destinations {
|
||||
go func(d Destination) {
|
||||
if err := d.SendVerification(s.ctx, v); err != nil {
|
||||
log.Printf("[WARN] failed to send to %s, %s", d, err)
|
||||
}
|
||||
wg.Done()
|
||||
}(dest)
|
||||
}
|
||||
wg.Wait()
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
log.Print("[WARN] terminated notifier")
|
||||
}
|
||||
|
||||
// NopService is do-nothing notifier, without destinations
|
||||
var NopService = &Service{}
|
||||
|
||||
// deduplicateStrings returns provided slice of strings will all duplicates removed.
|
||||
// Resulting slice is not sorted.
|
||||
func deduplicateStrings(source []string) []string {
|
||||
set := make(map[string]struct{})
|
||||
|
||||
for _, k := range source {
|
||||
set[k] = struct{}{}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
result = append(result, k)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -9,13 +9,16 @@ import (
|
||||
log "github.com/go-pkgz/lgr"
|
||||
)
|
||||
|
||||
// MockDest is a destination mock
|
||||
type MockDest struct {
|
||||
data []Request
|
||||
id int
|
||||
closed bool
|
||||
lock sync.Mutex
|
||||
data []Request
|
||||
verificationData []VerificationRequest
|
||||
id int
|
||||
closed bool
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
// Send mock
|
||||
func (m *MockDest) Send(ctx context.Context, r Request) error {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
@@ -30,6 +33,22 @@ func (m *MockDest) Send(ctx context.Context, r Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendVerification mock
|
||||
func (m *MockDest) SendVerification(ctx context.Context, v VerificationRequest) error {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
select {
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
m.verificationData = append(m.verificationData, v)
|
||||
log.Printf("sent verification %s -> %d", v.User, m.id)
|
||||
case <-ctx.Done():
|
||||
log.Printf("verification ctx closed %d", m.id)
|
||||
m.closed = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get mock
|
||||
func (m *MockDest) Get() []Request {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
@@ -37,4 +56,14 @@ func (m *MockDest) Get() []Request {
|
||||
copy(res, m.data)
|
||||
return res
|
||||
}
|
||||
|
||||
// GetVerify mock
|
||||
func (m *MockDest) GetVerify() []VerificationRequest {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
res := make([]VerificationRequest, len(m.verificationData))
|
||||
copy(res, m.verificationData)
|
||||
return res
|
||||
}
|
||||
|
||||
func (m *MockDest) String() string { return fmt.Sprintf("mock id=%d, closed=%v", m.id, m.closed) }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sync/atomic"
|
||||
@@ -11,7 +10,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
)
|
||||
|
||||
func TestService_NoDestinations(t *testing.T) {
|
||||
@@ -22,6 +21,8 @@ func TestService_NoDestinations(t *testing.T) {
|
||||
s.Submit(Request{Comment: store.Comment{ID: "123"}})
|
||||
s.Submit(Request{Comment: store.Comment{ID: "123"}})
|
||||
s.Close()
|
||||
// second call should not result in panic
|
||||
s.Close()
|
||||
}
|
||||
|
||||
func TestService_WithDestinations(t *testing.T) {
|
||||
@@ -52,15 +53,42 @@ func TestService_WithDrops(t *testing.T) {
|
||||
|
||||
s.Submit(Request{Comment: store.Comment{ID: "100"}})
|
||||
s.Submit(Request{Comment: store.Comment{ID: "101"}})
|
||||
time.Sleep(time.Millisecond * 11)
|
||||
s.Submit(Request{Comment: store.Comment{ID: "102"}})
|
||||
time.Sleep(time.Millisecond * 11)
|
||||
time.Sleep(time.Millisecond * 21)
|
||||
s.Close()
|
||||
|
||||
s.Submit(Request{Comment: store.Comment{ID: "111"}}) // safe to send after close
|
||||
|
||||
assert.Equal(t, 2, len(d1.Get()), "one comment from three dropped from d1, got: %v", d1.Get())
|
||||
assert.Equal(t, 2, len(d2.Get()), "one comment from three dropped from d2, got: %v", d2.Get())
|
||||
assert.LessOrEqual(t, len(d1.Get()), 2, "at least one comment from three dropped from d1, got: %v", d1.Get())
|
||||
assert.LessOrEqual(t, len(d2.Get()), 2, "at least one comment from three dropped from d2, got: %v", d2.Get())
|
||||
}
|
||||
|
||||
func TestService_SubmitVerificationWithDrops(t *testing.T) {
|
||||
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
||||
s := NewService(nil, 1, d1, d2)
|
||||
assert.NotNil(t, s)
|
||||
|
||||
s.SubmitVerification(VerificationRequest{
|
||||
SiteID: "remark",
|
||||
User: "testUser",
|
||||
Email: "test@example.org",
|
||||
Token: "testToken",
|
||||
})
|
||||
s.SubmitVerification(VerificationRequest{})
|
||||
s.SubmitVerification(VerificationRequest{})
|
||||
time.Sleep(time.Millisecond * 21)
|
||||
s.Close()
|
||||
|
||||
s.SubmitVerification(VerificationRequest{}) // safe to send after close
|
||||
|
||||
assert.LessOrEqual(t, len(d2.GetVerify()), 2, "one request from three dropped from d2, got: %v", d2.GetVerify())
|
||||
|
||||
verifyDest := d1.GetVerify()
|
||||
require.LessOrEqual(t, len(verifyDest), 2, "one request from three dropped from d1, got: %v", verifyDest)
|
||||
assert.Equal(t, "remark", verifyDest[0].SiteID)
|
||||
assert.Equal(t, "testUser", verifyDest[0].User)
|
||||
assert.Equal(t, "test@example.org", verifyDest[0].Email)
|
||||
assert.Equal(t, "testToken", verifyDest[0].Token)
|
||||
}
|
||||
|
||||
func TestService_Many(t *testing.T) {
|
||||
@@ -70,16 +98,20 @@ func TestService_Many(t *testing.T) {
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
s.Submit(Request{Comment: store.Comment{ID: fmt.Sprintf("%d", 100+i)}})
|
||||
s.SubmitVerification(VerificationRequest{User: fmt.Sprintf("%d", 100+i)})
|
||||
time.Sleep(time.Millisecond * time.Duration(rand.Int31n(20)))
|
||||
}
|
||||
s.Close()
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
|
||||
assert.NotEqual(t, 10, len(d1.Get()), "some comments dropped from d1")
|
||||
assert.NotEqual(t, 10, len(d1.GetVerify()), "some verifications dropped from d1")
|
||||
assert.NotEqual(t, 10, len(d2.Get()), "some comments dropped from d2")
|
||||
assert.NotEqual(t, 10, len(d2.GetVerify()), "some verifications dropped from d2")
|
||||
|
||||
assert.True(t, d1.closed)
|
||||
assert.True(t, d2.closed)
|
||||
assert.Equal(t, "mock id=1, closed=true", d1.String())
|
||||
}
|
||||
|
||||
func TestService_WithParent(t *testing.T) {
|
||||
@@ -106,6 +138,138 @@ func TestService_WithParent(t *testing.T) {
|
||||
assert.Equal(t, "", destRes[1].parent.ID)
|
||||
}
|
||||
|
||||
func TestService_EmailRetrieval(t *testing.T) {
|
||||
dest := &MockDest{id: 1}
|
||||
dataStore := &mockStore{data: map[string]store.Comment{}, userDetails: map[string]string{}}
|
||||
|
||||
dataStore.data["p1"] = store.Comment{ID: "p1", User: store.User{ID: "u1"}}
|
||||
dataStore.data["p2"] = store.Comment{ID: "p2", ParentID: "p1", User: store.User{ID: "u1"}}
|
||||
dataStore.data["p3"] = store.Comment{ID: "p3", ParentID: "p1", User: store.User{ID: "u2"}}
|
||||
dataStore.data["p4"] = store.Comment{ID: "p4", ParentID: "p3", User: store.User{ID: "u1"}}
|
||||
dataStore.userDetails["u1"] = "u1@example.com"
|
||||
|
||||
s := NewService(dataStore, 1, dest)
|
||||
assert.NotNil(t, s)
|
||||
|
||||
// one comment, one notification
|
||||
s.Submit(Request{Comment: dataStore.data["p1"]})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
|
||||
destRes := dest.Get()
|
||||
require.Equal(t, 1, len(destRes), "one comment notified")
|
||||
assert.Equal(t, "p1", destRes[0].Comment.ID)
|
||||
assert.Empty(t, destRes[0].parent)
|
||||
assert.Empty(t, destRes[0].Emails)
|
||||
|
||||
// reply to the first comment, same comment as one in original comment
|
||||
s.Submit(Request{Comment: dataStore.data["p2"]})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 2, len(destRes), "two comment notified")
|
||||
assert.Equal(t, "p2", destRes[1].Comment.ID)
|
||||
assert.Equal(t, "p1", destRes[1].parent.ID)
|
||||
assert.Equal(t, "u1", destRes[1].parent.User.ID)
|
||||
assert.Empty(t, destRes[1].Emails, "u1 is not notified they are the one who left the comment")
|
||||
|
||||
// another reply to the first comment, another user
|
||||
s.Submit(Request{Comment: dataStore.data["p3"]})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 3, len(destRes), "three comment notified")
|
||||
assert.Equal(t, "p3", destRes[2].Comment.ID)
|
||||
assert.Equal(t, "p1", destRes[2].parent.ID)
|
||||
assert.Equal(t, "u1", destRes[2].parent.User.ID)
|
||||
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[2].Emails)
|
||||
|
||||
// reply to the last comment by another user, should trigger email retrieval error
|
||||
s.Submit(Request{Comment: dataStore.data["p4"]})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 4, len(destRes), "four comment notified")
|
||||
assert.Equal(t, "p4", destRes[3].Comment.ID)
|
||||
assert.Equal(t, "p3", destRes[3].parent.ID)
|
||||
assert.Equal(t, "u2", destRes[3].parent.User.ID)
|
||||
assert.Empty(t, destRes[3].Emails, "no email can be retrieved for u2")
|
||||
|
||||
s.Close()
|
||||
}
|
||||
|
||||
func TestService_Recursive(t *testing.T) {
|
||||
dest := &MockDest{id: 1}
|
||||
dataStore := &mockStore{data: map[string]store.Comment{}, userDetails: map[string]string{}}
|
||||
|
||||
dataStore.data["p1"] = store.Comment{ID: "p1", User: store.User{ID: "u1"}}
|
||||
dataStore.data["p2"] = store.Comment{ID: "p2", ParentID: "p1", User: store.User{ID: "u2"}}
|
||||
dataStore.data["p3"] = store.Comment{ID: "p3", ParentID: "p2", User: store.User{ID: "u3"}}
|
||||
dataStore.data["p4"] = store.Comment{ID: "p4", ParentID: "p3", User: store.User{ID: "u1"}}
|
||||
dataStore.data["p5"] = store.Comment{ID: "p5", ParentID: "p4", User: store.User{ID: "u4"}}
|
||||
dataStore.userDetails["u1"] = "u1@example.com"
|
||||
// second comment goes without email address for notification
|
||||
dataStore.userDetails["u3"] = "u3@example.com"
|
||||
|
||||
s := NewService(dataStore, 1, dest)
|
||||
assert.NotNil(t, s)
|
||||
|
||||
// one comment from u1 with email set
|
||||
s.Submit(Request{Comment: dataStore.data["p1"]})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
|
||||
destRes := dest.Get()
|
||||
require.Equal(t, 1, len(destRes), "one comment notified")
|
||||
assert.Equal(t, "p1", destRes[0].Comment.ID)
|
||||
assert.Empty(t, destRes[0].parent)
|
||||
assert.Empty(t, destRes[0].Emails)
|
||||
|
||||
// reply to the first comment from u2 without email set
|
||||
s.Submit(Request{Comment: dataStore.data["p2"]})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 2, len(destRes), "two comment notified")
|
||||
assert.Equal(t, "p2", destRes[1].Comment.ID)
|
||||
assert.Equal(t, "p1", destRes[1].parent.ID)
|
||||
assert.Equal(t, "u1", destRes[1].parent.User.ID)
|
||||
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[1].Emails)
|
||||
|
||||
// reply to the second comment from u3 with email set
|
||||
s.Submit(Request{Comment: dataStore.data["p3"]})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 3, len(destRes), "three comment notified")
|
||||
assert.Equal(t, "p3", destRes[2].Comment.ID)
|
||||
assert.Equal(t, "p2", destRes[2].parent.ID)
|
||||
assert.Equal(t, "u2", destRes[2].parent.User.ID)
|
||||
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[2].Emails)
|
||||
|
||||
// reply to the third comment from u1 (author of the first comment), only u3 should be notified
|
||||
s.Submit(Request{Comment: dataStore.data["p4"]})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 4, len(destRes), "four comment notified once each")
|
||||
assert.Equal(t, "p4", destRes[3].Comment.ID)
|
||||
assert.Equal(t, "p3", destRes[3].parent.ID)
|
||||
assert.Equal(t, "u3", destRes[3].parent.User.ID)
|
||||
assert.ElementsMatch(t, []string{"u3@example.com"}, destRes[3].Emails, "u1 is not notified they are the one who left the comment")
|
||||
|
||||
// reply to the fourth comment from u4, u1 and u3 should be notified once as a result
|
||||
s.Submit(Request{Comment: dataStore.data["p5"]})
|
||||
time.Sleep(time.Millisecond * 110)
|
||||
|
||||
destRes = dest.Get()
|
||||
require.Equal(t, 5, len(destRes), "four comment notified once each")
|
||||
assert.Equal(t, "p5", destRes[4].Comment.ID)
|
||||
assert.Equal(t, "p4", destRes[4].parent.ID)
|
||||
assert.Equal(t, "u1", destRes[4].parent.User.ID)
|
||||
assert.ElementsMatch(t, []string{"u1@example.com", "u3@example.com"}, destRes[4].Emails, "u3 and u1 notified once")
|
||||
|
||||
s.Close()
|
||||
}
|
||||
|
||||
func TestService_Nop(t *testing.T) {
|
||||
s := NopService
|
||||
s.Submit(Request{Comment: store.Comment{}})
|
||||
@@ -113,16 +277,31 @@ func TestService_Nop(t *testing.T) {
|
||||
assert.Equal(t, uint32(1), atomic.LoadUint32(&s.closed))
|
||||
}
|
||||
|
||||
type mockStore struct{ data map[string]store.Comment }
|
||||
type mockStore struct {
|
||||
data map[string]store.Comment
|
||||
userDetails map[string]string
|
||||
}
|
||||
|
||||
func (m mockStore) getUserDetail(userID string) (string, error) {
|
||||
detail, ok := m.userDetails[userID]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no such user")
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func (m mockStore) Get(_ store.Locator, id string, _ store.User) (store.Comment, error) {
|
||||
res, ok := m.data[id]
|
||||
if !ok {
|
||||
return store.Comment{}, errors.New("no such id")
|
||||
return store.Comment{}, fmt.Errorf("no such id")
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (m mockStore) GetUserEmail(_ string, _ string) (string, error) {
|
||||
return "", errors.New("no such user")
|
||||
func (m mockStore) GetUserEmail(_, userID string) (string, error) {
|
||||
return m.getUserDetail(userID)
|
||||
}
|
||||
|
||||
func (m mockStore) GetUserTelegram(_, userID string) (string, error) {
|
||||
return m.getUserDetail(userID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
ntf "github.com/go-pkgz/notify"
|
||||
)
|
||||
|
||||
// Slack implements notify.Destination for Slack
|
||||
type Slack struct {
|
||||
*ntf.Slack
|
||||
|
||||
channelName string
|
||||
}
|
||||
|
||||
// NewSlack makes Slack bot for notifications
|
||||
func NewSlack(token, channelName string) *Slack {
|
||||
log.Printf("[DEBUG] create new slack notifier for chan %s", channelName)
|
||||
if channelName == "" {
|
||||
channelName = "general"
|
||||
}
|
||||
|
||||
return &Slack{Slack: ntf.NewSlack(token), channelName: channelName}
|
||||
}
|
||||
|
||||
// Send to Slack channel
|
||||
func (s *Slack) Send(ctx context.Context, req Request) error {
|
||||
log.Printf("[DEBUG] send slack notification, comment id %s", req.Comment.ID)
|
||||
|
||||
user := req.Comment.User.Name
|
||||
if req.Comment.ParentID != "" {
|
||||
user += " → " + req.parent.User.Name
|
||||
}
|
||||
|
||||
title := "↦ original comment"
|
||||
if req.Comment.PostTitle != "" {
|
||||
title = "↦ " + req.Comment.PostTitle
|
||||
}
|
||||
|
||||
destination := fmt.Sprintf(
|
||||
"slack:%s?title=%s&attachmentText=%s&titleLink=%s",
|
||||
s.channelName,
|
||||
url.QueryEscape(title),
|
||||
url.QueryEscape(req.Comment.Orig),
|
||||
url.QueryEscape(req.Comment.Locator.URL+uiNav+req.Comment.ID),
|
||||
)
|
||||
|
||||
return s.Slack.Send(ctx, destination, "New comment from "+user)
|
||||
}
|
||||
|
||||
// SendVerification is not implemented for Slack
|
||||
func (s *Slack) SendVerification(_ context.Context, _ VerificationRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Slack) String() string {
|
||||
return s.Slack.String() + " for channel " + s.channelName + ""
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
)
|
||||
|
||||
func TestSlack_New(t *testing.T) {
|
||||
ts := NewSlack("", "")
|
||||
assert.NotNil(t, ts)
|
||||
assert.Equal(t, "general", ts.channelName)
|
||||
}
|
||||
|
||||
func TestSlack_Send(t *testing.T) {
|
||||
ts := NewSlack("", "")
|
||||
|
||||
c := store.Comment{PostTitle: "test title", Text: "some text", ParentID: "1", ID: "999"}
|
||||
c.User.Name = "from"
|
||||
cp := store.Comment{Text: "some parent text"}
|
||||
cp.User.Name = "to"
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
err := ts.Send(ctx, Request{Comment: c, parent: cp})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSlack_Name(t *testing.T) {
|
||||
tb := NewSlack("", "test-channel")
|
||||
assert.Equal(t, "slack notifications destination for channel test-channel", tb.String())
|
||||
}
|
||||
|
||||
func TestSlack_SendVerification(t *testing.T) {
|
||||
ts := NewSlack("", "")
|
||||
assert.NoError(t, ts.SendVerification(context.Background(), VerificationRequest{}))
|
||||
}
|
||||
+84
-121
@@ -1,152 +1,115 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
"github.com/go-pkgz/repeater"
|
||||
"github.com/pkg/errors"
|
||||
ntf "github.com/go-pkgz/notify"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
)
|
||||
|
||||
// TelegramParams contain settings for telegram notifications
|
||||
type TelegramParams struct {
|
||||
AdminChannelID string // unique identifier for the target chat or username of the target channel (in the format @channelusername)
|
||||
Token string // token for telegram bot API interactions
|
||||
Timeout time.Duration // http client timeout
|
||||
UserNotifications bool // flag which enables user notifications
|
||||
ErrorMsg, SuccessMsg string // messages for successful and unsuccessful subscription requests to bot
|
||||
}
|
||||
|
||||
// Telegram implements notify.Destination for telegram
|
||||
type Telegram struct {
|
||||
channelID string // unique identifier for the target chat or username of the target channel (in the format @channelusername)
|
||||
token string
|
||||
apiPrefix string
|
||||
timeout time.Duration
|
||||
}
|
||||
*ntf.Telegram
|
||||
|
||||
const telegramTimeOut = 5000 * time.Millisecond
|
||||
const telegramAPIPrefix = "https://api.telegram.org/bot"
|
||||
AdminChannelID string // unique identifier for the target chat or username of the target channel (in the format @channelusername)
|
||||
UserNotifications bool // flag which enables user notifications
|
||||
}
|
||||
|
||||
// NewTelegram makes telegram bot for notifications
|
||||
func NewTelegram(token string, channelID string, timeout time.Duration, api string) (*Telegram, error) {
|
||||
|
||||
if _, err := strconv.ParseInt(channelID, 10, 64); err != nil {
|
||||
channelID = "@" + channelID // if channelID not a number enforce @ prefix
|
||||
}
|
||||
|
||||
res := Telegram{channelID: channelID, token: token, apiPrefix: api, timeout: timeout}
|
||||
if res.apiPrefix == "" {
|
||||
res.apiPrefix = telegramAPIPrefix
|
||||
}
|
||||
if res.timeout == 0 {
|
||||
res.timeout = telegramTimeOut
|
||||
}
|
||||
log.Printf("[DEBUG] create new telegram notifier for chan %s, timeout=%s, api=%s", channelID, res.timeout, res.timeout)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := repeater.NewDefault(5, time.Millisecond*250).Do(ctx, func() error {
|
||||
client := http.Client{Timeout: telegramTimeOut}
|
||||
resp, err := client.Get(fmt.Sprintf("%s%s/getMe", res.apiPrefix, token))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "can't initialize telegram notifications")
|
||||
}
|
||||
defer func() {
|
||||
if err = resp.Body.Close(); err != nil {
|
||||
log.Printf("[WARN] can't close request body, %s", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return errors.Errorf("unexpected telegram status code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
tgResp := struct {
|
||||
OK bool `json:"ok"`
|
||||
Result struct {
|
||||
FirstName string `json:"first_name"`
|
||||
ID uint64 `json:"id"`
|
||||
IsBot bool `json:"is_bot"`
|
||||
UserName string `json:"username"`
|
||||
}
|
||||
}{}
|
||||
|
||||
if err = json.NewDecoder(resp.Body).Decode(&tgResp); err != nil {
|
||||
return errors.Wrap(err, "can't decode response")
|
||||
}
|
||||
|
||||
if !tgResp.OK || !tgResp.Result.IsBot {
|
||||
return errors.Errorf("unexpected telegram response %+v", tgResp)
|
||||
}
|
||||
return nil
|
||||
func NewTelegram(params TelegramParams) (*Telegram, error) {
|
||||
client, err := ntf.NewTelegram(ntf.TelegramParams{
|
||||
Token: params.Token,
|
||||
Timeout: params.Timeout,
|
||||
ErrorMsg: params.ErrorMsg,
|
||||
SuccessMsg: params.SuccessMsg,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &res, err
|
||||
return &Telegram{Telegram: client, AdminChannelID: params.AdminChannelID, UserNotifications: params.UserNotifications}, nil
|
||||
}
|
||||
|
||||
// Send to telegram channel
|
||||
// Send to telegram recipients
|
||||
func (t *Telegram) Send(ctx context.Context, req Request) error {
|
||||
if req.Comment.ID == "" {
|
||||
// verification request received, send nothing
|
||||
return nil
|
||||
}
|
||||
client := http.Client{Timeout: telegramTimeOut}
|
||||
log.Printf("[DEBUG] send telegram notification to %s, comment id %s", t.channelID, req.Comment.ID)
|
||||
log.Printf("[DEBUG] send telegram notification for comment ID %s", req.Comment.ID)
|
||||
result := new(multierror.Error)
|
||||
|
||||
from := req.Comment.User.Name
|
||||
if req.Comment.ParentID != "" {
|
||||
from += " → " + req.parent.User.Name
|
||||
}
|
||||
from = "*" + from + "*"
|
||||
link := fmt.Sprintf("↦ [original comment](%s)", req.Comment.Locator.URL+uiNav+req.Comment.ID)
|
||||
if req.Comment.PostTitle != "" {
|
||||
link = fmt.Sprintf("↦ [%s](%s)", req.Comment.PostTitle, req.Comment.Locator.URL+uiNav+req.Comment.ID)
|
||||
}
|
||||
u := fmt.Sprintf("%s%s/sendMessage?chat_id=%s&parse_mode=Markdown&disable_web_page_preview=true",
|
||||
t.apiPrefix, t.token, t.channelID)
|
||||
msg := t.buildMessage(req)
|
||||
|
||||
msg := fmt.Sprintf("%s\n\n%s\n\n%s", from, req.Comment.Orig, link)
|
||||
msg = html.UnescapeString(msg)
|
||||
body := struct {
|
||||
Text string `json:"text"`
|
||||
}{Text: msg}
|
||||
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to make telegram body")
|
||||
}
|
||||
|
||||
r, err := http.NewRequest("POST", u, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to make telegram request")
|
||||
}
|
||||
r.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
r = r.WithContext(ctx)
|
||||
resp, err := client.Do(r)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to get telegram response")
|
||||
}
|
||||
defer func() {
|
||||
if err = resp.Body.Close(); err != nil {
|
||||
log.Printf("[WARN] can't close request body, %s", err)
|
||||
if t.AdminChannelID != "" {
|
||||
err := t.Telegram.Send(ctx, fmt.Sprintf("telegram:%s?parseMode=HTML", t.AdminChannelID), msg)
|
||||
if err != nil {
|
||||
result = multierror.Append(result,
|
||||
fmt.Errorf("problem sending admin telegram notification about comment ID %s to %s: %w",
|
||||
req.Comment.ID, t.AdminChannelID, err,
|
||||
),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return errors.Errorf("unexpected telegram status code %d for url %q", resp.StatusCode, u)
|
||||
}
|
||||
|
||||
tgResp := struct {
|
||||
OK bool `json:"ok"`
|
||||
}{}
|
||||
|
||||
if err = json.NewDecoder(resp.Body).Decode(&tgResp); err != nil {
|
||||
return errors.Wrap(err, "can't decode telegram response")
|
||||
if t.UserNotifications {
|
||||
for _, user := range req.Telegrams {
|
||||
err := t.Telegram.Send(ctx, fmt.Sprintf("telegram:%s?parseMode=HTML", user), msg)
|
||||
if err != nil {
|
||||
result = multierror.Append(result,
|
||||
fmt.Errorf("problem sending user telegram notification about comment ID %s to %q: %w",
|
||||
req.Comment.ID, user, err,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.ErrorOrNil()
|
||||
}
|
||||
|
||||
// buildMessage generates message for generic notification about new comment
|
||||
func (t *Telegram) buildMessage(req Request) string {
|
||||
commentURLPrefix := req.Comment.Locator.URL + uiNav
|
||||
|
||||
msg := fmt.Sprintf(`<a href=%q>%s</a>`, commentURLPrefix+req.Comment.ID, ntf.EscapeTelegramText(req.Comment.User.Name))
|
||||
|
||||
if req.Comment.ParentID != "" {
|
||||
msg += fmt.Sprintf(" -> <a href=%q>%s</a>", commentURLPrefix+req.parent.ID, ntf.EscapeTelegramText(req.parent.User.Name))
|
||||
}
|
||||
|
||||
msg += fmt.Sprintf("\n\n%s", ntf.TelegramSupportedHTML(req.Comment.Text))
|
||||
|
||||
if req.Comment.ParentID != "" {
|
||||
msg += fmt.Sprintf("\n\n\"<i>%s</i>\"", ntf.TelegramSupportedHTML(req.parent.Text))
|
||||
}
|
||||
|
||||
if req.Comment.PostTitle != "" {
|
||||
msg += fmt.Sprintf("\n\n↦ <a href=%q>%s</a>", req.Comment.Locator.URL, ntf.EscapeTelegramText(req.Comment.PostTitle))
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
// SendVerification is not needed for telegram
|
||||
func (t *Telegram) SendVerification(_ context.Context, _ VerificationRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Telegram) String() string {
|
||||
return "telegram: " + t.channelID
|
||||
result := t.Telegram.String()
|
||||
if t.AdminChannelID != "" {
|
||||
result += " with admin notifications to " + t.AdminChannelID
|
||||
}
|
||||
if t.UserNotifications {
|
||||
result += " with user notifications enabled"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -2,122 +2,61 @@ package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
ntf "github.com/go-pkgz/notify"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
)
|
||||
|
||||
func TestTelegram_New(t *testing.T) {
|
||||
|
||||
ts := mockTelegramServer()
|
||||
defer ts.Close()
|
||||
|
||||
tb, err := NewTelegram("good-token", "remark_test", 2*time.Second, ts.URL+"/")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, tb)
|
||||
assert.Equal(t, "@remark_test", tb.channelID, "@ added")
|
||||
|
||||
st := time.Now()
|
||||
_, err = NewTelegram("bad-resp", "remark_test", 2*time.Second, ts.URL+"/")
|
||||
assert.EqualError(t, err, "unexpected telegram response {OK:false Result:{FirstName:comments_test ID:707381019 IsBot:false UserName:remark42_test_bot}}")
|
||||
assert.True(t, time.Since(st) >= 250*5*time.Millisecond)
|
||||
|
||||
_, err = NewTelegram("non-json-resp", "remark_test", 2*time.Second, ts.URL+"/")
|
||||
func TestTelegram_NewError(t *testing.T) {
|
||||
tb, err := NewTelegram(TelegramParams{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "can't decode response:")
|
||||
|
||||
_, err = NewTelegram("404", "remark_test", 2*time.Second, ts.URL+"/")
|
||||
assert.EqualError(t, err, "unexpected telegram status code 404")
|
||||
|
||||
_, err = NewTelegram("no-such-thing", "remark_test", 2*time.Second, "http://127.0.0.1:4321/")
|
||||
assert.EqualError(t, err, "can't initialize telegram notifications: Get http://127.0.0.1:4321/no-such-thing/getMe: dial tcp 127.0.0.1:4321: connect: connection refused")
|
||||
|
||||
_, err = NewTelegram("good-token", "remark_test", 2*time.Second, "")
|
||||
assert.Error(t, err, "empty api url not allowed")
|
||||
|
||||
_, err = NewTelegram("good-token", "remark_test", 0, ts.URL+"/")
|
||||
assert.NoError(t, err, "0 timeout allowed as default")
|
||||
|
||||
tb, err = NewTelegram("good-token", "1234567890", 2*time.Second, ts.URL+"/")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, tb)
|
||||
assert.Equal(t, "1234567890", tb.channelID, "no @ prefix")
|
||||
assert.Nil(t, tb)
|
||||
}
|
||||
|
||||
func TestTelegram_Send(t *testing.T) {
|
||||
ts := mockTelegramServer()
|
||||
defer ts.Close()
|
||||
|
||||
tb, err := NewTelegram("good-token", "remark_test", 2*time.Second, ts.URL+"/")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, tb)
|
||||
c := store.Comment{Text: "some text", ParentID: "1", ID: "999"}
|
||||
tb := Telegram{
|
||||
AdminChannelID: "remark_test",
|
||||
UserNotifications: true,
|
||||
Telegram: &ntf.Telegram{}, // broken sender due to unset API
|
||||
}
|
||||
assert.Equal(t, "telegram notifications destination with admin notifications to remark_test with user notifications enabled", tb.String())
|
||||
c := store.Comment{Text: "some text", ParentID: "1", ID: "999", PostTitle: "[test title]", Locator: store.Locator{URL: "http://example.org/"}}
|
||||
c.User.Name = "from"
|
||||
cp := store.Comment{Text: "some parent text"}
|
||||
cp := store.Comment{Text: `<p>some parent text with a <a href="http://example.org">link</a> and special text:<br>& < > &</p>`}
|
||||
cp.User.Name = "to"
|
||||
|
||||
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
|
||||
assert.NoError(t, err)
|
||||
c.PostTitle = "test title"
|
||||
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
|
||||
assert.NoError(t, err)
|
||||
err := tb.Send(context.Background(), Request{Comment: c, parent: cp, Telegrams: []string{"test_user_channel"}})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "2 errors occurred")
|
||||
assert.Contains(t, err.Error(), "problem sending user telegram notification about comment ID 999 to \"test_user_channel\"")
|
||||
assert.Contains(t, err.Error(), "problem sending admin telegram notification about comment ID 999 to remark_test")
|
||||
|
||||
tb, err = NewTelegram("non-json-resp", "remark_test", 2*time.Second, ts.URL+"/")
|
||||
assert.Error(t, err, "should failed")
|
||||
err = tb.Send(context.TODO(), Request{Comment: c, parent: cp})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unexpected telegram status code 404", "send on broken tg")
|
||||
// test buildMessage separately for message text
|
||||
res := tb.buildMessage(Request{Comment: c, parent: cp})
|
||||
assert.Equal(t, `<a href="http://example.org/#remark42__comment-999">from</a> -> <a href="http://example.org/#remark42__comment-">to</a>
|
||||
|
||||
assert.Equal(t, "telegram: @remark_test", tb.String())
|
||||
require.NoError(t, tb.Send(context.TODO(), Request{}), "Empty Comment doesn't send anything")
|
||||
some text
|
||||
|
||||
"<i>some parent text with a <a href="http://example.org">link</a> and special text:& < > &</i>"
|
||||
|
||||
↦ <a href="http://example.org/">[test title]</a>`,
|
||||
res)
|
||||
|
||||
// special case for text with h1-h6 header
|
||||
ch := store.Comment{Text: "<h1>Hello</h1><h6>World</h6>", ID: "555", Locator: store.Locator{URL: "http://example.org/"}}
|
||||
ch.User.Name = "from"
|
||||
res = tb.buildMessage(Request{Comment: ch})
|
||||
assert.Equal(t, `<a href="http://example.org/#remark42__comment-555">from</a>
|
||||
|
||||
<b>Hello</b><i><b>World</b></i>`,
|
||||
res)
|
||||
}
|
||||
|
||||
func mockTelegramServer() *httptest.Server {
|
||||
router := chi.NewRouter()
|
||||
router.Get("/good-token/getMe", func(w http.ResponseWriter, r *http.Request) {
|
||||
s := `{"ok": true,
|
||||
"result": {
|
||||
"first_name": "comments_test",
|
||||
"id": 707381019,
|
||||
"is_bot": true,
|
||||
"username": "remark42_test_bot"
|
||||
}}`
|
||||
_, _ = w.Write([]byte(s))
|
||||
})
|
||||
router.Get("/bad-resp/getMe", func(w http.ResponseWriter, r *http.Request) {
|
||||
s := `{"ok": false,
|
||||
"result": {
|
||||
"first_name": "comments_test",
|
||||
"id": 707381019,
|
||||
"is_bot": false,
|
||||
"username": "remark42_test_bot"
|
||||
}}`
|
||||
_, _ = w.Write([]byte(s))
|
||||
})
|
||||
router.Get("/non-json-resp/getMe", func(w http.ResponseWriter, r *http.Request) {
|
||||
s := `"ok": false,
|
||||
"result": {
|
||||
"first_name": "comments_test",
|
||||
"id": 707381019,
|
||||
"is_bot": false,
|
||||
"username": "remark42_test_bot"
|
||||
`
|
||||
_, _ = w.Write([]byte(s))
|
||||
})
|
||||
router.Get("/404/getMe", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(404)
|
||||
})
|
||||
|
||||
router.Post("/good-token/sendMessage", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok": true}`))
|
||||
})
|
||||
|
||||
return httptest.NewServer(router)
|
||||
func TestTelegram_SendVerification(t *testing.T) {
|
||||
tb := Telegram{}
|
||||
// empty VerificationRequest should return no error and do nothing, as well as any other
|
||||
assert.NoError(t, tb.SendVerification(context.Background(), VerificationRequest{}))
|
||||
}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
{{
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{{- if .ForAdmin}}
|
||||
New comment from {{.UserName}} on your site {{if .PostTitle}} to «{{.PostTitle}}»{{ end }}
|
||||
{{- else }}
|
||||
New reply from {{.UserName}} on your comment{{if .PostTitle}} to «{{.PostTitle}}»{{ end }}
|
||||
{{- end }}
|
||||
{{- if .ParentCommentText}}
|
||||
{{.ParentUserPicture}}
|
||||
{{.ParentUserName}}
|
||||
{{.ParentCommentDate.Format "02.01.2006 at 15:04"}}
|
||||
Parent comment link: {{.ParentCommentLink}}
|
||||
{{.ParentCommentText}}
|
||||
{{- end }}
|
||||
|
||||
User: {{.UserName}}
|
||||
{{.CommentDate.Format "02.01.2006 at 15:04"}}
|
||||
Comment: {{.CommentText}}
|
||||
{{.Email}} {{if not .ForAdmin}} for {{.ParentUserName}}{{ end }}
|
||||
{{- if .UnsubscribeLink}}
|
||||
Unsubscribe link: {{.UnsubscribeLink}}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,7 @@
|
||||
Confirmation for {{.User}} on site {{.Site}}
|
||||
{{- if .SubscribeURL}}
|
||||
Subscribe url: {{.SubscribeURL}}{{.Token}}
|
||||
{{- end }}
|
||||
Token:{{.Token}}
|
||||
Sent to {{.Email}}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
ntf "github.com/go-pkgz/notify"
|
||||
)
|
||||
|
||||
const (
|
||||
webhookDefaultTemplate = `{"text": "{{.Text}}"}`
|
||||
)
|
||||
|
||||
// WebhookParams contain settings for webhook notifications
|
||||
type WebhookParams struct {
|
||||
URL string
|
||||
Template string
|
||||
Headers []string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Webhook implements notify.Destination for Webhook notifications
|
||||
type Webhook struct {
|
||||
*ntf.Webhook
|
||||
|
||||
url string
|
||||
template *template.Template
|
||||
}
|
||||
|
||||
// NewWebhook makes Webhook
|
||||
func NewWebhook(params WebhookParams) (*Webhook, error) {
|
||||
res := &Webhook{
|
||||
Webhook: ntf.NewWebhook(ntf.WebhookParams{
|
||||
Timeout: params.Timeout,
|
||||
Headers: params.Headers,
|
||||
}),
|
||||
url: params.URL,
|
||||
}
|
||||
|
||||
if res.url == "" {
|
||||
return nil, fmt.Errorf("webhook URL is required for webhook notifications")
|
||||
}
|
||||
|
||||
if params.Template == "" {
|
||||
params.Template = webhookDefaultTemplate
|
||||
}
|
||||
|
||||
payloadTmpl, err := template.New("webhook").Parse(params.Template)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse webhook template: %w", err)
|
||||
}
|
||||
|
||||
res.template = payloadTmpl
|
||||
|
||||
log.Printf("[DEBUG] create new webhook notifier for %s", res.url)
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Send sends Webhook notification
|
||||
func (w *Webhook) Send(ctx context.Context, req Request) error {
|
||||
log.Printf("[DEBUG] send webhook notification, comment id %s", req.Comment.ID)
|
||||
var payload bytes.Buffer
|
||||
err := w.template.Execute(&payload, req.Comment)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to compile webhook template: %w", err)
|
||||
}
|
||||
|
||||
return w.Webhook.Send(ctx, w.url, payload.String())
|
||||
}
|
||||
|
||||
// SendVerification is not implemented for Webhook
|
||||
func (w *Webhook) SendVerification(_ context.Context, _ VerificationRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// String describes the webhook instance
|
||||
func (w *Webhook) String() string {
|
||||
return fmt.Sprintf("%s to %s", w.Webhook.String(), w.url)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
)
|
||||
|
||||
func TestWebhook_NewWebhook(t *testing.T) {
|
||||
wh, err := NewWebhook(WebhookParams{
|
||||
URL: "https://example.org/webhook",
|
||||
Headers: []string{"Authorization:Basic AXVubzpwQDU1dzByYM=="},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, wh)
|
||||
|
||||
assert.Equal(t, "https://example.org/webhook", wh.url)
|
||||
assert.Equal(t, []string{"Authorization:Basic AXVubzpwQDU1dzByYM=="}, wh.Headers)
|
||||
assert.NotNil(t, wh.template)
|
||||
|
||||
wh, err = NewWebhook(WebhookParams{})
|
||||
assert.Nil(t, wh)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "webhook URL is required for webhook notifications", err.Error())
|
||||
|
||||
wh, err = NewWebhook(WebhookParams{URL: "https://example.org/webhook", Template: "{{.Text"})
|
||||
assert.Nil(t, wh)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unable to parse webhook template")
|
||||
}
|
||||
|
||||
func TestWebhook_Send(t *testing.T) {
|
||||
wh, err := NewWebhook(WebhookParams{
|
||||
URL: "bad-url",
|
||||
Headers: []string{"Content-Type:application/json,text/plain", ""},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, wh)
|
||||
|
||||
c := store.Comment{Text: "some text", ParentID: "1", ID: "999"}
|
||||
c.User.Name = "from"
|
||||
|
||||
err = wh.Send(context.Background(), Request{Comment: c})
|
||||
assert.Error(t, err)
|
||||
|
||||
wh, err = NewWebhook(WebhookParams{
|
||||
URL: "https://example.org/webhook",
|
||||
Template: "{{.InvalidProperty}}",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
err = wh.Send(context.Background(), Request{Comment: c})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "webhook template")
|
||||
|
||||
wh, err = NewWebhook(WebhookParams{URL: "https://example.org/webhook"})
|
||||
assert.NoError(t, err)
|
||||
err = wh.Send(nil, Request{Comment: c}) // nolint
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unable to create webhook request")
|
||||
|
||||
wh, err = NewWebhook(WebhookParams{URL: "https://not-existing-url.net"})
|
||||
assert.NoError(t, err)
|
||||
err = wh.Send(context.Background(), Request{Comment: c})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "webhook request failed")
|
||||
}
|
||||
|
||||
func TestWebhook_SendVerification(t *testing.T) {
|
||||
wh, err := NewWebhook(WebhookParams{URL: "https://example.org/webhook"})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, wh)
|
||||
|
||||
err = wh.SendVerification(context.Background(), VerificationRequest{})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestWebhook_String(t *testing.T) {
|
||||
wh, err := NewWebhook(WebhookParams{URL: "https://example.org/webhook", Timeout: time.Minute * 5})
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, wh)
|
||||
|
||||
str := wh.String()
|
||||
assert.Equal(t, "webhook notification with timeout 5m0s to https://example.org/webhook", str)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package providers
|
||||
|
||||
// Both Telegram auth and notifications need to receive messages received by Telegram bot in the loop,
|
||||
// and below is the implementation of such loop which dispatched received events to both receivers,
|
||||
// so that they could work at the same time.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
log "github.com/go-pkgz/lgr"
|
||||
ntf "github.com/go-pkgz/notify"
|
||||
)
|
||||
|
||||
type tgRequester interface {
|
||||
Request(ctx context.Context, method string, b []byte, data interface{}) error
|
||||
}
|
||||
|
||||
// TGUpdatesReceiver used to dispatch telegram updates to multiple receivers
|
||||
type TGUpdatesReceiver interface {
|
||||
fmt.Stringer
|
||||
ProcessUpdate(ctx context.Context, textUpdate string) error
|
||||
}
|
||||
|
||||
// DispatchTelegramUpdates dispatches telegram updates to provided list of receivers
|
||||
// Blocks caller
|
||||
func DispatchTelegramUpdates(ctx context.Context, requester tgRequester, receivers []TGUpdatesReceiver, period time.Duration) {
|
||||
// Identifier of the first update to be requested.
|
||||
// Should be equal to LastSeenUpdateID + 1
|
||||
// See https://core.telegram.org/bots/api#getupdates
|
||||
var updateOffset int
|
||||
|
||||
processUpdatedTicker := time.NewTicker(period)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
processUpdatedTicker.Stop()
|
||||
return
|
||||
case <-processUpdatedTicker.C:
|
||||
url := `getUpdates?allowed_updates=["message"]`
|
||||
if updateOffset != 0 {
|
||||
url += fmt.Sprintf("&offset=%d", updateOffset)
|
||||
}
|
||||
|
||||
var update ntf.TelegramUpdate
|
||||
|
||||
err := requester.Request(ctx, url, nil, &update)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] failed to fetch updates: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, u := range update.Result {
|
||||
if u.UpdateID >= updateOffset {
|
||||
updateOffset = u.UpdateID + 1
|
||||
}
|
||||
}
|
||||
|
||||
if raw, err := json.Marshal(update); err == nil {
|
||||
for _, r := range receivers {
|
||||
e := r.ProcessUpdate(ctx, string(raw))
|
||||
if e != nil {
|
||||
log.Printf("[ERROR] failure from destination %s on processing telegram update %v", r, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
ntf "github.com/go-pkgz/notify"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDispatchTelegramUpdates(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
poolPeriod := time.Millisecond * 100
|
||||
go DispatchTelegramUpdates(ctx, &mockTGRequester{t: t}, []TGUpdatesReceiver{&mockTGUpdatesReceiver{t: t}}, poolPeriod)
|
||||
time.Sleep(poolPeriod * 3)
|
||||
cancel()
|
||||
time.Sleep(poolPeriod)
|
||||
}
|
||||
|
||||
const getUpdatesResp = `{
|
||||
"ok": true,
|
||||
"result": [
|
||||
{
|
||||
"update_id": 998,
|
||||
"message": {
|
||||
"chat": {
|
||||
"type": "group"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
type mockTGRequester struct {
|
||||
hit int
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
func (m *mockTGRequester) Request(_ context.Context, _ string, _ []byte, data interface{}) error {
|
||||
if m.hit < 2 {
|
||||
m.hit++
|
||||
assert.NoError(m.t, json.Unmarshal([]byte(getUpdatesResp), data))
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("test error")
|
||||
}
|
||||
|
||||
type mockTGUpdatesReceiver struct {
|
||||
t *testing.T
|
||||
hit int
|
||||
}
|
||||
|
||||
func (m *mockTGUpdatesReceiver) String() string {
|
||||
return "mock updater"
|
||||
}
|
||||
|
||||
func (m *mockTGUpdatesReceiver) ProcessUpdate(_ context.Context, textUpdate string) error {
|
||||
var result ntf.TelegramUpdate
|
||||
err := json.Unmarshal([]byte(textUpdate), &result)
|
||||
assert.NoError(m.t, err)
|
||||
if m.hit < 2 {
|
||||
assert.NotNil(m.t, result.Result)
|
||||
m.hit++
|
||||
return nil
|
||||
}
|
||||
assert.Nil(m.t, result.Result)
|
||||
return fmt.Errorf("test error")
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
"github.com/go-pkgz/auth"
|
||||
cache "github.com/go-pkgz/lcw"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
R "github.com/go-pkgz/rest"
|
||||
|
||||
"github.com/umputun/remark/backend/app/rest"
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark/backend/app/store/engine"
|
||||
"github.com/umputun/remark42/backend/app/rest"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store/engine"
|
||||
)
|
||||
|
||||
// admin provides router for all requests available for admin users only
|
||||
@@ -29,22 +29,21 @@ type admin struct {
|
||||
|
||||
type adminStore interface {
|
||||
Delete(locator store.Locator, commentID string, mode store.DeleteMode) error
|
||||
DeleteUser(siteID string, userID string, mode store.DeleteMode) error
|
||||
DeleteUserDetail(siteID string, userID string, detail engine.UserDetail) error
|
||||
DeleteUser(siteID, userID string, mode store.DeleteMode) error
|
||||
DeleteUserDetail(siteID, userID string, detail engine.UserDetail) error
|
||||
User(siteID, userID string, limit, skip int, user store.User) ([]store.Comment, error)
|
||||
IsBlocked(siteID string, userID string) bool
|
||||
SetBlock(siteID string, userID string, status bool, ttl time.Duration) error
|
||||
IsBlocked(siteID, userID string) bool
|
||||
SetBlock(siteID, userID string, status bool, ttl time.Duration) error
|
||||
BlockedUsers(siteID string) ([]store.BlockedUser, error)
|
||||
Info(locator store.Locator, readonlyAge int) (store.PostInfo, error)
|
||||
SetTitle(locator store.Locator, commentID string) (comment store.Comment, err error)
|
||||
SetVerified(siteID string, userID string, status bool) error
|
||||
SetVerified(siteID, userID string, status bool) error
|
||||
SetReadOnly(locator store.Locator, status bool) error
|
||||
SetPin(locator store.Locator, commentID string, status bool) error
|
||||
}
|
||||
|
||||
// DELETE /comment/{id}?site=siteID&url=post-url - removes comment
|
||||
func (a *admin) deleteCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
||||
log.Printf("[INFO] delete comment %s", id)
|
||||
@@ -61,7 +60,6 @@ func (a *admin) deleteCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// DELETE /user/{userid}?site=side-id - delete all user comments for requested userid
|
||||
func (a *admin) deleteUserCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
userID := chi.URLParam(r, "userid")
|
||||
siteID := r.URL.Query().Get("site")
|
||||
log.Printf("[INFO] delete all user comments for %s, site %s", userID, siteID)
|
||||
@@ -77,7 +75,6 @@ func (a *admin) deleteUserCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// GET /user/{userid}?site=side-id - get user info for requested userid
|
||||
func (a *admin) getUserInfoCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
userID := chi.URLParam(r, "userid")
|
||||
siteID := r.URL.Query().Get("site")
|
||||
log.Printf("[INFO] get user info for %s, site %s", userID, siteID)
|
||||
@@ -94,7 +91,6 @@ func (a *admin) getUserInfoCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
// GET /deleteme?token=jwt - delete all user comments and details by user's request. Gets info about deleted used from provided token
|
||||
// request made GET to allow direct click from the email sent by user
|
||||
func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
token := r.URL.Query().Get("token")
|
||||
|
||||
claims, err := a.authenticator.TokenService().Parse(token)
|
||||
@@ -107,13 +103,13 @@ func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// deleteme set by deleteMeCtrl, this check just to make sure we not trying to delete with leaked token
|
||||
if !claims.User.BoolAttr("delete_me") {
|
||||
rest.SendErrorJSON(w, r, http.StatusForbidden, errors.New("forbidden"), "can't use provided token", rest.ErrNoAccess)
|
||||
rest.SendErrorJSON(w, r, http.StatusForbidden, fmt.Errorf("forbidden"), "can't use provided token", rest.ErrNoAccess)
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.dataService.DeleteUserDetail(claims.Audience, claims.User.ID, engine.UserEmail); err != nil {
|
||||
if err = a.dataService.DeleteUserDetail(claims.Audience, claims.User.ID, engine.AllUserDetails); err != nil {
|
||||
code := parseError(err, rest.ErrInternal)
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete email for user", code)
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user details for user", code)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -187,7 +183,7 @@ func (a *admin) setReadOnlyCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
// don't allow to reset ro for posts turned to ro by ReadOnlyAge
|
||||
if !roStatus {
|
||||
if info, e := a.dataService.Info(locator, a.readOnlyAge); e == nil && isRoByAge(info) {
|
||||
rest.SendErrorJSON(w, r, http.StatusForbidden, errors.New("rejected"),
|
||||
rest.SendErrorJSON(w, r, http.StatusForbidden, fmt.Errorf("rejected"),
|
||||
"read-only due the age", rest.ErrActionRejected)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -13,16 +13,15 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
jwt "github.com/dgrijalva/jwt-go"
|
||||
"github.com/go-pkgz/auth/token"
|
||||
cache "github.com/go-pkgz/lcw"
|
||||
R "github.com/go-pkgz/rest"
|
||||
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/umputun/remark/backend/app/store"
|
||||
"github.com/umputun/remark/backend/app/store/service"
|
||||
"github.com/umputun/remark42/backend/app/store"
|
||||
"github.com/umputun/remark42/backend/app/store/service"
|
||||
)
|
||||
|
||||
func TestAdmin_Delete(t *testing.T) {
|
||||
@@ -39,7 +38,7 @@ func TestAdmin_Delete(t *testing.T) {
|
||||
|
||||
// check last comments
|
||||
res, code := get(t, ts.URL+"/api/v1/last/2?site=remark42")
|
||||
assert.Equal(t, 200, code)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
comments := []store.Comment{}
|
||||
err := json.Unmarshal([]byte(res), &comments)
|
||||
assert.NoError(t, err)
|
||||
@@ -48,28 +47,28 @@ func TestAdmin_Delete(t *testing.T) {
|
||||
// check multi count
|
||||
resp, err := post(t, ts.URL+"/api/v1/counts?site=remark42", `["https://radio-t.com/blah","https://radio-t.com/blah2"]`)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
bb, err := ioutil.ReadAll(resp.Body)
|
||||
bb, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.NoError(t, err)
|
||||
j := []store.PostInfo{}
|
||||
err = json.Unmarshal(bb, &j)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []store.PostInfo([]store.PostInfo{{URL: "https://radio-t.com/blah", Count: 2},
|
||||
{URL: "https://radio-t.com/blah2", Count: 0}}), j)
|
||||
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/blah", Count: 2},
|
||||
{URL: "https://radio-t.com/blah2", Count: 0}}, j)
|
||||
|
||||
// delete a comment
|
||||
req, err := http.NewRequest(http.MethodDelete,
|
||||
fmt.Sprintf("%s/api/v1/admin/comment/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1), nil)
|
||||
fmt.Sprintf("%s/api/v1/admin/comment/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1), http.NoBody)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
requireAdminOnly(t, req)
|
||||
resp, err = sendReq(t, req, adminUmputunToken)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
body, code := getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
|
||||
assert.Equal(t, 200, code)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
cr := store.Comment{}
|
||||
err = json.Unmarshal([]byte(body), &cr)
|
||||
assert.NoError(t, err)
|
||||
@@ -79,7 +78,7 @@ func TestAdmin_Delete(t *testing.T) {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
// check last comments updated
|
||||
res, code = get(t, ts.URL+"/api/v1/last/2?site=remark42")
|
||||
assert.Equal(t, 200, code)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
comments = []store.Comment{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
assert.NoError(t, err)
|
||||
@@ -87,7 +86,7 @@ func TestAdmin_Delete(t *testing.T) {
|
||||
|
||||
// check count updated
|
||||
res, code = get(t, ts.URL+"/api/v1/count?site=remark42&url=https://radio-t.com/blah")
|
||||
assert.Equal(t, 200, code)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
b := map[string]interface{}{}
|
||||
err = json.Unmarshal([]byte(res), &b)
|
||||
assert.NoError(t, err)
|
||||
@@ -98,13 +97,14 @@ func TestAdmin_Delete(t *testing.T) {
|
||||
resp, err = post(t, ts.URL+"/api/v1/counts?site=remark42", `["https://radio-t.com/blah","https://radio-t.com/blah2"]`)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
bb, err = ioutil.ReadAll(resp.Body)
|
||||
bb, err = io.ReadAll(resp.Body)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.NoError(t, err)
|
||||
j = []store.PostInfo{}
|
||||
err = json.Unmarshal(bb, &j)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []store.PostInfo([]store.PostInfo{{URL: "https://radio-t.com/blah", Count: 1},
|
||||
{URL: "https://radio-t.com/blah2", Count: 0}}), j)
|
||||
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/blah", Count: 1},
|
||||
{URL: "https://radio-t.com/blah2", Count: 0}}, j)
|
||||
}
|
||||
|
||||
func TestAdmin_Title(t *testing.T) {
|
||||
@@ -136,15 +136,16 @@ func TestAdmin_Title(t *testing.T) {
|
||||
addComment(t, c2, ts)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/title/%s?site=remark42&url=%s/post1", ts.URL, id1, tss.URL), nil)
|
||||
fmt.Sprintf("%s/api/v1/admin/title/%s?site=remark42&url=%s/post1", ts.URL, id1, tss.URL), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
requireAdminOnly(t, req)
|
||||
resp, err := sendReq(t, req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
body, code := get(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=%s/post1", ts.URL, id1, tss.URL))
|
||||
require.Equal(t, 200, code)
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
cr := store.Comment{}
|
||||
err = json.Unmarshal([]byte(body), &cr)
|
||||
assert.NoError(t, err)
|
||||
@@ -170,16 +171,17 @@ func TestAdmin_DeleteUser(t *testing.T) {
|
||||
_, err = srv.DataService.Create(c3)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42", ts.URL, "id2"), nil)
|
||||
req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42", ts.URL, "id2"), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
requireAdminOnly(t, req)
|
||||
resp, err := sendReq(t, req, adminUmputunToken)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// all 3 comments here, but for id2 they deleted
|
||||
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
|
||||
assert.Equal(t, 200, code)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
cmntWithInfo := commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &cmntWithInfo)
|
||||
assert.NoError(t, err)
|
||||
@@ -217,30 +219,32 @@ func TestAdmin_Pin(t *testing.T) {
|
||||
|
||||
pin := func(val int) int {
|
||||
client := http.Client{}
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/pin/%s?site=remark42&url=https://radio-t.com/blah&pin=%d", ts.URL, id1, val), nil)
|
||||
fmt.Sprintf("%s/api/v1/admin/pin/%s?site=remark42&url=https://radio-t.com/blah&pin=%d", ts.URL, id1, val), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
requireAdminOnly(t, req)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
resp, err := client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
code := pin(1)
|
||||
assert.Equal(t, 200, code)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
|
||||
body, code := get(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
|
||||
assert.Equal(t, 200, code)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
cr := store.Comment{}
|
||||
err := json.Unmarshal([]byte(body), &cr)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, cr.Pin)
|
||||
|
||||
code = pin(-1)
|
||||
assert.Equal(t, 200, code)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
body, code = get(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
|
||||
assert.Equal(t, 200, code)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
cr = store.Comment{}
|
||||
err = json.Unmarshal([]byte(body), &cr)
|
||||
assert.NoError(t, err)
|
||||
@@ -268,12 +272,12 @@ func TestAdmin_Block(t *testing.T) {
|
||||
if ttl != "" {
|
||||
url = url + "&ttl=" + ttl
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPut, url, nil)
|
||||
req, err := http.NewRequest(http.MethodPut, url, http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
requireAdminOnly(t, req)
|
||||
resp, err := sendReq(t, req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
return resp.StatusCode, body
|
||||
@@ -283,7 +287,7 @@ func TestAdmin_Block(t *testing.T) {
|
||||
|
||||
// block permanently
|
||||
code, body := block(1, "")
|
||||
require.Equal(t, 200, code)
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
j := R.JSON{}
|
||||
err := json.Unmarshal(body, &j)
|
||||
assert.NoError(t, err)
|
||||
@@ -296,7 +300,7 @@ func TestAdmin_Block(t *testing.T) {
|
||||
|
||||
// get last to confirm one comment deleted
|
||||
bodyStr, code := get(t, ts.URL+"/api/v1/last/10?site=remark42")
|
||||
assert.Equal(t, 200, code)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
pi := []store.PostInfo{}
|
||||
assert.NoError(t, json.Unmarshal([]byte(bodyStr), &pi))
|
||||
assert.Equal(t, 1, len(pi), "last status updated, one comment left")
|
||||
@@ -305,15 +309,16 @@ func TestAdmin_Block(t *testing.T) {
|
||||
resp, err := post(t, ts.URL+"/api/v1/counts?site=remark42", `["https://radio-t.com/blah"]`)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body, err = ioutil.ReadAll(resp.Body)
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
pi = []store.PostInfo{}
|
||||
err = json.Unmarshal(body, &pi)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []store.PostInfo([]store.PostInfo{{URL: "https://radio-t.com/blah", Count: 1}}), pi)
|
||||
assert.Equal(t, []store.PostInfo{{URL: "https://radio-t.com/blah", Count: 1}}, pi)
|
||||
|
||||
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
|
||||
assert.Equal(t, 200, code)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
comments := commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
assert.NoError(t, err)
|
||||
@@ -323,7 +328,7 @@ func TestAdmin_Block(t *testing.T) {
|
||||
|
||||
// unblock
|
||||
code, body = block(-1, "")
|
||||
require.Equal(t, 200, code)
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
err = json.Unmarshal(body, &j)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, false, j["block"])
|
||||
@@ -331,11 +336,11 @@ func TestAdmin_Block(t *testing.T) {
|
||||
// block with ttl
|
||||
makeTwoComments()
|
||||
code, _ = block(1, "50ms")
|
||||
require.Equal(t, 200, code)
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
|
||||
// get as regular user
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
|
||||
assert.Equal(t, 200, code)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
comments = commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
assert.NoError(t, err)
|
||||
@@ -347,7 +352,7 @@ func TestAdmin_Block(t *testing.T) {
|
||||
// time
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
|
||||
assert.Equal(t, 200, code)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
comments = commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
assert.NoError(t, err)
|
||||
@@ -376,28 +381,31 @@ func TestAdmin_BlockedList(t *testing.T) {
|
||||
|
||||
// block user1
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d", ts.URL, "user1", 1), nil)
|
||||
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d", ts.URL, "user1", 1), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
res, err := sendReq(t, req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, res.StatusCode)
|
||||
require.NoError(t, res.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
|
||||
// block user2
|
||||
req, err = http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d&ttl=150ms", ts.URL, "user2", 1), nil)
|
||||
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d&ttl=150ms", ts.URL, "user2", 1), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
res, err = sendReq(t, req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, res.StatusCode)
|
||||
require.NoError(t, res.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||
|
||||
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", nil)
|
||||
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", http.NoBody)
|
||||
require.NoError(t, err)
|
||||
res, err = sendReq(t, req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, res.StatusCode)
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
users := []store.BlockedUser{}
|
||||
err = json.NewDecoder(res.Body).Decode(&users)
|
||||
assert.NoError(t, err)
|
||||
require.NoError(t, res.Body.Close())
|
||||
require.Equal(t, 2, len(users), "two users blocked")
|
||||
assert.Equal(t, "user1", users[0].ID)
|
||||
assert.Equal(t, "user1 name", users[0].Name)
|
||||
@@ -406,14 +414,15 @@ func TestAdmin_BlockedList(t *testing.T) {
|
||||
t.Logf("%+v", users)
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", nil)
|
||||
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", http.NoBody)
|
||||
require.NoError(t, err)
|
||||
res, err = sendReq(t, req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 200, res.StatusCode)
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
users = []store.BlockedUser{}
|
||||
err = json.NewDecoder(res.Body).Decode(&users)
|
||||
assert.NoError(t, err)
|
||||
require.NoError(t, res.Body.Close())
|
||||
assert.Equal(t, 1, len(users), "one user left blocked")
|
||||
}
|
||||
|
||||
@@ -437,14 +446,16 @@ func TestAdmin_ReadOnly(t *testing.T) {
|
||||
|
||||
// set post to read-only
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), nil)
|
||||
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
resp, err := sendReq(t, req, "") // non-admin user
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 401, resp.StatusCode)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
resp, err = sendReq(t, req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
info, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, info.ReadOnly)
|
||||
@@ -458,15 +469,17 @@ func TestAdmin_ReadOnly(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
resp, err = sendReq(t, req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// reset post's read-only
|
||||
req, err = http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=0", ts.URL), nil)
|
||||
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=0", ts.URL), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
resp, err = sendReq(t, req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
info, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, info.ReadOnly)
|
||||
@@ -480,6 +493,7 @@ func TestAdmin_ReadOnly(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
resp, err = sendReq(t, req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -489,17 +503,18 @@ func TestAdmin_ReadOnlyNoComments(t *testing.T) {
|
||||
|
||||
// set post to read-only
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), nil)
|
||||
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
requireAdminOnly(t, req)
|
||||
resp, err := sendReq(t, req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
_, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
|
||||
assert.Error(t, err)
|
||||
|
||||
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&format=tree")
|
||||
assert.Equal(t, 200, code)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
comments := commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
assert.NoError(t, err)
|
||||
@@ -524,27 +539,28 @@ func TestAdmin_ReadOnlyWithAge(t *testing.T) {
|
||||
|
||||
// set post to read-only
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), nil)
|
||||
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
requireAdminOnly(t, req)
|
||||
resp, err := sendReq(t, req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
info, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, info.ReadOnly)
|
||||
|
||||
// reset post's read-only
|
||||
req, err = http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=0", ts.URL), nil)
|
||||
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=0", ts.URL), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
resp, err = sendReq(t, req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 403, resp.StatusCode)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
info, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, info.ReadOnly)
|
||||
|
||||
}
|
||||
func TestAdmin_Verify(t *testing.T) {
|
||||
ts, srv, teardown := startupT(t)
|
||||
@@ -564,17 +580,18 @@ func TestAdmin_Verify(t *testing.T) {
|
||||
assert.False(t, verified)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=1", ts.URL), nil)
|
||||
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=1", ts.URL), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
requireAdminOnly(t, req)
|
||||
resp, err := sendReq(t, req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
verified = srv.DataService.IsVerified("remark42", "user1")
|
||||
assert.True(t, verified)
|
||||
|
||||
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
|
||||
assert.Equal(t, 200, code)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
comments := commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
assert.NoError(t, err)
|
||||
@@ -583,16 +600,17 @@ func TestAdmin_Verify(t *testing.T) {
|
||||
assert.True(t, comments.Comments[0].User.Verified)
|
||||
|
||||
req, err = http.NewRequest(http.MethodPut,
|
||||
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=0", ts.URL), nil)
|
||||
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=0", ts.URL), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
resp, err = sendReq(t, req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
verified = srv.DataService.IsVerified("remark42", "user1")
|
||||
assert.False(t, verified)
|
||||
|
||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
|
||||
assert.Equal(t, 200, code)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
comments = commentsWithInfo{}
|
||||
err = json.Unmarshal([]byte(res), &comments)
|
||||
assert.NoError(t, err)
|
||||
@@ -614,7 +632,7 @@ func TestAdmin_ExportStream(t *testing.T) {
|
||||
addComment(t, c2, ts)
|
||||
|
||||
body, code := getWithAdminAuth(t, ts.URL+"/api/v1/admin/export?site=remark42&mode=stream")
|
||||
assert.Equal(t, 200, code)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
assert.Equal(t, 3, strings.Count(body, "\n"))
|
||||
assert.Equal(t, 2, strings.Count(body, "\"text\""))
|
||||
t.Logf("%s", body)
|
||||
@@ -632,18 +650,19 @@ func TestAdmin_ExportFile(t *testing.T) {
|
||||
addComment(t, c1, ts)
|
||||
addComment(t, c2, ts)
|
||||
|
||||
req, err := http.NewRequest("GET", ts.URL+"/api/v1/admin/export?site=remark42&mode=file", nil)
|
||||
req, err := http.NewRequest("GET", ts.URL+"/api/v1/admin/export?site=remark42&mode=file", http.NoBody)
|
||||
require.NoError(t, err)
|
||||
requireAdminOnly(t, req)
|
||||
resp, err := sendReq(t, req, adminUmputunToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, "application/gzip", resp.Header.Get("Content-Type"))
|
||||
|
||||
ungzReader, err := gzip.NewReader(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
ungzBody, err := ioutil.ReadAll(ungzReader)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
ungzBody, err := io.ReadAll(ungzReader)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, strings.Count(string(ungzBody), "\n"))
|
||||
assert.Equal(t, 2, strings.Count(string(ungzBody), "\"text\""))
|
||||
@@ -694,20 +713,22 @@ func TestAdmin_DeleteMeRequest(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, os.MkdirAll(os.TempDir()+"/ava-remark42/42", 0700))
|
||||
require.NoError(t, ioutil.WriteFile(os.TempDir()+"/ava-remark42/42/pic.image", []byte("some image data"), 0600))
|
||||
require.NoError(t, os.MkdirAll(os.TempDir()+"/ava-remark42/42", 0o700))
|
||||
require.NoError(t, os.WriteFile(os.TempDir()+"/ava-remark42/42/pic.image", []byte("some image data"), 0o600))
|
||||
|
||||
tkn, err := srv.Authenticator.TokenService().Token(claims)
|
||||
assert.NoError(t, err)
|
||||
|
||||
client := http.Client{}
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil)
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req.SetBasicAuth("admin", "password")
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
_, err = srv.DataService.User("remark42", "user1", 0, 0, store.User{})
|
||||
assert.EqualError(t, err, "no comments for user user1 in store")
|
||||
@@ -715,7 +736,6 @@ func TestAdmin_DeleteMeRequest(t *testing.T) {
|
||||
email, err = srv.DataService.GetUserEmail("remark42", "user1")
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, email, "user1 email was deleted")
|
||||
|
||||
}
|
||||
|
||||
func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
|
||||
@@ -723,9 +743,9 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
|
||||
defer teardown()
|
||||
|
||||
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
|
||||
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "user1"}}
|
||||
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user1 name", ID: "provider1_user1"}}
|
||||
c2 := store.Comment{Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "remark42",
|
||||
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user2", ID: "user2"}}
|
||||
URL: "https://radio-t.com/blah"}, User: store.User{Name: "user2", ID: "provider1_user2"}}
|
||||
|
||||
_, err := srv.DataService.Create(c1)
|
||||
assert.NoError(t, err)
|
||||
@@ -734,25 +754,27 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
|
||||
|
||||
// try with bad token
|
||||
client := http.Client{}
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, "bad token"), nil)
|
||||
defer client.CloseIdleConnections()
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, "bad token"), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
resp, err := client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 400, resp.StatusCode)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
// try with bad auth
|
||||
claims := token.Claims{
|
||||
SessionOnly: true,
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Audience: "remark42",
|
||||
Id: "1234567",
|
||||
Id: "provider1_1234567",
|
||||
Issuer: "remark42",
|
||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
||||
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
|
||||
},
|
||||
User: &token.User{
|
||||
ID: "user1",
|
||||
ID: "provider1_user1",
|
||||
Attributes: map[string]interface{}{
|
||||
"delete_me": true,
|
||||
},
|
||||
@@ -761,38 +783,41 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
|
||||
|
||||
tkn, err := srv.Authenticator.TokenService().Token(claims)
|
||||
assert.NoError(t, err)
|
||||
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil)
|
||||
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "bad-password")
|
||||
resp, err = client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 403, resp.StatusCode)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
|
||||
// try bad user
|
||||
badClaims := claims
|
||||
badClaims.User.ID = "no-such-id"
|
||||
tkn, err = srv.Authenticator.TokenService().Token(badClaims)
|
||||
assert.NoError(t, err)
|
||||
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil)
|
||||
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
resp, err = client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 400, resp.StatusCode, resp.Status)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, resp.Status)
|
||||
|
||||
// try without deleteme flag
|
||||
badClaims2 := claims
|
||||
badClaims2.User.SetBoolAttr("delete_me", false)
|
||||
tkn, err = srv.Authenticator.TokenService().Token(badClaims2)
|
||||
assert.NoError(t, err)
|
||||
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), nil)
|
||||
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
|
||||
assert.NoError(t, err)
|
||||
req.SetBasicAuth("admin", "password")
|
||||
resp, err = client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 403, resp.StatusCode)
|
||||
b, err := ioutil.ReadAll(resp.Body)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, resp.Body.Close())
|
||||
assert.True(t, strings.Contains(string(b), "can't use provided token"))
|
||||
}
|
||||
|
||||
@@ -812,7 +837,7 @@ func TestAdmin_GetUserInfo(t *testing.T) {
|
||||
|
||||
body, code := getWithAdminAuth(t, fmt.Sprintf("%s/api/v1/admin/user/user1?site=remark42&url=https://radio-t.com/blah",
|
||||
ts.URL))
|
||||
assert.Equal(t, 200, code)
|
||||
assert.Equal(t, http.StatusOK, code)
|
||||
u := store.User{}
|
||||
err = json.Unmarshal([]byte(body), &u)
|
||||
assert.NoError(t, err)
|
||||
@@ -820,8 +845,8 @@ func TestAdmin_GetUserInfo(t *testing.T) {
|
||||
Admin: false, Blocked: false, Verified: false}, u)
|
||||
|
||||
_, code = get(t, fmt.Sprintf("%s/api/v1/admin/user/user1?site=remark42&url=https://radio-t.com/blah", ts.URL))
|
||||
assert.Equal(t, 401, code, "no auth")
|
||||
assert.Equal(t, http.StatusUnauthorized, code, "no auth")
|
||||
|
||||
_, code = getWithAdminAuth(t, fmt.Sprintf("%s/api/v1/admin/user/userX?site=remark42&url=https://radio-t.com/blah", ts.URL))
|
||||
assert.Equal(t, 400, code, "no info about user")
|
||||
assert.Equal(t, http.StatusBadRequest, code, "no info about user")
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
@@ -15,10 +14,9 @@ import (
|
||||
cache "github.com/go-pkgz/lcw"
|
||||
log "github.com/go-pkgz/lgr"
|
||||
R "github.com/go-pkgz/rest"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/umputun/remark/backend/app/migrator"
|
||||
"github.com/umputun/remark/backend/app/rest"
|
||||
"github.com/umputun/remark42/backend/app/migrator"
|
||||
"github.com/umputun/remark42/backend/app/rest"
|
||||
)
|
||||
|
||||
// Migrator rest with import and export controllers
|
||||
@@ -27,8 +25,9 @@ type Migrator struct {
|
||||
NativeImporter migrator.Importer
|
||||
DisqusImporter migrator.Importer
|
||||
WordPressImporter migrator.Importer
|
||||
CommentoImporter migrator.Importer
|
||||
NativeExporter migrator.Exporter
|
||||
UrlMapperMaker migrator.MapperMaker
|
||||
URLMapperMaker migrator.MapperMaker
|
||||
KeyStore KeyStore
|
||||
|
||||
busy map[string]bool
|
||||
@@ -37,17 +36,16 @@ type Migrator struct {
|
||||
|
||||
// KeyStore defines sub-interface for consumers needed just a key
|
||||
type KeyStore interface {
|
||||
Key() (key string, err error)
|
||||
Key(siteID string) (key string, err error)
|
||||
}
|
||||
|
||||
// POST /import?secret=key&site=site-id&provider=disqus|remark|wordpress
|
||||
// imports comments from post body.
|
||||
func (m *Migrator) importCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
siteID := r.URL.Query().Get("site")
|
||||
|
||||
if m.isBusy(siteID) {
|
||||
rest.SendErrorJSON(w, r, http.StatusConflict, errors.New("already running"),
|
||||
rest.SendErrorJSON(w, r, http.StatusConflict, fmt.Errorf("already running"),
|
||||
"import rejected", rest.ErrActionRejected)
|
||||
return
|
||||
}
|
||||
@@ -70,7 +68,7 @@ func (m *Migrator) importFormCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
siteID := r.URL.Query().Get("site")
|
||||
|
||||
if m.isBusy(siteID) {
|
||||
rest.SendErrorJSON(w, r, http.StatusConflict, errors.New("already running"),
|
||||
rest.SendErrorJSON(w, r, http.StatusConflict, fmt.Errorf("already running"),
|
||||
"import rejected", rest.ErrActionRejected)
|
||||
return
|
||||
}
|
||||
@@ -131,7 +129,6 @@ func (m *Migrator) waitCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
// GET /export?site=site-id&secret=12345&?mode=file|stream
|
||||
// exports all comments for siteID as gz file
|
||||
func (m *Migrator) exportCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
siteID := r.URL.Query().Get("site")
|
||||
|
||||
var writer io.Writer = w
|
||||
@@ -161,12 +158,12 @@ func (m *Migrator) remapCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
siteID := r.URL.Query().Get("site")
|
||||
|
||||
// create new url-mapper from given rules in body
|
||||
mapper, err := m.UrlMapperMaker(r.Body)
|
||||
mapper, err := m.URLMapperMaker(r.Body)
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "remap failed, bad given rules", rest.ErrDecode)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
defer r.Body.Close() //nolint gosec // we don't care about response body
|
||||
|
||||
// start remap procedure with mapper
|
||||
go func() {
|
||||
@@ -174,18 +171,18 @@ func (m *Migrator) remapCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
defer m.setBusy(siteID, false)
|
||||
|
||||
// do export
|
||||
fh, e := ioutil.TempFile("", "remark42_convert")
|
||||
fh, e := os.CreateTemp("", "remark42_convert")
|
||||
if e != nil {
|
||||
log.Printf("[WARN] failed to make temp file %+v", e)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if e := os.Remove(fh.Name()); e != nil {
|
||||
if e = os.Remove(fh.Name()); e != nil {
|
||||
log.Printf("[WARN] failed to remove temp file %+v", e)
|
||||
}
|
||||
}()
|
||||
log.Printf("[DEBUG] start export for site=%s", siteID)
|
||||
if _, e := m.NativeExporter.Export(fh, siteID); e != nil {
|
||||
if _, e = m.NativeExporter.Export(fh, siteID); e != nil {
|
||||
log.Printf("[WARN] export failed with %+v", e)
|
||||
return
|
||||
}
|
||||
@@ -212,7 +209,7 @@ func (m *Migrator) remapCtrl(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// runImport reads from tmpfile and import for given siteID and provider
|
||||
func (m *Migrator) runImport(siteID string, provider string, tmpfile string) {
|
||||
func (m *Migrator) runImport(siteID, provider, tmpfile string) {
|
||||
m.setBusy(siteID, true)
|
||||
|
||||
defer func() {
|
||||
@@ -228,12 +225,14 @@ func (m *Migrator) runImport(siteID string, provider string, tmpfile string) {
|
||||
importer = m.DisqusImporter
|
||||
case "wordpress":
|
||||
importer = m.WordPressImporter
|
||||
case "commento":
|
||||
importer = m.CommentoImporter
|
||||
default:
|
||||
importer = m.NativeImporter
|
||||
}
|
||||
log.Printf("[DEBUG] import request for site=%s, provider=%s", siteID, provider)
|
||||
|
||||
fh, err := os.Open(tmpfile)
|
||||
fh, err := os.Open(tmpfile) // nolint
|
||||
if err != nil {
|
||||
log.Printf("[WARN] import failed, %v", err)
|
||||
return
|
||||
@@ -250,17 +249,17 @@ func (m *Migrator) runImport(siteID string, provider string, tmpfile string) {
|
||||
|
||||
// saveTemp reads from reader and saves to temp file
|
||||
func (m *Migrator) saveTemp(r io.Reader) (string, error) {
|
||||
tmpfile, err := ioutil.TempFile("", "remark42_import")
|
||||
tmpfile, err := os.CreateTemp("", "remark42_import")
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "can't make temp file")
|
||||
return "", fmt.Errorf("can't make temp file: %w", err)
|
||||
}
|
||||
|
||||
if _, err = io.Copy(tmpfile, r); err != nil {
|
||||
return "", errors.Wrap(err, "can't copy to temp file")
|
||||
return "", fmt.Errorf("can't copy to temp file: %w", err)
|
||||
}
|
||||
|
||||
if err = tmpfile.Close(); err != nil {
|
||||
return "", errors.Wrap(err, "can't close temp file")
|
||||
return "", fmt.Errorf("can't close temp file: %w", err)
|
||||
}
|
||||
|
||||
return tmpfile.Name(), nil
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user