Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3286f028e3 | ||
|
|
c947a06d48 | ||
|
|
5f439cf1d5 | ||
|
|
7de51ad2ef | ||
|
|
389189afcf | ||
|
|
6f40926241 | ||
|
|
2640aaee9e | ||
|
|
250e8ad925 | ||
|
|
4793c1cd2c | ||
|
|
23be25d84a | ||
|
|
4d5dae20e2 | ||
|
|
a82dc8d3f1 | ||
|
|
e3d1d0e23e | ||
|
|
49bf83b09c | ||
|
|
0b651dddd4 | ||
|
|
b6975af63c | ||
|
|
4fca268dc6 | ||
|
|
a0879b2336 | ||
|
|
7c312da199 | ||
|
|
a5b2fe3cfc | ||
|
|
7ee3a0da48 | ||
|
|
4aaba0fb61 | ||
|
|
fb7b6c2cdd | ||
|
|
123b9328d9 | ||
|
|
2bfad021e3 | ||
|
|
4c9ef37cf1 | ||
|
|
ff77f41a3a | ||
|
|
1bb002348a | ||
|
|
fc4e10573c | ||
|
|
a91e322d5c | ||
|
|
931f2db4e3 | ||
|
|
b8f6dc5f91 | ||
|
|
b03dc366f9 | ||
|
|
36062de0e7 | ||
|
|
90766d6637 | ||
|
|
a1dbb2cb92 | ||
|
|
d370b78613 | ||
|
|
439ccfa83c | ||
|
|
29627f4bf0 | ||
|
|
164eb89c60 | ||
|
|
09110c792f | ||
|
|
3f5b3cdd98 | ||
|
|
43fccf3bc9 | ||
|
|
1f34984dab | ||
|
|
455d770899 | ||
|
|
bf67c251c5 | ||
|
|
cebba4cee4 | ||
|
|
35a389cb75 | ||
|
|
a725d990ed | ||
|
|
fdfce6495c | ||
|
|
8801903d01 | ||
|
|
8bcfd9e456 | ||
|
|
5b37a583ce | ||
|
|
29b5f88a1c | ||
|
|
287aef4dfb | ||
|
|
d06aa6771c | ||
|
|
a54d2d2756 | ||
|
|
e575066ea9 | ||
|
|
2544d80f98 | ||
|
|
5c0798fe10 | ||
|
|
76d0cc2cf6 | ||
|
|
51b6a7e890 | ||
|
|
8c5e82bd16 | ||
|
|
95f59213e5 | ||
|
|
503f5cacb0 | ||
|
|
d1f8cf412b | ||
|
|
db9d8703ef | ||
|
|
8f61ec691b | ||
|
|
07f6b9a0a0 | ||
|
|
98e4f03091 | ||
|
|
f8f2becb4b | ||
|
|
6e7820d2b7 | ||
|
|
3e63d72852 | ||
|
|
a8dd527c45 | ||
|
|
e62b3c830d | ||
|
|
b1502801fa | ||
|
|
2e3a680ca4 | ||
|
|
d8b7f7530c | ||
|
|
b33025a76f | ||
|
|
c48254a994 | ||
|
|
3fc5d6b970 | ||
|
|
380aa3c828 | ||
|
|
b6bc8ba675 | ||
|
|
c5121fd402 | ||
|
|
fff9127976 | ||
|
|
406df022ba | ||
|
|
6840a46ac9 | ||
|
|
6a50ffd88a | ||
|
|
f7dbdae26c | ||
|
|
f4b236c66a | ||
|
|
17365f4304 | ||
|
|
0b6eea68a1 | ||
|
|
b19e6269c1 | ||
|
|
bb6d1450f1 | ||
|
|
8318f89dde | ||
|
|
3e18681ca7 | ||
|
|
11d8a978a2 | ||
|
|
d7fe27cb97 | ||
|
|
7fee12a978 | ||
|
|
fc3d93c398 | ||
|
|
4baf0f4260 | ||
|
|
b72030114c | ||
|
|
8626e4181f | ||
|
|
d274724c08 | ||
|
|
f5ccfaa0e1 | ||
|
|
c8832e708c | ||
|
|
07c7926453 | ||
|
|
34ed97b7a6 | ||
|
|
0868b70fa9 | ||
|
|
589e956ade | ||
|
|
a21044738d | ||
|
|
929c06d957 | ||
|
|
198efddb54 | ||
|
|
39408dffe8 | ||
|
|
6961dc24e5 | ||
|
|
e8b9d70061 | ||
|
|
556e0a70d5 | ||
|
|
0e20861419 | ||
|
|
8224626ed4 | ||
|
|
45c17a913f | ||
|
|
f3a7dea1f1 | ||
|
|
e8c106f06b | ||
|
|
54b7b3fdd4 | ||
|
|
c0636f204e | ||
|
|
c418f8ec00 | ||
|
|
e9ad5dcc09 | ||
|
|
d072f34a67 | ||
|
|
a4c5e17bbb | ||
|
|
8d9290ea1f | ||
|
|
07d89202b4 | ||
|
|
5d6599237d | ||
|
|
b13b737461 | ||
|
|
c9ba8520c7 | ||
|
|
ee782785f0 | ||
|
|
3b1d7be6fc | ||
|
|
e98657a88a | ||
|
|
a96bddcb8d | ||
|
|
5ff5059db3 | ||
|
|
ff85bbc5ea | ||
|
|
5d88c1b2fa | ||
|
|
114a1be2e9 | ||
|
|
59c92f8c4d | ||
|
|
ddcb2c7b5f | ||
|
|
f8ba38779b | ||
|
|
94d1f6e224 | ||
|
|
ba3df171d1 | ||
|
|
7ec5af8068 | ||
|
|
fc6f15534e | ||
|
|
80c12a3f10 | ||
|
|
bea67f0136 | ||
|
|
8b9e5c6c8c | ||
|
|
06436ff9b0 | ||
|
|
b888a53759 | ||
|
|
c26f45e55e | ||
|
|
ba7c3aed94 | ||
|
|
8aafc8fcd7 | ||
|
|
ab9e6675cf | ||
|
|
ed67390dea | ||
|
|
aca0cff399 | ||
|
|
f359256489 | ||
|
|
336f17e7b7 | ||
|
|
0105bc2314 | ||
|
|
78d6de6bce | ||
|
|
638fa63e81 | ||
|
|
e3b0d63648 | ||
|
|
b38d91cb0f | ||
|
|
d6d53ff2e0 | ||
|
|
195becc6ee | ||
|
|
5bc5167a31 | ||
|
|
1320b1f055 | ||
|
|
283e2c19c7 | ||
|
|
55d9e22373 | ||
|
|
31e20fc26d | ||
|
|
c2cc2305c1 | ||
|
|
4d0bd29b45 | ||
|
|
a1215d87d9 | ||
|
|
3c2679a1d5 | ||
|
|
79177e52f9 | ||
|
|
baa615a0d1 | ||
|
|
e665dcf9e2 | ||
|
|
b7a13a6636 | ||
|
|
218570cfad | ||
|
|
4c9a791d6d | ||
|
|
cdad560df3 | ||
|
|
307e69e5c1 | ||
|
|
d5b07d7670 | ||
|
|
41b75eba08 | ||
|
|
4aa8362de1 | ||
|
|
dc168f69bf | ||
|
|
2a4a1591fa | ||
|
|
bc612ddf81 | ||
|
|
9132a6158b | ||
|
|
658bf307b1 | ||
|
|
c6efcc56a9 | ||
|
|
bdee00b662 | ||
|
|
3013d03be2 | ||
|
|
4a2bb5eda8 | ||
|
|
d01b738741 | ||
|
|
564e8ff316 | ||
|
|
b451142790 | ||
|
|
6d8a0c783b | ||
|
|
d925584af8 | ||
|
|
22ee7a06d5 | ||
|
|
a311ff7b38 | ||
|
|
88257931ca | ||
|
|
588ec169ff | ||
|
|
d9efa765d1 | ||
|
|
baf0db1947 | ||
|
|
34ea4c3e83 | ||
|
|
8112f445f4 | ||
|
|
bd7bbe629f | ||
|
|
ed8b3c314d | ||
|
|
51ec396691 | ||
|
|
ca8ab66812 | ||
|
|
eaa64bac45 | ||
|
|
abdca907a7 | ||
|
|
a9c8cf51a0 | ||
|
|
d829a57061 | ||
|
|
1dffb2f16e | ||
|
|
9e2a1da0df | ||
|
|
6fbaa806f0 | ||
|
|
58acca4dcb | ||
|
|
22d21df22b | ||
|
|
ddb490bbc1 | ||
|
|
242499787e | ||
|
|
fd0799384f | ||
|
|
61dbf6b1f2 | ||
|
|
4a3c73db31 | ||
|
|
df510360e6 | ||
|
|
a3309516c3 | ||
|
|
5e30dc86e1 | ||
|
|
bbcba5487e | ||
|
|
c32e5efdc2 | ||
|
|
af139a7f4c | ||
|
|
89221ff2bc | ||
|
|
528242c1ca | ||
|
|
edfc5b9d76 | ||
|
|
7e944bfe0d | ||
|
|
e6afc58b34 | ||
|
|
f49b878eb4 | ||
|
|
5fe843de71 | ||
|
|
6c9ade9062 | ||
|
|
ed3c104ba4 | ||
|
|
a90b4296c6 | ||
|
|
0ebe893125 | ||
|
|
e32ca020c0 | ||
|
|
558350c546 | ||
|
|
c50a5d6245 | ||
|
|
bb650d7526 | ||
|
|
551212db82 | ||
|
|
2e00002413 | ||
|
|
81f70aa287 | ||
|
|
f104e3c775 | ||
|
|
be076d03e9 | ||
|
|
e1166a48cf | ||
|
|
3f0789fd90 | ||
|
|
518ae79556 | ||
|
|
3c55238bdd | ||
|
|
556b95a655 | ||
|
|
bfef15f05f | ||
|
|
c3ba55ba43 | ||
|
|
0af8b2eab0 | ||
|
|
82a0888c42 | ||
|
|
d5162d3fe6 | ||
|
|
e61a46efff | ||
|
|
f473105c52 | ||
|
|
c2d386230c | ||
|
|
e3ad01b555 | ||
|
|
8d9e55c33c | ||
|
|
6402ef9cae | ||
|
|
4ed48dd85c | ||
|
|
9628312b5d | ||
|
|
c65c2b395d | ||
|
|
27671c50c4 | ||
|
|
56ac1bb841 | ||
|
|
0aadf0ba86 | ||
|
|
34c667b83a | ||
|
|
f2e5758b6c | ||
|
|
cd7f616596 | ||
|
|
a0c412ee1e | ||
|
|
15f5b7dde5 | ||
|
|
a561588117 | ||
|
|
ac36dccd19 | ||
|
|
afdac27651 | ||
|
|
995c4963fb | ||
|
|
16ff2690f0 | ||
|
|
d0dc1131ea | ||
|
|
d5e3602e54 | ||
|
|
a4772bd9de | ||
|
|
d23b7003c6 | ||
|
|
3646c4a871 | ||
|
|
29fc63f116 | ||
|
|
8c59bab921 | ||
|
|
c42511d5a1 | ||
|
|
9b5f6ee2c5 | ||
|
|
b5579152cb | ||
|
|
3c78a54be6 | ||
|
|
3001b37812 | ||
|
|
63048cc798 | ||
|
|
1a27913404 | ||
|
|
4a39ceee8d | ||
|
|
1e659917d2 | ||
|
|
5c586e12bd | ||
|
|
73ca4a1b6d | ||
|
|
7604548035 | ||
|
|
7529aa7e17 | ||
|
|
c1a447b873 | ||
|
|
3c110eb0ec | ||
|
|
d7494d5392 | ||
|
|
c1048e95b3 | ||
|
|
5b6d8de807 | ||
|
|
dd2cff6a13 | ||
|
|
68fe6eb55f | ||
|
|
4d5f9f269b | ||
|
|
6140d82eb2 | ||
|
|
cff261c15b | ||
|
|
18ea40582a | ||
|
|
f9d4837567 | ||
|
|
e5ae07b1c2 | ||
|
|
4fbb3b59be | ||
|
|
9fb3014229 | ||
|
|
2a9b29dd53 | ||
|
|
872b818323 | ||
|
|
4a7bee1d98 | ||
|
|
cbe793fb42 | ||
|
|
cbf9a82a92 | ||
|
|
6cd5c45a6c | ||
|
|
0bc85a6ff6 | ||
|
|
88bf4b7d70 | ||
|
|
26c5425646 | ||
|
|
15d2ab9644 | ||
|
|
50c56cb771 | ||
|
|
e65f71b958 | ||
|
|
a9b439602b | ||
|
|
d2027f5241 | ||
|
|
95966f6407 | ||
|
|
8df986e70a | ||
|
|
71a6d0b385 | ||
|
|
974d4aaf55 | ||
|
|
dc8d7d46cb | ||
|
|
c4ace9fc0c | ||
|
|
16b07ded66 | ||
|
|
c04705947a | ||
|
|
eadd65e247 | ||
|
|
4428f79046 | ||
|
|
bad6af87f7 | ||
|
|
f7ba43e5f1 | ||
|
|
661f042cb4 | ||
|
|
877765cda2 | ||
|
|
4bb0017060 | ||
|
|
e0423b8683 | ||
|
|
5a781693aa | ||
|
|
e5743185b0 | ||
|
|
1510aec17c | ||
|
|
01837b69e5 | ||
|
|
d02099844e | ||
|
|
6fcfaa12b7 | ||
|
|
6269c19881 | ||
|
|
1313dee829 | ||
|
|
3210de8f7b | ||
|
|
532573fb34 | ||
|
|
e1173bbcad | ||
|
|
e748951182 | ||
|
|
df8670752a | ||
|
|
654250f033 | ||
|
|
0050c65596 | ||
|
|
02db7a917d | ||
|
|
81c30e01f8 | ||
|
|
82c617806d | ||
|
|
e043dc2ac3 | ||
|
|
cbd73865bd | ||
|
|
884b5685eb | ||
|
|
3f14651653 | ||
|
|
310b797679 | ||
|
|
0594565143 | ||
|
|
d4c153662b | ||
|
|
f64b0b8831 | ||
|
|
94893b77dc | ||
|
|
30f46efa5b | ||
|
|
e0904603c6 | ||
|
|
d143932924 | ||
|
|
dcc7613409 | ||
|
|
d04d2097f8 | ||
|
|
ce678bf967 | ||
|
|
cd481d401d | ||
|
|
618c267370 | ||
|
|
307866f7f5 | ||
|
|
19e1616129 | ||
|
|
c6506b8905 | ||
|
|
676ae77456 | ||
|
|
b93fc48b73 | ||
|
|
4be664e78d | ||
|
|
62aaa35287 | ||
|
|
69b18d3536 | ||
|
|
efceed6f68 | ||
|
|
f4358173c7 | ||
|
|
7a71d47556 | ||
|
|
41d27e2a7f | ||
|
|
10e4686f1a | ||
|
|
eba447319d | ||
|
|
40a0d7ca62 | ||
|
|
c9b6f9272f | ||
|
|
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 |
+5
-5
@@ -10,10 +10,6 @@
|
|||||||
/frontend/node_modules/
|
/frontend/node_modules/
|
||||||
/frontend/apps/remark42/node_modules/
|
/frontend/apps/remark42/node_modules/
|
||||||
/frontend/apps/remark42/public/
|
/frontend/apps/remark42/public/
|
||||||
# e2e tests arficats
|
|
||||||
/frontend/e2e/playwright-report/
|
|
||||||
/frontend/e2e/playwright/.cache/
|
|
||||||
/frontend/e2e/test-results/
|
|
||||||
|
|
||||||
# source files
|
# source files
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
@@ -21,8 +17,10 @@ compose-dev-backend.yml
|
|||||||
compose-dev-frontend.yml
|
compose-dev-frontend.yml
|
||||||
compose-private-backend.yml
|
compose-private-backend.yml
|
||||||
compose-private-frontend.yml
|
compose-private-frontend.yml
|
||||||
|
compose-e2e-test.yml
|
||||||
compose-private.yml
|
compose-private.yml
|
||||||
rest-client.env.json
|
rest-client.env.json
|
||||||
|
Makefile
|
||||||
|
|
||||||
# generated files
|
# generated files
|
||||||
*.cov
|
*.cov
|
||||||
@@ -34,4 +32,6 @@ debug.test
|
|||||||
*.test
|
*.test
|
||||||
remark42
|
remark42
|
||||||
/backend/var/
|
/backend/var/
|
||||||
/playwright-report/
|
|
||||||
|
# go e2e suite, never built into the image
|
||||||
|
/e2e/
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
[*]
|
[*]
|
||||||
indent_style = tab
|
indent_style = tab
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
|
|||||||
+1
-1
@@ -3,4 +3,4 @@
|
|||||||
# review when someone opens a pull request.
|
# review when someone opens a pull request.
|
||||||
|
|
||||||
* @umputun
|
* @umputun
|
||||||
frontend/* @akellbl4 @Mavrin
|
frontend/* @umputun @akellbl4 @Mavrin
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# To get started with Dependabot version updates, you'll need to specify which
|
||||||
|
# package ecosystems to update and where the package manifests are located.
|
||||||
|
# Please see the documentation for all configuration options:
|
||||||
|
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
|
||||||
|
# npm updates are switched off entirely. open-pull-requests-limit bounds version
|
||||||
|
# updates only, so the ignore entries below are what also stops security updates;
|
||||||
|
# removing the npm entries would not work, as security updates come from alerts
|
||||||
|
# rather than from this file.
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
groups:
|
||||||
|
"GitHub Actions updates":
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
- package-ecosystem: "gomod"
|
||||||
|
directory: "/backend"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
groups:
|
||||||
|
"Go modules updates":
|
||||||
|
dependency-type: "production"
|
||||||
|
- package-ecosystem: "gomod"
|
||||||
|
directory: "/e2e"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
groups:
|
||||||
|
"Go modules updates":
|
||||||
|
dependency-type: "production"
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/frontend"
|
||||||
|
open-pull-requests-limit: 0
|
||||||
|
ignore:
|
||||||
|
- dependency-name: "*"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
groups:
|
||||||
|
"NPM modules updates":
|
||||||
|
dependency-type: "production"
|
||||||
|
"NPM modules updates for tests":
|
||||||
|
dependency-type: "development"
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/frontend/apps/remark42"
|
||||||
|
open-pull-requests-limit: 0
|
||||||
|
ignore:
|
||||||
|
- dependency-name: "*"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
groups:
|
||||||
|
"NPM modules updates":
|
||||||
|
dependency-type: "production"
|
||||||
|
"NPM modules updates for tests":
|
||||||
|
dependency-type: "development"
|
||||||
|
- package-ecosystem: "docker"
|
||||||
|
directory: "/site"
|
||||||
|
schedule:
|
||||||
|
interval: "monthly"
|
||||||
|
groups:
|
||||||
|
"Site image updates":
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
@@ -5,24 +5,34 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
tags:
|
tags:
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/ci-test-backend.yml"
|
- ".github/workflows/ci-backend.yml"
|
||||||
- "backend/**"
|
- "backend/**"
|
||||||
|
- "Dockerfile"
|
||||||
|
- "docker-init.sh"
|
||||||
|
- ".dockerignore"
|
||||||
- "!backend/scripts/**"
|
- "!backend/scripts/**"
|
||||||
- "!**.md"
|
- "!**.md"
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, reopened]
|
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/ci-test-backend.yml"
|
- ".github/workflows/ci-backend.yml"
|
||||||
- "backend/**"
|
- "backend/**"
|
||||||
|
- "Dockerfile"
|
||||||
|
- "docker-init.sh"
|
||||||
|
- ".dockerignore"
|
||||||
- "!backend/scripts/**"
|
- "!backend/scripts/**"
|
||||||
- "!**.md"
|
- "!**.md"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Test & Coverage
|
name: Test & Coverage
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: debug if needed
|
- name: debug if needed
|
||||||
run: if [[ "$DEBUG" == "true" ]]; then env; fi
|
run: if [[ "$DEBUG" == "true" ]]; then env; fi
|
||||||
@@ -30,36 +40,78 @@ jobs:
|
|||||||
DEBUG: ${{secrets.DEBUG}}
|
DEBUG: ${{secrets.DEBUG}}
|
||||||
|
|
||||||
- name: install go
|
- name: install go
|
||||||
uses: actions/setup-go@v3
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: 1.17
|
go-version: "1.25"
|
||||||
|
check-latest: true
|
||||||
|
cache-dependency-path: backend
|
||||||
|
|
||||||
- name: install golangci-lint and goveralls
|
- name: test and build backend
|
||||||
run: |
|
run: |
|
||||||
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $GITHUB_WORKSPACE v1.49.0
|
go test -race -timeout=300s -covermode=atomic -coverprofile=$GITHUB_WORKSPACE/profile.cov_tmp ./...
|
||||||
go install github.com/mattn/goveralls@latest
|
|
||||||
|
|
||||||
- name: test and lint 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
|
cat $GITHUB_WORKSPACE/profile.cov_tmp | grep -v "_mock.go" > $GITHUB_WORKSPACE/profile.cov
|
||||||
$GITHUB_WORKSPACE/golangci-lint --config ${GITHUB_WORKSPACE}/backend/.golangci.yml run --out-format=github-actions ./...
|
go build -race ./...
|
||||||
working-directory: backend/app
|
working-directory: backend/app
|
||||||
env:
|
env:
|
||||||
TZ: "America/Chicago"
|
TZ: "America/Chicago"
|
||||||
|
|
||||||
- name: test and lint examples
|
- name: test examples
|
||||||
run: |
|
run: |
|
||||||
go version
|
|
||||||
$GITHUB_WORKSPACE/golangci-lint version
|
|
||||||
go test -race ./...
|
go test -race ./...
|
||||||
$GITHUB_WORKSPACE/golangci-lint --config ${GITHUB_WORKSPACE}/backend/.golangci.yml run --out-format=github-actions ./...
|
go build -race ./...
|
||||||
working-directory: backend/_example/memory_store
|
working-directory: backend/_example/memory_store
|
||||||
env:
|
env:
|
||||||
TZ: "America/Chicago"
|
TZ: "America/Chicago"
|
||||||
|
|
||||||
|
- name: golangci-lint
|
||||||
|
uses: golangci/golangci-lint-action@v9
|
||||||
|
with:
|
||||||
|
version: "v2.13.1"
|
||||||
|
working-directory: backend/app
|
||||||
|
|
||||||
|
- name: golangci-lint on example directory
|
||||||
|
uses: golangci/golangci-lint-action@v9
|
||||||
|
with:
|
||||||
|
version: "v2.13.1"
|
||||||
|
args: --config ../../.golangci.yml
|
||||||
|
working-directory: backend/_example/memory_store
|
||||||
|
|
||||||
- name: submit coverage
|
- name: submit coverage
|
||||||
run: goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov
|
run: |
|
||||||
|
go install github.com/mattn/goveralls@latest
|
||||||
|
goveralls -service="github" -coverprofile=$GITHUB_WORKSPACE/profile.cov
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
env:
|
env:
|
||||||
COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
vulncheck:
|
||||||
|
name: Vulnerability scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: install go
|
||||||
|
uses: actions/setup-go@v7
|
||||||
|
with:
|
||||||
|
go-version: "1.25"
|
||||||
|
check-latest: true
|
||||||
|
# both go.sum files so the cache key covers the main and example modules scanned below
|
||||||
|
cache-dependency-path: |
|
||||||
|
backend/go.sum
|
||||||
|
backend/_example/memory_store/go.sum
|
||||||
|
|
||||||
|
- name: govulncheck
|
||||||
|
run: |
|
||||||
|
go install golang.org/x/vuln/cmd/govulncheck@v1.5.0
|
||||||
|
govulncheck ./...
|
||||||
|
(cd _example/memory_store && govulncheck ./...)
|
||||||
|
working-directory: backend
|
||||||
|
env:
|
||||||
|
# ignore the committed vendor dirs and resolve modules from the cache so
|
||||||
|
# both the main module and the nested example module scan consistently
|
||||||
|
GOFLAGS: "-mod=readonly"
|
||||||
|
|||||||
@@ -1,18 +1,6 @@
|
|||||||
name: build
|
name: build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
tags:
|
|
||||||
paths:
|
|
||||||
- ".github/workflows/ci-build.yml"
|
|
||||||
- "backend/**"
|
|
||||||
- "frontend/apps/**"
|
|
||||||
- ".dockerignore"
|
|
||||||
- "docker-init.sh"
|
|
||||||
- "Dockerfile"
|
|
||||||
- "!**.md"
|
|
||||||
- "!frontend/packages/**"
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/ci-build.yml"
|
- ".github/workflows/ci-build.yml"
|
||||||
@@ -23,79 +11,53 @@ on:
|
|||||||
- "Dockerfile"
|
- "Dockerfile"
|
||||||
- "!**.md"
|
- "!**.md"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-images:
|
build-images:
|
||||||
name: Build Docker images
|
name: Validate Docker build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
- name: set up QEMU
|
persist-credentials: false
|
||||||
uses: docker/setup-qemu-action@v2
|
|
||||||
|
|
||||||
- name: set up Docker Buildx
|
- name: set up Docker Buildx
|
||||||
id: buildx
|
uses: docker/setup-buildx-action@v4
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
|
|
||||||
- name: available platforms
|
- name: free disk space
|
||||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
|
||||||
|
|
||||||
- name: build docker image without pushing (only outside master)
|
|
||||||
if: ${{ github.ref != 'refs/heads/master' }}
|
|
||||||
run: |
|
run: |
|
||||||
docker buildx build \
|
sudo rm -rf /usr/share/dotnet
|
||||||
--build-arg SKIP_BACKEND_TEST=true --build-arg SKIP_FRONTEND_TEST=true \
|
sudo rm -rf /opt/ghc
|
||||||
--platform linux/amd64 .
|
sudo rm -rf /usr/local/share/boost
|
||||||
|
docker system prune -af
|
||||||
|
|
||||||
- name: build example docker image without pushing (only outside master)
|
- name: build docker image without pushing
|
||||||
if: ${{ github.ref != 'refs/heads/master' }}
|
uses: docker/build-push-action@v7
|
||||||
run: |
|
with:
|
||||||
docker buildx build \
|
context: .
|
||||||
--build-arg SKIP_BACKEND_TEST=true --build-arg SKIP_FRONTEND_TEST=true \
|
platforms: linux/amd64
|
||||||
--platform linux/amd64 -f backend/_example/memory_store/Dockerfile .
|
load: true
|
||||||
|
cache-from: type=gha,scope=main
|
||||||
|
cache-to: type=gha,scope=main,mode=max,ignore-error=true
|
||||||
|
build-args: |
|
||||||
|
SKIP_BACKEND_TEST=true
|
||||||
|
SKIP_FRONTEND_TEST=true
|
||||||
|
|
||||||
- name: build and deploy master image to ghcr.io and dockerhub
|
- name: build example docker image without pushing
|
||||||
if: ${{ github.ref == 'refs/heads/master' }}
|
uses: docker/build-push-action@v7
|
||||||
env:
|
with:
|
||||||
GITHUB_PACKAGE_TOKEN: ${{ secrets.PKG_TOKEN }}
|
context: .
|
||||||
DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN }}
|
file: backend/_example/memory_store/Dockerfile
|
||||||
USERNAME: ${{ github.actor }}
|
platforms: linux/amd64
|
||||||
GITHUB_SHA: ${{ github.sha}}
|
load: true
|
||||||
GITHUB_REF: ${{ github.ref}}
|
cache-from: type=gha,scope=example
|
||||||
run: |
|
cache-to: type=gha,scope=example,mode=max,ignore-error=true
|
||||||
ref="$(echo ${GITHUB_REF} | cut -d'/' -f3)"
|
build-args: |
|
||||||
echo "GITHUB_REF=${GITHUB_REF}, GITHUB_SHA=${GITHUB_SHA}, GIT_BRANCH=${ref}"
|
SKIP_BACKEND_TEST=true
|
||||||
echo ${GITHUB_PACKAGE_TOKEN} | docker login ghcr.io -u ${USERNAME} --password-stdin
|
SKIP_FRONTEND_TEST=true
|
||||||
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,44 @@
|
|||||||
|
name: compose
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/ci-compose.yml"
|
||||||
|
- "**compose*.yml"
|
||||||
|
- "**compose*.yaml"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/ci-compose.yml"
|
||||||
|
- "**compose*.yml"
|
||||||
|
- "**compose*.yaml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
name: Validate compose files
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: validate tracked compose files
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
n=0
|
||||||
|
# null-delimited to stay safe with unusual filenames; exclude this
|
||||||
|
# workflow (its name contains "compose") and vendored compose files.
|
||||||
|
# filenames are not echoed as workflow commands to avoid log-command injection
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
docker compose -f "$f" config --quiet
|
||||||
|
n=$((n + 1))
|
||||||
|
done < <(git ls-files -z '*compose*.yml' '*compose*.yaml' ':!:*/vendor/*' ':!:.github/*')
|
||||||
|
if [ "$n" -eq 0 ]; then
|
||||||
|
echo "no compose files found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "validated $n compose file(s)"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: docs versions
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/ci-docs-versions.yml"
|
||||||
|
- "scripts/check-documented-versions.sh"
|
||||||
|
- "site/content/docs/getting-started/installation/index.md"
|
||||||
|
- "backend/go.mod"
|
||||||
|
- "frontend/apps/remark42/package.json"
|
||||||
|
- "frontend/.nvmrc"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/ci-docs-versions.yml"
|
||||||
|
- "scripts/check-documented-versions.sh"
|
||||||
|
- "site/content/docs/getting-started/installation/index.md"
|
||||||
|
- "backend/go.mod"
|
||||||
|
- "frontend/apps/remark42/package.json"
|
||||||
|
- "frontend/.nvmrc"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
name: Documented versions
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Check documented versions against the repository
|
||||||
|
run: ./scripts/check-documented-versions.sh
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
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 }}
|
|
||||||
@@ -6,54 +6,46 @@ on:
|
|||||||
- master
|
- master
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/ci-frontend.yml"
|
- ".github/workflows/ci-frontend.yml"
|
||||||
- "frontend/apps/remark42/**"
|
- "frontend/**"
|
||||||
- "!**.md"
|
- "!**.md"
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/ci-frontend.yml"
|
- ".github/workflows/ci-frontend.yml"
|
||||||
- "frontend/apps/remark42/**"
|
- "frontend/**"
|
||||||
- "!**.md"
|
- "!**.md"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
translations-check:
|
translations-check:
|
||||||
name: Translations check
|
name: Translations check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node: [16.15.1]
|
node: [24]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install node
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node }}
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v2.0.1
|
uses: pnpm/action-setup@v6.0.10
|
||||||
id: pnpm-install
|
|
||||||
with:
|
with:
|
||||||
version: 7
|
version: 10.10.0
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Install node
|
||||||
id: pnpm-cache
|
uses: actions/setup-node@v7
|
||||||
run: |
|
|
||||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
node-version: ${{ matrix.node }}
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
cache: "pnpm"
|
||||||
restore-keys: |
|
cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
|
||||||
${{ runner.os }}-pnpm-store-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm i
|
run: pnpm install --frozen-lockfile
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend/apps/remark42
|
||||||
|
|
||||||
- name: Translations check
|
- name: Translations check
|
||||||
run: pnpm translation-check
|
run: pnpm translation-check
|
||||||
@@ -62,42 +54,34 @@ jobs:
|
|||||||
type-check:
|
type-check:
|
||||||
name: Type check
|
name: Type check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node: [16.15.1]
|
node: [24]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install node
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node }}
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v2.0.1
|
uses: pnpm/action-setup@v6.0.10
|
||||||
id: pnpm-install
|
|
||||||
with:
|
with:
|
||||||
version: 7
|
version: 10.10.0
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Install node
|
||||||
id: pnpm-cache
|
uses: actions/setup-node@v7
|
||||||
run: |
|
|
||||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
node-version: ${{ matrix.node }}
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
cache: "pnpm"
|
||||||
restore-keys: |
|
cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
|
||||||
${{ runner.os }}-pnpm-store-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm i
|
run: pnpm install --frozen-lockfile
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend/apps/remark42
|
||||||
|
|
||||||
- name: Run type check
|
- name: Run type check
|
||||||
run: pnpm type-check
|
run: pnpm type-check
|
||||||
@@ -106,42 +90,34 @@ jobs:
|
|||||||
lint:
|
lint:
|
||||||
name: Eslint & Stylelint
|
name: Eslint & Stylelint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node: [16.15.1]
|
node: [24]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install node
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node }}
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v2.0.1
|
uses: pnpm/action-setup@v6.0.10
|
||||||
id: pnpm-install
|
|
||||||
with:
|
with:
|
||||||
version: 7
|
version: 10.10.0
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Install node
|
||||||
id: pnpm-cache
|
uses: actions/setup-node@v7
|
||||||
run: |
|
|
||||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
node-version: ${{ matrix.node }}
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
cache: "pnpm"
|
||||||
restore-keys: |
|
cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
|
||||||
${{ runner.os }}-pnpm-store-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm i
|
run: pnpm install --frozen-lockfile
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend/apps/remark42
|
||||||
|
|
||||||
- name: Run linters
|
- name: Run linters
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
@@ -151,70 +127,69 @@ jobs:
|
|||||||
name: Size limit
|
name: Size limit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
env:
|
env:
|
||||||
CI_JOB_NUMBER: 1
|
CI_JOB_NUMBER: 1
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v2.0.1
|
uses: pnpm/action-setup@v6.0.10
|
||||||
id: pnpm-install
|
|
||||||
with:
|
with:
|
||||||
version: 7
|
version: 10.10.0
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Check bundle size
|
- name: Check bundle size
|
||||||
uses: andresz1/size-limit-action@v1
|
uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
directory: ./frontend/apps/remark42
|
directory: ./frontend/apps/remark42
|
||||||
|
package_manager: pnpm
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Tests & Coverage
|
name: Tests & Coverage
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node: [16.15.1]
|
node: [24]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install node
|
|
||||||
uses: actions/setup-node@v3
|
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node }}
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v2.0.1
|
uses: pnpm/action-setup@v6.0.10
|
||||||
id: pnpm-install
|
|
||||||
with:
|
with:
|
||||||
version: 7
|
version: 10.10.0
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Install node
|
||||||
id: pnpm-cache
|
uses: actions/setup-node@v7
|
||||||
run: |
|
|
||||||
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
|
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
node-version: ${{ matrix.node }}
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
cache: "pnpm"
|
||||||
restore-keys: |
|
cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
|
||||||
${{ runner.os }}-pnpm-store-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm i
|
run: pnpm install --frozen-lockfile
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend/apps/remark42
|
||||||
|
|
||||||
- name: Test & Coverage
|
- name: Test & Coverage
|
||||||
run: pnpm coverage
|
run: pnpm coverage
|
||||||
working-directory: ./frontend/apps/remark42
|
working-directory: ./frontend/apps/remark42
|
||||||
|
|
||||||
- name: Submit coverage
|
- name: Upload coverage to Codecov
|
||||||
run: ${{ github.workspace }}/frontend/apps/remark42/node_modules/.bin/codecov
|
uses: codecov/codecov-action@v7
|
||||||
env:
|
with:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
working-directory: ./frontend/apps/remark42
|
||||||
|
codecov_yml_path: ./frontend/apps/remark42/codecov.yml
|
||||||
|
|||||||
+144
-38
@@ -1,69 +1,175 @@
|
|||||||
name: site
|
name: site
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
tags:
|
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/ci-site.yml"
|
- ".github/workflows/ci-site.yml"
|
||||||
- "site/**"
|
- "site/**"
|
||||||
|
- "!**/CLAUDE.md"
|
||||||
|
- "!site/README.md"
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- ".github/workflows/ci-site.yml"
|
- ".github/workflows/ci-site.yml"
|
||||||
- "site/**"
|
- "site/**"
|
||||||
|
- "!**/CLAUDE.md"
|
||||||
|
- "!site/README.md"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
validate:
|
||||||
name: Build
|
name: Build site image (pull request)
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: checkout
|
- name: checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: set up QEMU
|
- name: set up docker buildx
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
- name: build image without pushing
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: ./site
|
||||||
|
load: true
|
||||||
|
push: false
|
||||||
|
cache-from: |
|
||||||
|
type=gha,scope=site-pr
|
||||||
|
type=gha,scope=site-linux/amd64
|
||||||
|
cache-to: type=gha,scope=site-pr,mode=max,ignore-error=true
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build site image (${{ matrix.platform }})
|
||||||
|
if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/')
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
artifact: linux-amd64
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
artifact: linux-arm64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: set up Docker Buildx
|
- name: set up Docker Buildx
|
||||||
id: buildx
|
uses: docker/setup-buildx-action@v4
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
|
|
||||||
- name: available platforms
|
- name: login to ghcr.io
|
||||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.PKG_TOKEN }}
|
||||||
|
|
||||||
- name: build and deploy master image to ghcr.io and dockerhub
|
- name: build and push by digest
|
||||||
if: ${{ github.ref == 'refs/heads/master' }}
|
id: build
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: ./site
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
cache-from: type=gha,scope=site-${{ matrix.platform }}
|
||||||
|
cache-to: type=gha,scope=site-${{ matrix.platform }},mode=max,ignore-error=true
|
||||||
|
outputs: type=image,name=ghcr.io/umputun/remark42-site,push-by-digest=true,name-canonical=true,push=true
|
||||||
|
|
||||||
|
- name: export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: upload digest
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: site-digests-${{ matrix.artifact }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
name: Create site multi-arch manifest
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: download digests
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: site-digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: verify all digests present
|
||||||
|
run: |
|
||||||
|
expected=2
|
||||||
|
actual=$(find /tmp/digests -maxdepth 1 -type f | wc -l)
|
||||||
|
if [ "$actual" -ne "$expected" ]; then
|
||||||
|
echo "Expected $expected digests, found $actual"
|
||||||
|
ls -la /tmp/digests
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "All $expected digests present"
|
||||||
|
|
||||||
|
- name: set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
- name: login to ghcr.io
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.PKG_TOKEN }}
|
||||||
|
|
||||||
|
- name: create manifest and push
|
||||||
|
working-directory: /tmp/digests
|
||||||
env:
|
env:
|
||||||
GITHUB_PACKAGE_TOKEN: ${{ secrets.PKG_TOKEN }}
|
GITHUB_REF: ${{ github.ref }}
|
||||||
USERNAME: ${{ github.actor }}
|
|
||||||
GITHUB_SHA: ${{ github.sha}}
|
|
||||||
GITHUB_REF: ${{ github.ref}}
|
|
||||||
working-directory: ./site
|
|
||||||
run: |
|
run: |
|
||||||
ref="$(echo ${GITHUB_REF} | cut -d'/' -f3)"
|
ref="$(echo ${GITHUB_REF} | cut -d'/' -f3)"
|
||||||
echo GITHUB_REF - $ref
|
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||||
echo ${GITHUB_PACKAGE_TOKEN} | docker login ghcr.io -u ${USERNAME} --password-stdin
|
docker buildx imagetools create \
|
||||||
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:${ref} .
|
-t ghcr.io/umputun/remark42-site:latest \
|
||||||
|
$(printf 'ghcr.io/umputun/remark42-site@sha256:%s ' *)
|
||||||
|
else
|
||||||
|
docker buildx imagetools create \
|
||||||
|
-t ghcr.io/umputun/remark42-site:${ref} \
|
||||||
|
$(printf 'ghcr.io/umputun/remark42-site@sha256:%s ' *)
|
||||||
|
fi
|
||||||
|
|
||||||
- name: deploy tagged (latest) to ghcr.io and dockerhub
|
deploy:
|
||||||
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
name: Deploy site
|
||||||
env:
|
runs-on: ubuntu-latest
|
||||||
GITHUB_PACKAGE_TOKEN: ${{ secrets.PKG_TOKEN }}
|
needs: merge
|
||||||
USERNAME: ${{ github.actor }}
|
if: github.ref == 'refs/heads/master' || github.event_name == 'release'
|
||||||
GITHUB_SHA: ${{ github.sha}}
|
permissions: {} # only calls an external URL via curl, no GitHub API access needed
|
||||||
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
|
steps:
|
||||||
if: ${{ github.ref == 'refs/heads/master' }}
|
- name: trigger deployment
|
||||||
env:
|
env:
|
||||||
UPDATER_KEY: ${{ secrets.UPDATER_KEY }}
|
UPDATER_KEY: ${{ secrets.UPDATER_KEY }}
|
||||||
run: curl https://jess.umputun.com/update/remark42-site/${UPDATER_KEY}
|
run: curl -sf https://jess.umputun.com/update/remark42-site/${UPDATER_KEY}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
name: docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: [backend, frontend]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Docker image (${{ matrix.platform }})
|
||||||
|
if: >-
|
||||||
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
|
github.event.workflow_run.event != 'pull_request' &&
|
||||||
|
(github.event.workflow_run.head_branch == 'master' ||
|
||||||
|
startsWith(github.event.workflow_run.head_branch, 'v'))
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
artifact: linux-amd64
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
artifact: linux-arm64
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
- name: login to ghcr.io
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.PKG_TOKEN }}
|
||||||
|
|
||||||
|
- name: login to DockerHub
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
username: umputun
|
||||||
|
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: free disk space
|
||||||
|
run: |
|
||||||
|
sudo rm -rf /usr/share/dotnet
|
||||||
|
sudo rm -rf /opt/ghc
|
||||||
|
sudo rm -rf /usr/local/share/boost
|
||||||
|
docker system prune -af
|
||||||
|
|
||||||
|
- name: build and push to ghcr.io by digest
|
||||||
|
id: build-ghcr
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||||
|
cache-to: type=gha,scope=${{ matrix.platform }},mode=max
|
||||||
|
build-args: |
|
||||||
|
SKIP_BACKEND_TEST=true
|
||||||
|
SKIP_FRONTEND_TEST=true
|
||||||
|
CI=github
|
||||||
|
GITHUB_SHA=${{ github.event.workflow_run.head_sha }}
|
||||||
|
GIT_BRANCH=${{ github.event.workflow_run.head_branch }}
|
||||||
|
GITHUB_REF=refs/heads/${{ github.event.workflow_run.head_branch }}
|
||||||
|
outputs: type=image,name=ghcr.io/umputun/remark42,push-by-digest=true,name-canonical=true,push=true
|
||||||
|
|
||||||
|
- name: build and push to DockerHub by digest
|
||||||
|
id: build-dockerhub
|
||||||
|
uses: docker/build-push-action@v7
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||||
|
build-args: |
|
||||||
|
SKIP_BACKEND_TEST=true
|
||||||
|
SKIP_FRONTEND_TEST=true
|
||||||
|
CI=github
|
||||||
|
GITHUB_SHA=${{ github.event.workflow_run.head_sha }}
|
||||||
|
GIT_BRANCH=${{ github.event.workflow_run.head_branch }}
|
||||||
|
GITHUB_REF=refs/heads/${{ github.event.workflow_run.head_branch }}
|
||||||
|
outputs: type=image,name=umputun/remark42,push-by-digest=true,name-canonical=true,push=true
|
||||||
|
|
||||||
|
- name: export digests
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests/ghcr /tmp/digests/dockerhub
|
||||||
|
digest_ghcr="${{ steps.build-ghcr.outputs.digest }}"
|
||||||
|
digest_dockerhub="${{ steps.build-dockerhub.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/ghcr/${digest_ghcr#sha256:}"
|
||||||
|
touch "/tmp/digests/dockerhub/${digest_dockerhub#sha256:}"
|
||||||
|
|
||||||
|
- name: upload ghcr digest
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: digests-ghcr-${{ matrix.artifact }}
|
||||||
|
path: /tmp/digests/ghcr/*
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
- name: upload dockerhub digest
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: digests-dockerhub-${{ matrix.artifact }}
|
||||||
|
path: /tmp/digests/dockerhub/*
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
name: Create multi-arch manifest
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: download ghcr digests
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
path: /tmp/digests/ghcr
|
||||||
|
pattern: digests-ghcr-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: download dockerhub digests
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
path: /tmp/digests/dockerhub
|
||||||
|
pattern: digests-dockerhub-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: verify all digests present
|
||||||
|
run: |
|
||||||
|
expected=2
|
||||||
|
for registry in ghcr dockerhub; do
|
||||||
|
actual=$(find /tmp/digests/$registry -maxdepth 1 -type f | wc -l)
|
||||||
|
if [ "$actual" -ne "$expected" ]; then
|
||||||
|
echo "Expected $expected digests for $registry, found $actual"
|
||||||
|
ls -la /tmp/digests/$registry
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "All digests present for both registries"
|
||||||
|
|
||||||
|
- name: set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
- name: login to ghcr.io
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.PKG_TOKEN }}
|
||||||
|
|
||||||
|
- name: login to DockerHub
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
username: umputun
|
||||||
|
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: create ghcr.io manifest and push
|
||||||
|
working-directory: /tmp/digests/ghcr
|
||||||
|
env:
|
||||||
|
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||||
|
run: |
|
||||||
|
if [[ "$HEAD_BRANCH" == v* ]]; then
|
||||||
|
docker buildx imagetools create \
|
||||||
|
-t ghcr.io/umputun/remark42:${HEAD_BRANCH} \
|
||||||
|
-t ghcr.io/umputun/remark42:latest \
|
||||||
|
$(printf 'ghcr.io/umputun/remark42@sha256:%s ' *)
|
||||||
|
else
|
||||||
|
docker buildx imagetools create \
|
||||||
|
-t ghcr.io/umputun/remark42:${HEAD_BRANCH} \
|
||||||
|
$(printf 'ghcr.io/umputun/remark42@sha256:%s ' *)
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: create DockerHub manifest and push
|
||||||
|
working-directory: /tmp/digests/dockerhub
|
||||||
|
env:
|
||||||
|
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||||
|
run: |
|
||||||
|
if [[ "$HEAD_BRANCH" == v* ]]; then
|
||||||
|
docker buildx imagetools create \
|
||||||
|
-t umputun/remark42:${HEAD_BRANCH} \
|
||||||
|
-t umputun/remark42:latest \
|
||||||
|
$(printf 'umputun/remark42@sha256:%s ' *)
|
||||||
|
else
|
||||||
|
docker buildx imagetools create \
|
||||||
|
-t umputun/remark42:${HEAD_BRANCH} \
|
||||||
|
$(printf 'umputun/remark42@sha256:%s ' *)
|
||||||
|
fi
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy to remark42.com
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: merge
|
||||||
|
if: github.event.workflow_run.head_branch == 'master'
|
||||||
|
permissions: {} # only calls an external URL via curl, no GitHub API access needed
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: trigger deployment
|
||||||
|
env:
|
||||||
|
UPDATER_KEY: ${{ secrets.UPDATER_KEY }}
|
||||||
|
run: curl -sf https://jess.umputun.com/update/remark42-core/${UPDATER_KEY}
|
||||||
+108
-17
@@ -4,31 +4,122 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
paths:
|
paths:
|
||||||
- "frontend/apps/remark42/**"
|
- ".github/workflows/e2e-tests.yml"
|
||||||
- "frontend/e2e/**"
|
- "backend/**"
|
||||||
|
- "frontend/**"
|
||||||
|
- "e2e/**"
|
||||||
|
- "compose-e2e-test.yml"
|
||||||
|
- "Dockerfile"
|
||||||
|
- "!**.md"
|
||||||
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
paths:
|
paths:
|
||||||
- "frontend/apps/remark42/**"
|
- ".github/workflows/e2e-tests.yml"
|
||||||
- "frontend/e2e/**"
|
- "backend/**"
|
||||||
|
- "frontend/**"
|
||||||
|
- "e2e/**"
|
||||||
|
- "compose-e2e-test.yml"
|
||||||
|
- "Dockerfile"
|
||||||
|
- "!**.md"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests:
|
# cheap gate: catches a compile break or a lint regression in the build-tagged suite
|
||||||
name: Tests
|
# without paying for the docker build and the browser download
|
||||||
timeout-minutes: 60
|
vet:
|
||||||
|
name: Vet
|
||||||
|
timeout-minutes: 10
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- 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:
|
with:
|
||||||
name: playwright-report
|
persist-credentials: false
|
||||||
path: ./playwright-report/
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v7
|
||||||
|
with:
|
||||||
|
go-version-file: e2e/go.mod
|
||||||
|
cache-dependency-path: e2e/go.sum
|
||||||
|
|
||||||
|
- name: Vet
|
||||||
|
run: cd e2e && go vet -tags=e2e ./...
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
uses: golangci/golangci-lint-action@v9
|
||||||
|
with:
|
||||||
|
version: v2.13.1
|
||||||
|
working-directory: e2e
|
||||||
|
args: --build-tags=e2e --config ../backend/.golangci.yml
|
||||||
|
|
||||||
|
tests:
|
||||||
|
name: Tests
|
||||||
|
needs: vet
|
||||||
|
# generous against the docker build plus one 8m go test: a job cancelled on timeout skips
|
||||||
|
# its own failure steps, so the run would end with neither logs nor traces
|
||||||
|
timeout-minutes: 45
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v7
|
||||||
|
with:
|
||||||
|
go-version-file: e2e/go.mod
|
||||||
|
cache-dependency-path: e2e/go.sum
|
||||||
|
|
||||||
|
# two directories: the driver (node plus the npm package) and the browser builds,
|
||||||
|
# which include firefox and webkit for the rendering tests
|
||||||
|
- name: Cache playwright driver and browsers
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/ms-playwright
|
||||||
|
~/.cache/ms-playwright-go
|
||||||
|
key: playwright-${{ hashFiles('e2e/go.sum') }}
|
||||||
|
restore-keys: playwright-
|
||||||
|
|
||||||
|
# E2E_STAMP is what the suite compares the running stack against, so a stack started here
|
||||||
|
# has to carry the same value `make e2e-up` and the suite itself would give it
|
||||||
|
- name: Build & start the stack
|
||||||
|
run: |
|
||||||
|
./e2e/tls/generate.sh
|
||||||
|
COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 E2E_STAMP=$(./e2e/stamp.sh) \
|
||||||
|
docker compose -f compose-e2e-test.yml up -d --build --quiet-pull --wait
|
||||||
|
|
||||||
|
# no retry: a failure here is evidence about a suite too young to have a flake rate,
|
||||||
|
# and a rerun is how an intermittent regression becomes invisible. revisit when there
|
||||||
|
# are failures on record to look at
|
||||||
|
- name: Run e2e
|
||||||
|
# stamps this run's comment threads with the CI run, so a thread url in a trace or a
|
||||||
|
# log names the run it came from
|
||||||
|
env:
|
||||||
|
E2E_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
# 20m, matching the Makefile. the suite runs about four minutes on a laptop and a runner
|
||||||
|
# is slower, so a tighter budget turns a loaded runner into a timeout panic instead of a
|
||||||
|
# readable failure. the job's own timeout above is what bounds a wedged run
|
||||||
|
run: cd e2e && go test -tags=e2e -count 1 -timeout 20m -v ./...
|
||||||
|
|
||||||
|
- name: Server logs on failure
|
||||||
|
if: failure()
|
||||||
|
run: docker compose -f compose-e2e-test.yml logs --tail=200
|
||||||
|
|
||||||
|
- name: Upload browser traces
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: playwright-traces
|
||||||
|
path: e2e/traces/
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/release.yml"
|
||||||
|
- ".goreleaser.yml"
|
||||||
|
- "Makefile"
|
||||||
|
- "scripts/**"
|
||||||
|
- "backend/**"
|
||||||
|
- "frontend/**"
|
||||||
|
- "!backend/**.md"
|
||||||
|
- "!frontend/**.md"
|
||||||
|
- "README.md"
|
||||||
|
- "LICENSE"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: install go
|
||||||
|
uses: actions/setup-go@v7
|
||||||
|
with:
|
||||||
|
go-version: "1.25"
|
||||||
|
check-latest: true
|
||||||
|
cache-dependency-path: backend/go.sum
|
||||||
|
|
||||||
|
- name: install pnpm
|
||||||
|
uses: pnpm/action-setup@v6.0.10
|
||||||
|
with:
|
||||||
|
version: 10.10.0
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: install node
|
||||||
|
uses: actions/setup-node@v7
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "pnpm"
|
||||||
|
cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: test and build backend
|
||||||
|
run: |
|
||||||
|
go test -race -timeout=300s ./...
|
||||||
|
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: install frontend dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
working-directory: frontend/apps/remark42
|
||||||
|
env:
|
||||||
|
CI: "true"
|
||||||
|
|
||||||
|
- name: check frontend
|
||||||
|
run: |
|
||||||
|
pnpm lint
|
||||||
|
pnpm type-check
|
||||||
|
pnpm test --runInBand
|
||||||
|
working-directory: frontend/apps/remark42
|
||||||
|
env:
|
||||||
|
CI: "true"
|
||||||
|
|
||||||
|
- name: check goreleaser snapshot
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: goreleaser/goreleaser-action@v7
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
args: release --snapshot --clean --skip=publish
|
||||||
|
env:
|
||||||
|
SKIP_PNPM_INSTALL: "true"
|
||||||
|
|
||||||
|
- name: clean generated release assets
|
||||||
|
if: always()
|
||||||
|
run: ./scripts/cleanup-release-assets.sh
|
||||||
|
|
||||||
|
release:
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
needs: validate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: install go
|
||||||
|
uses: actions/setup-go@v7
|
||||||
|
with:
|
||||||
|
go-version: "1.25"
|
||||||
|
check-latest: true
|
||||||
|
cache-dependency-path: backend/go.sum
|
||||||
|
|
||||||
|
- name: install pnpm
|
||||||
|
uses: pnpm/action-setup@v6.0.10
|
||||||
|
with:
|
||||||
|
version: 10.10.0
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: install node
|
||||||
|
uses: actions/setup-node@v7
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: "pnpm"
|
||||||
|
cache-dependency-path: frontend/apps/remark42/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: install frontend dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
working-directory: frontend/apps/remark42
|
||||||
|
env:
|
||||||
|
CI: "true"
|
||||||
|
|
||||||
|
- name: run goreleaser
|
||||||
|
uses: goreleaser/goreleaser-action@v7
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
args: release --clean
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
SKIP_PNPM_INSTALL: "true"
|
||||||
|
|
||||||
|
- name: clean generated release assets
|
||||||
|
if: always()
|
||||||
|
run: ./scripts/cleanup-release-assets.sh
|
||||||
+11
-1
@@ -15,6 +15,7 @@ debug.test
|
|||||||
.mongo
|
.mongo
|
||||||
remark42
|
remark42
|
||||||
/bin/
|
/bin/
|
||||||
|
/dist/
|
||||||
/backend/var/
|
/backend/var/
|
||||||
/backend/app/var/
|
/backend/app/var/
|
||||||
/backend/app/cmd/web/
|
/backend/app/cmd/web/
|
||||||
@@ -24,4 +25,13 @@ compose-private-frontend.yml
|
|||||||
compose-private.yml
|
compose-private.yml
|
||||||
/backend/_example/*/vendor
|
/backend/_example/*/vendor
|
||||||
http-client.env.json
|
http-client.env.json
|
||||||
/playwright-report/
|
/backend/app/cmd/var
|
||||||
|
|
||||||
|
# ralphex progress logs
|
||||||
|
.ralphex/progress/
|
||||||
|
|
||||||
|
# traces from failed e2e runs
|
||||||
|
/e2e/traces/
|
||||||
|
|
||||||
|
# self-signed certificate for the e2e https services, made by e2e/tls/generate.sh
|
||||||
|
/e2e/tls/*.pem
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
project_name: remark42
|
||||||
|
|
||||||
|
git:
|
||||||
|
ignore_tags:
|
||||||
|
- backend/*
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- ./scripts/prepare-release-assets.sh
|
||||||
|
|
||||||
|
builds:
|
||||||
|
- id: remark42
|
||||||
|
dir: backend
|
||||||
|
main: ./app
|
||||||
|
binary: "remark42.{{ .Os }}-{{ .Arch }}"
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
- darwin
|
||||||
|
- freebsd
|
||||||
|
- windows
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
- arm64
|
||||||
|
- "386"
|
||||||
|
ignore:
|
||||||
|
- goos: darwin
|
||||||
|
goarch: "386"
|
||||||
|
- goos: freebsd
|
||||||
|
goarch: arm64
|
||||||
|
- goos: freebsd
|
||||||
|
goarch: "386"
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm64
|
||||||
|
- goos: windows
|
||||||
|
goarch: "386"
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X main.revision={{ .Tag }}-{{ .ShortCommit }}-{{ trimsuffix (replace (replace .CommitDate "-" "") ":" "") "Z" }}
|
||||||
|
|
||||||
|
archives:
|
||||||
|
- id: remark42
|
||||||
|
ids:
|
||||||
|
- remark42
|
||||||
|
name_template: "{{ .ProjectName }}.{{ .Os }}-{{ .Arch }}"
|
||||||
|
formats:
|
||||||
|
- tar.gz
|
||||||
|
format_overrides:
|
||||||
|
- goos: windows
|
||||||
|
formats:
|
||||||
|
- zip
|
||||||
|
files:
|
||||||
|
- LICENSE
|
||||||
|
- README.md
|
||||||
|
|
||||||
|
release:
|
||||||
|
name_template: "Version {{ .Version }}"
|
||||||
|
mode: keep-existing
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# Remark42 Development Guidelines
|
||||||
|
|
||||||
|
## Build/Test/Lint Commands
|
||||||
|
- **Backend**:
|
||||||
|
- Run server: `make rundev`
|
||||||
|
- Build: `make backend`
|
||||||
|
- Race test: `make race_test`
|
||||||
|
- **Backend Testing**:
|
||||||
|
- Run all tests: `cd backend/app && go test -timeout=300s -count 1 ./...`
|
||||||
|
- Run single test: `cd backend/app && go test -run TestName ./path/to/package`
|
||||||
|
- **IMPORTANT**: Run example tests: `cd backend/_example/memory_store && go test -race ./... && go build -race ./...`
|
||||||
|
- **Frontend**:
|
||||||
|
- Development: `cd frontend/apps/remark42 && pnpm dev`
|
||||||
|
- Tests: `cd frontend/apps/remark42 && pnpm test`
|
||||||
|
- **End-to-end**: `make e2e` drives the widget in a real browser; see `e2e/README.md`. Build-tagged, so `go test ./...` never runs it.
|
||||||
|
- **Lint**:
|
||||||
|
- Backend: `cd backend && golangci-lint run`
|
||||||
|
- **IMPORTANT**: Example lint: `cd backend/_example/memory_store && golangci-lint run --config ../../.golangci.yml`
|
||||||
|
- Frontend: `cd frontend/apps/remark42 && pnpm lint`
|
||||||
|
- **Before committing**: Always run tests and linter on both main backend AND examples
|
||||||
|
- **Go module changes**:
|
||||||
|
- **Any** change to `backend/go.mod` or `backend/go.sum` requires `go mod tidy` in `backend/_example/memory_store` in the same commit. That covers dependency bumps, adding or removing a dependency, and changing the `go` directive, not only version updates.
|
||||||
|
- Only `go mod tidy` there, not `go mod vendor`: the example's vendor directory is gitignored (`.gitignore:26`), so its output is never committed, while a stale local copy silently becomes what the example resolves against.
|
||||||
|
- The example module replaces `github.com/umputun/remark42/backend` with `../../`, so it carries the backend's dependencies as indirect entries. Leaving them stale fails the `test examples` CI step with `go: updates to go.mod needed; to update it: go mod tidy`.
|
||||||
|
- This applies to Dependabot pull requests too: the bot updates `backend/` only, so its Go module PRs need the example tidied before they can go green.
|
||||||
|
|
||||||
|
|
||||||
|
## Backend Test Determinism
|
||||||
|
|
||||||
|
Backend tests must never depend on how fast the machine is. CI runs them under `-race` with coverage on a shared runner, so any test that assumes an operation finishes within some duration eventually fails on a rerun-and-it-passes basis.
|
||||||
|
|
||||||
|
- **Wait on a condition, never on a duration.** Use `require.Eventually` / `require.EventuallyWithT` to poll for the state the assertion needs, and `require.Never` when the point is that something did *not* happen. A bare `time.Sleep` before an assertion is a defect; sleeping until a deadline you computed, as `waitPastMillisecond` does, is not.
|
||||||
|
- **Polling closures must not touch `*testing.T`.** testify runs them on a separate goroutine, where `t.FailNow` is undefined behaviour. Assert on the `*assert.CollectT` that `EventuallyWithT` hands the closure, so the real error also lands in the failure message.
|
||||||
|
- **Mind the rate limiter when polling over HTTP.** Route groups are capped independently and most of the caps are hard-coded in `rest.go`, out of reach of a test: `/auth/` at 2 req/s and the admin, protected and image routes at 10 req/s. Only the open-route group is settable, via `openRouteLimiter` (100 in `startupT`). Poll with the existing constants rather than a new number, `httpPoll` for anything issuing an HTTP request and `pollInterval` only for in-process or filesystem checks, or the poll manufactures the 429s it then has to interpret.
|
||||||
|
- **When a test needs time to have passed, pin the clock input rather than waiting for it:** `os.Chtimes` for file ages, an explicit `store.Comment.Timestamp` for anything that formats a timestamp.
|
||||||
|
- **Prefer a `testing/synctest` bubble** where the code under test has no real I/O. Inside one the clock is fake, so `time.Sleep` is instant and deterministic. `app/notify`, `app/store/service`, `app/store/image`, `app/store/engine`, `app/providers`, `app/migrator` and `_example/memory_store/accessor` already use it, and most surviving `time.Sleep` calls live in them.
|
||||||
|
- **Helpers fail loudly.** A wait that gives up must call `t.Fatal`/`require` naming what it was waiting for, never return silently and leave the next assertion to fail with something unrelated. Because these packages run `goleak.VerifyTestMain`, a failing helper also exits the test goroutine, so anything that started a server in a goroutine must `defer cancel()` or `defer srv.Shutdown()` right after launching it; otherwise a failed readiness wait is reported as a goroutine leak rather than the failure that caused it.
|
||||||
|
- **Take ports and paths from outside the test.** Ports come from the kernel with `net.Listen("tcp", ":0")`, files from `t.TempDir()`. `go test ./...` runs package binaries concurrently, so a number out of a fixed range or a fixed name under `/tmp` lets two of them collide.
|
||||||
|
- **Close idle connections before shutting a test server down.** Clients built as `http.Client{Timeout: x}` share `http.DefaultTransport`, and `Shutdown` waits on their keep-alive connections until its own deadline expires.
|
||||||
|
- **Keep the test timeout budgets aligned.** `Makefile`, `ci-backend.yml`, `release.yml` and the command above all use `-timeout=300s`; the wait helpers allow 30s per condition, so a shorter per-package budget turns a slow runner into a timeout panic instead of a readable failure.
|
||||||
|
|
||||||
|
`chooseUnusedPort` and the server-start wait helpers are duplicated in `app`, `app/cmd`, `app/rest/api` and `_example/memory_store/server`. Nothing shares them today; keep the copies in step when changing one.
|
||||||
|
|
||||||
|
## Release Procedure
|
||||||
|
|
||||||
|
Remark42 uses two tags for each release:
|
||||||
|
- `vX.Y.Z` - product release tag used by GitHub releases, GoReleaser binary artifacts, and Docker image publishing.
|
||||||
|
- `backend/vX.Y.Z` - nested Go module tag for `github.com/umputun/remark42/backend`.
|
||||||
|
|
||||||
|
Release flow:
|
||||||
|
1. Create the GitHub release for `vX.Y.Z` with title `Version X.Y.Z`. The GitHub release must exist before the `vX.Y.Z` tag reaches the remote; `gh release create vX.Y.Z` satisfies this because it creates and pushes the tag.
|
||||||
|
2. The `vX.Y.Z` tag triggers GoReleaser, which builds and uploads binary artifacts to the existing release.
|
||||||
|
3. Create and push the matching backend module tag pointing at the same commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git fetch origin --tags
|
||||||
|
git tag backend/vX.Y.Z vX.Y.Z
|
||||||
|
git push origin backend/vX.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
GoReleaser must ignore `backend/*` tags in `.goreleaser.yml` so release notes and current-tag detection use only product tags. Docker image publishing stays separate and is handled by the existing Docker workflow.
|
||||||
|
|
||||||
|
For local artifact runs, install GoReleaser, Go 1.25, Node 24+ and PNPM 10, then use `make release`. The target runs a snapshot/no-publish GoReleaser build, leaves local artifacts and metadata in `dist/`, and cleans generated frontend embed files after GoReleaser exits. Do not run raw `goreleaser release` for local artifacts unless you also run `./scripts/cleanup-release-assets.sh` afterward.
|
||||||
|
|
||||||
|
## Milestones and Issue Labels
|
||||||
|
|
||||||
|
**Milestones** — one `vX.Y.Z` milestone per release. Assign every merged PR, and every issue closed by a code change, to the milestone of the release it shipped in.
|
||||||
|
- Decide which release a PR belongs to by whether its merge commit is **contained in a release tag** — not by comparing dates (a tag can be cut from an earlier commit, or moved). `git fetch --tags`, then `git tag --contains <merge_sha> | grep '^v' | sort -V | head -1` is its release. If no release tag contains it yet, it belongs to the next (unreleased) version's milestone — create it if missing (`gh api repos/umputun/remark42/milestones -f title="vX.Y.Z"`).
|
||||||
|
- An **issue gets a milestone only when it was closed by a code change** (a linked closing PR/commit); take the milestone from that PR/commit (via the commit-in-tag rule). Issues closed as `duplicate`/`invalid`/`wontfix`/answered get no milestone.
|
||||||
|
- Find unassigned: `gh pr list --state merged --search "no:milestone"`, `gh issue list --state closed --search "no:milestone"`. Assign with `gh pr edit N --milestone "vX.Y.Z"` / `gh issue edit N --milestone "vX.Y.Z"`.
|
||||||
|
|
||||||
|
**Issue labels** — classify each issue with a type and an area (add priority when relevant):
|
||||||
|
- Type: `bug`, `enhancement`, `question`, `documentation`, `discussion`
|
||||||
|
- Area: `backend`, `frontend`, `site`, `CI`, `design`, `localization`
|
||||||
|
- Priority: `important`, `minor`, `some day`
|
||||||
|
- Contribution: `help wanted`, `good-first-issue`
|
||||||
|
- Resolution (on close, when applicable): `duplicate`, `invalid`, `wontfix`, `no-action-needed`
|
||||||
|
- PR auto-labels (applied by Dependabot/Actions, not manual PRs): `dependencies`, `go`, `javascript`, `github_actions`
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
- **Backend**: Formatting with golangci-lint, strict error handling
|
||||||
|
- **Frontend**: TypeScript with ESLint, Stylelint and Prettier
|
||||||
|
- **Imports**: Group stdlib, external packages, then internal packages
|
||||||
|
- **CSS**: All components use CSS Modules (`component.module.css`). Class naming: BEM block = `.root`, elements = camelCase, modifiers = camelCase. Use `clsx` for conditional class composition. `raw-content.css` is the only global CSS file (syntax highlighting utility). Root wrapper keeps bare `.dark`/`.light` theme class — 8+ module CSS files depend on `:global(.dark)` ancestor. `comment_highlighting` uses `:global()` for imperative `classList` usage in root.tsx
|
||||||
|
|
||||||
|
## Key Backend Packages
|
||||||
|
- **Web/API**: `github.com/go-pkgz/routegroup`, `github.com/go-pkgz/rest`
|
||||||
|
- **Auth**: `github.com/go-pkgz/auth/v2`
|
||||||
|
- **Logging**: `github.com/go-pkgz/lgr`
|
||||||
|
- **Testing**: `github.com/stretchr/testify`
|
||||||
|
- **Notifications**: `github.com/go-pkgz/notify`
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
- Backend: Go server using BoltDB for storage
|
||||||
|
- Frontend: Preact/Redux-based UI with iframe embedding
|
||||||
|
- `/web` is served from two sources, in lookup order: the frontend build output
|
||||||
|
(`frontend/apps/remark42/public`, embedded at `backend/app/cmd/web` or read from `--web-root`),
|
||||||
|
then `backend/app/webassets/assets`, embedded in the binary. A plain page or image the bundler
|
||||||
|
does not process belongs in `webassets`; anything needing templating or the widget's CSS/JS goes
|
||||||
|
through webpack. A name present in both is served from the frontend build.
|
||||||
+14
-13
@@ -1,17 +1,19 @@
|
|||||||
FROM --platform=$BUILDPLATFORM node:16.15.1-alpine AS frontend-deps
|
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-deps
|
||||||
|
|
||||||
ARG SKIP_FRONTEND_TEST
|
ARG SKIP_FRONTEND_TEST
|
||||||
ARG SKIP_FRONTEND_BUILD
|
ARG SKIP_FRONTEND_BUILD
|
||||||
|
# the manifest's prepare script installs husky hooks, which needs a git repository the build
|
||||||
|
# context does not have. husky itself skips on CI, and this is the same flag the build stage sets
|
||||||
|
ENV CI=true
|
||||||
|
|
||||||
WORKDIR /srv/frontend/
|
WORKDIR /srv/frontend/apps/remark42/
|
||||||
|
|
||||||
COPY ./frontend/package.json ./frontend/pnpm-lock.yaml ./frontend/pnpm-workspace.yaml /srv/frontend/
|
COPY ./frontend/apps/remark42/package.json ./frontend/apps/remark42/pnpm-lock.yaml /srv/frontend/apps/remark42/
|
||||||
COPY ./frontend/apps/remark42/package.json /srv/frontend/apps/remark42/
|
|
||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
if [[ -z "$SKIP_FRONTEND_BUILD" || -z "$SKIP_FRONTEND_TEST" ]]; then \
|
if [[ -z "$SKIP_FRONTEND_BUILD" || -z "$SKIP_FRONTEND_TEST" ]]; then \
|
||||||
apk add --no-cache --update git && \
|
apk add --no-cache --update git && \
|
||||||
npm i -g pnpm; \
|
npm i -g pnpm@10.10.0; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
RUN --mount=type=cache,id=pnpm,target=/root/.pnpm-store/v3 \
|
RUN --mount=type=cache,id=pnpm,target=/root/.pnpm-store/v3 \
|
||||||
@@ -45,7 +47,7 @@ RUN \
|
|||||||
echo 'Skip frontend build'; \
|
echo 'Skip frontend build'; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
FROM umputun/baseimage:buildgo-v1.9.2 as build-backend
|
FROM umputun/baseimage:buildgo-v1.17.0 AS build-backend
|
||||||
|
|
||||||
ARG CI
|
ARG CI
|
||||||
ARG GITHUB_REF
|
ARG GITHUB_REF
|
||||||
@@ -54,15 +56,14 @@ ARG GIT_BRANCH
|
|||||||
ARG SKIP_BACKEND_TEST
|
ARG SKIP_BACKEND_TEST
|
||||||
ARG BACKEND_TEST_TIMEOUT
|
ARG BACKEND_TEST_TIMEOUT
|
||||||
|
|
||||||
|
# install gcc in order to be able to go test package with -race
|
||||||
|
RUN apk --no-cache add gcc libc-dev
|
||||||
|
|
||||||
ADD backend /build/backend
|
ADD backend /build/backend
|
||||||
# to embed the frontend files statically into Remark42 binary
|
# to embed the frontend files statically into Remark42 binary
|
||||||
COPY --from=build-frontend /srv/frontend/apps/remark42/public/ /build/backend/app/cmd/web/
|
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
|
WORKDIR /build/backend
|
||||||
|
|
||||||
# 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 echo go version: `go version`
|
||||||
|
|
||||||
# run tests
|
# run tests
|
||||||
@@ -70,7 +71,7 @@ RUN \
|
|||||||
cd app && \
|
cd app && \
|
||||||
if [ -z "$SKIP_BACKEND_TEST" ] ; then \
|
if [ -z "$SKIP_BACKEND_TEST" ] ; then \
|
||||||
CGO_ENABLED=1 go test -race -p 1 -timeout="${BACKEND_TEST_TIMEOUT:-300s}" -covermode=atomic -coverprofile=/profile.cov_tmp ./... && \
|
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 ; \
|
cat /profile.cov_tmp | grep -v "_mock.go" > /profile.cov && \
|
||||||
golangci-lint run --config ../.golangci.yml ./... ; \
|
golangci-lint run --config ../.golangci.yml ./... ; \
|
||||||
else \
|
else \
|
||||||
echo "skip backend tests and linter" \
|
echo "skip backend tests and linter" \
|
||||||
@@ -81,7 +82,7 @@ RUN \
|
|||||||
echo "version=$version" && \
|
echo "version=$version" && \
|
||||||
go build -o remark42 -ldflags "-X main.revision=${version} -s -w" ./app
|
go build -o remark42 -ldflags "-X main.revision=${version} -s -w" ./app
|
||||||
|
|
||||||
FROM umputun/baseimage:app-v1.9.2
|
FROM umputun/baseimage:app-v1.17.0
|
||||||
|
|
||||||
ARG GITHUB_SHA
|
ARG GITHUB_SHA
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ LABEL org.opencontainers.image.authors="Umputun <umputun@gmail.com>" \
|
|||||||
org.opencontainers.image.description="Remark42 comment engine" \
|
org.opencontainers.image.description="Remark42 comment engine" \
|
||||||
org.opencontainers.image.documentation="https://remark42.com/docs/getting-started/" \
|
org.opencontainers.image.documentation="https://remark42.com/docs/getting-started/" \
|
||||||
org.opencontainers.image.licenses="MIT" \
|
org.opencontainers.image.licenses="MIT" \
|
||||||
org.opencontainers.image.source="https://github.com/umputun/remark42.git" \
|
org.opencontainers.image.source="https://github.com/umputun/remark42" \
|
||||||
org.opencontainers.image.title="Remark42" \
|
org.opencontainers.image.title="Remark42" \
|
||||||
org.opencontainers.image.url="https://remark42.com/" \
|
org.opencontainers.image.url="https://remark42.com/" \
|
||||||
org.opencontainers.image.revision="${GITHUB_SHA}"
|
org.opencontainers.image.revision="${GITHUB_SHA}"
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
FROM node:16.15.1-alpine AS frontend-deps
|
|
||||||
|
|
||||||
ENV CI=true
|
|
||||||
|
|
||||||
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/package.json
|
|
||||||
|
|
||||||
RUN apk add --no-cache --update git && npm i -g pnpm
|
|
||||||
RUN --mount=type=cache,id=pnpm,target=/root/.pnpm-store/v3 pnpm i
|
|
||||||
|
|
||||||
FROM frontend-deps AS build-frontend
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV CI=true
|
|
||||||
|
|
||||||
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
|
|
||||||
ARG GITHUB_REF
|
|
||||||
ARG GITHUB_SHA
|
|
||||||
|
|
||||||
WORKDIR /build/backend
|
|
||||||
ADD backend /build/backend
|
|
||||||
ADD README.md /build/
|
|
||||||
ADD LICENSE /build/
|
|
||||||
|
|
||||||
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" {} \;
|
|
||||||
|
|
||||||
RUN \
|
|
||||||
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 \
|
|
||||||
apk add --no-cache --update zip && \
|
|
||||||
cp ../LICENSE ./LICENSE && cp ../README.md ./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
|
|
||||||
|
|
||||||
|
|
||||||
FROM alpine
|
|
||||||
COPY --from=build-backend /build/backend/remark42.* /artifacts/
|
|
||||||
RUN ls -la /artifacts/*
|
|
||||||
CMD ["sleep", "100"]
|
|
||||||
@@ -2,51 +2,58 @@ OS=linux
|
|||||||
ARCH=amd64
|
ARCH=amd64
|
||||||
GITHUB_REF=$(shell git rev-parse --symbolic-full-name HEAD)
|
GITHUB_REF=$(shell git rev-parse --symbolic-full-name HEAD)
|
||||||
GITHUB_SHA=$(shell git rev-parse --short HEAD)
|
GITHUB_SHA=$(shell git rev-parse --short HEAD)
|
||||||
|
CLEANUP_RELEASE_ASSETS=$(CURDIR)/scripts/cleanup-release-assets.sh
|
||||||
|
|
||||||
bin:
|
bin:
|
||||||
docker build -f Dockerfile.artifacts -t remark42.bin .
|
@set -e; \
|
||||||
- @docker rm -f remark42.bin 2>/dev/null || exit 0
|
./scripts/prepare-release-assets.sh; \
|
||||||
docker run -d --name=remark42.bin remark42.bin
|
trap '$(CLEANUP_RELEASE_ASSETS)' EXIT; \
|
||||||
docker cp remark42.bin:/artifacts/remark42.$(OS)-$(ARCH) remark42
|
cd backend && CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -o ../remark42 -ldflags "-X main.revision=$(GITHUB_REF)-$(GITHUB_SHA) -s -w" ./app
|
||||||
docker rm -f remark42.bin
|
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
DOCKER_BUILDKIT=1 docker build -t umputun/remark42 --build-arg GITHUB_REF=$(GITHUB_REF) --build-arg GITHUB_SHA=$(GITHUB_SHA) \
|
DOCKER_BUILDKIT=1 docker build -t umputun/remark42 -t ghcr.io/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 .
|
--build-arg CI=true --build-arg SKIP_FRONTEND_TEST=true --build-arg SKIP_BACKEND_TEST=true .
|
||||||
|
|
||||||
dockerx:
|
dockerx:
|
||||||
docker buildx build --build-arg GITHUB_REF=$(GITHUB_REF) --build-arg GITHUB_SHA=$(GITHUB_SHA) --build-arg CI=true \
|
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 \
|
--build-arg SKIP_FRONTEND_TEST=true --build-arg SKIP_BACKEND_TEST=true \
|
||||||
--progress=plain --platform linux/amd64,linux/arm/v7,linux/arm64 \
|
--progress=plain --platform linux/amd64,linux/arm64 \
|
||||||
-t ghcr.io/umputun/remark42:master -t umputun/remark42:master .
|
-t ghcr.io/umputun/remark42:master -t umputun/remark42:master .
|
||||||
|
|
||||||
release:
|
release:
|
||||||
docker build -f Dockerfile.artifacts --no-cache --pull --build-arg CI=true \
|
@set -e; \
|
||||||
--build-arg GITHUB_REF=$(GITHUB_REF) --build-arg GITHUB_SHA=$(GITHUB_SHA) -t remark42.bin .
|
trap '$(CLEANUP_RELEASE_ASSETS)' EXIT; \
|
||||||
- @docker rm -f remark42.bin 2>/dev/null || exit 0
|
goreleaser release --snapshot --clean --skip=publish
|
||||||
- @mkdir -p bin
|
|
||||||
docker run -d --name=remark42.bin remark42.bin
|
|
||||||
docker cp remark42.bin:/artifacts/remark42.linux-amd64.tar.gz bin/remark42.linux-amd64.tar.gz
|
|
||||||
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:
|
race_test:
|
||||||
cd backend/app && go test -race -timeout=60s -count 1 ./...
|
cd backend/app && go test -race -timeout=300s -count 1 ./...
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
docker-compose -f compose-dev-backend.yml build
|
docker compose -f compose-dev-backend.yml build
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
docker-compose -f compose-dev-frontend.yml build
|
docker compose -f compose-dev-frontend.yml build
|
||||||
|
|
||||||
rundev:
|
rundev:
|
||||||
SKIP_BACKEND_TEST=true SKIP_FRONTEND_TEST=true GITHUB_REF=$(GITHUB_REF) GITHUB_SHA=$(GITHUB_SHA) CI=true \
|
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 build
|
||||||
docker-compose -f compose-private.yml up
|
docker compose -f compose-private.yml up
|
||||||
|
|
||||||
.PHONY: bin backend
|
# stamped the same way the suite stamps a stack it starts itself, so one brought up here is
|
||||||
|
# accepted instead of rejected as belonging to another checkout
|
||||||
|
e2e-up:
|
||||||
|
./e2e/tls/generate.sh
|
||||||
|
E2E_STAMP=$$(./e2e/stamp.sh) docker compose -f compose-e2e-test.yml up -d --build --quiet-pull --wait
|
||||||
|
|
||||||
|
e2e-down:
|
||||||
|
docker compose -f compose-e2e-test.yml down -v
|
||||||
|
|
||||||
|
# the suite brings the stack up itself when it finds none, so e2e-up is only worth running
|
||||||
|
# to keep the containers between invocations
|
||||||
|
e2e:
|
||||||
|
cd e2e && go test -tags=e2e -count 1 -timeout 20m ./...
|
||||||
|
|
||||||
|
e2e-ui:
|
||||||
|
cd e2e && E2E_HEADLESS=false E2E_KEEP=1 go test -tags=e2e -count 1 -v -timeout 20m ./...
|
||||||
|
|
||||||
|
.PHONY: bin docker dockerx release race_test backend frontend rundev e2e e2e-up e2e-down e2e-ui
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
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, Microsoft, GitHub, Yandex, Patreon and Telegram
|
* Social login via Google, Facebook, Microsoft, GitHub, Apple, Yandex, Patreon, Discord, Telegram and custom OAuth2 providers
|
||||||
* Login via email
|
* Login via email
|
||||||
* Optional anonymous access
|
* Optional anonymous access
|
||||||
* Multi-level nested comments with both tree and plain presentations
|
* Multi-level nested comments with both tree and plain presentations
|
||||||
|
|||||||
+1
-1
@@ -12,4 +12,4 @@ We release patches for security vulnerabilities.
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## 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.
|
Please report (suspected) security vulnerabilities either by using GitHub's [private vulnerability reporting](https://github.com/umputun/remark42/security/advisories/new) (click the "Report a vulnerability" button on the [Security tab](https://github.com/umputun/remark42/security)) or by emailing umputun@gmail.com. You will receive a response 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.
|
||||||
|
|||||||
+63
-61
@@ -1,68 +1,70 @@
|
|||||||
run:
|
version: "2"
|
||||||
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:
|
linters:
|
||||||
|
default: none
|
||||||
enable:
|
enable:
|
||||||
- bodyclose
|
- bodyclose
|
||||||
- megacheck
|
- copyloopvar
|
||||||
- revive
|
|
||||||
- govet
|
|
||||||
- unconvert
|
|
||||||
- gas
|
|
||||||
- gocyclo
|
|
||||||
- dupl
|
- dupl
|
||||||
|
- gochecknoinits
|
||||||
|
- gocritic
|
||||||
|
- gocyclo
|
||||||
|
- gosec
|
||||||
|
- govet
|
||||||
|
- ineffassign
|
||||||
- misspell
|
- misspell
|
||||||
|
- nakedret
|
||||||
|
- prealloc
|
||||||
|
- revive
|
||||||
|
- staticcheck
|
||||||
|
- unconvert
|
||||||
- unparam
|
- unparam
|
||||||
- unused
|
- unused
|
||||||
- typecheck
|
settings:
|
||||||
- ineffassign
|
gosec:
|
||||||
- stylecheck
|
excludes:
|
||||||
- gochecknoinits
|
- G117 # false positive: struct field name matches "secret" pattern
|
||||||
- exportloopref
|
gocritic:
|
||||||
- gocritic
|
disabled-checks:
|
||||||
- nakedret
|
- wrapperFunc
|
||||||
- gosimple
|
- hugeParam
|
||||||
- prealloc
|
- rangeValCopy
|
||||||
fast: false
|
enabled-tags:
|
||||||
disable-all: true
|
- performance
|
||||||
|
- style
|
||||||
issues:
|
- experimental
|
||||||
exclude-rules:
|
govet:
|
||||||
- text: "at least one file in a package should have a package comment"
|
enable:
|
||||||
linters:
|
- shadow
|
||||||
- stylecheck
|
misspell:
|
||||||
- text: "package-comments: should have a package comment"
|
locale: US
|
||||||
linters:
|
exclusions:
|
||||||
- revive
|
generated: lax
|
||||||
- path: _test\.go
|
rules:
|
||||||
linters:
|
- linters:
|
||||||
- gosec
|
- staticcheck
|
||||||
- dupl
|
text: at least one file in a package should have a package comment
|
||||||
exclude-use-default: false
|
- linters:
|
||||||
|
- revive
|
||||||
|
text: 'package-comments: should have a package comment'
|
||||||
|
- linters:
|
||||||
|
- revive
|
||||||
|
text: 'var-naming: avoid meaningless package names'
|
||||||
|
- linters:
|
||||||
|
- revive
|
||||||
|
text: 'var-naming: avoid package names that conflict with Go standard library package names'
|
||||||
|
- linters:
|
||||||
|
- dupl
|
||||||
|
- gosec
|
||||||
|
path: _test\.go
|
||||||
|
paths:
|
||||||
|
- vendor
|
||||||
|
- third_party$
|
||||||
|
- builtin$
|
||||||
|
- examples$
|
||||||
|
formatters:
|
||||||
|
exclusions:
|
||||||
|
generated: lax
|
||||||
|
paths:
|
||||||
|
- third_party$
|
||||||
|
- builtin$
|
||||||
|
- examples$
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
../site/src/docs/contributing/backend/index.md
|
../site/content/docs/contributing/backend/index.md
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
FROM umputun/baseimage:buildgo-v1.9.2 as build-backend
|
FROM umputun/baseimage:buildgo-v1.17.0 AS build-backend
|
||||||
|
|
||||||
ADD backend /build/backend
|
ADD backend /build/backend
|
||||||
WORKDIR /build/backend/_example/memory_store
|
WORKDIR /build/backend/_example/memory_store
|
||||||
|
|
||||||
RUN go build -o /build/bin/memory_store -ldflags "-X main.revision=0.0.0 -s -w"
|
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-v1.17.0
|
||||||
|
|
||||||
ARG GITHUB_SHA
|
ARG GITHUB_SHA
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ LABEL org.opencontainers.image.authors="Umputun <umputun@gmail.com>" \
|
|||||||
org.opencontainers.image.description="Remark42 comment engine example JRPC memory store" \
|
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.documentation="https://github.com/umputun/remark42/tree/master/backend/_example/memory_store" \
|
||||||
org.opencontainers.image.licenses="MIT" \
|
org.opencontainers.image.licenses="MIT" \
|
||||||
org.opencontainers.image.source="https://github.com/umputun/remark42.git" \
|
org.opencontainers.image.source="https://github.com/umputun/remark42" \
|
||||||
org.opencontainers.image.title="Remark42 JRPC example memory store" \
|
org.opencontainers.image.title="Remark42 JRPC example memory store" \
|
||||||
org.opencontainers.image.url="https://remark42.com/" \
|
org.opencontainers.image.url="https://remark42.com/" \
|
||||||
org.opencontainers.image.revision="${GITHUB_SHA}"
|
org.opencontainers.image.revision="${GITHUB_SHA}"
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
In order to run remark42 with memory_store copy provided `compose-dev-memstore.yml` to the root directory and run:
|
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 build`
|
||||||
1. `docker-compose -f compose-dev-memstore.yml up`
|
1. `docker compose -f compose-dev-memstore.yml up`
|
||||||
|
|
||||||
As usual, demo site will run on http://127.0.0.1:8080/web/
|
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/remark42/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.
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ func NewMemAdminStore(key string) *MemAdmin {
|
|||||||
return &MemAdmin{data: map[string]AdminRec{}, key: key}
|
return &MemAdmin{data: map[string]AdminRec{}, key: key}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Key executes find by siteID and returns substructure with secret key
|
// 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) {
|
func (m *MemAdmin) Key(_ string) (key string, err error) {
|
||||||
return m.key, nil
|
return m.key, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,11 +251,11 @@ func (m *MemData) Flag(req engine.FlagRequest) (val bool, err error) {
|
|||||||
|
|
||||||
// ListFlags get list of flagged keys, like blocked & verified user
|
// ListFlags get list of flagged keys, like blocked & verified user
|
||||||
// works for full locator (post flags) or with userID
|
// works for full locator (post flags) or with userID
|
||||||
func (m *MemData) ListFlags(req engine.FlagRequest) (res []interface{}, err error) {
|
func (m *MemData) ListFlags(req engine.FlagRequest) (res []any, err error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
res = []interface{}{}
|
res = []any{}
|
||||||
|
|
||||||
switch req.Flag {
|
switch req.Flag {
|
||||||
case engine.Verified:
|
case engine.Verified:
|
||||||
@@ -293,17 +293,17 @@ func (m *MemData) UserDetail(req engine.UserDetailRequest) ([]engine.UserDetailE
|
|||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
if req.Update == "" { // read detail value, no update requested
|
if req.Update == "" { // read detail value, no update requested
|
||||||
return m.getUserDetail(req)
|
return m.getUserDetail(req), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.setUserDetail(req)
|
return m.setUserDetail(req), nil
|
||||||
case engine.AllUserDetails:
|
case engine.AllUserDetails:
|
||||||
// list of all details returned in case request is a read request
|
// list of all details returned in case request is a read request
|
||||||
// (Update is not set) and does not have UserID or Detail set
|
// (Update is not set) and does not have UserID or Detail set
|
||||||
if req.Update == "" && req.UserID == "" { // read list of all details
|
if req.Update == "" && req.UserID == "" { // read list of all details
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
return m.listDetails(req.Locator)
|
return m.listDetails(req.Locator), nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("unsupported request with userdetail all")
|
return nil, fmt.Errorf("unsupported request with userdetail all")
|
||||||
default:
|
default:
|
||||||
@@ -319,7 +319,8 @@ func (m *MemData) Delete(req engine.DeleteRequest) error {
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case req.UserDetail != "": // delete user detail
|
case req.UserDetail != "": // delete user detail
|
||||||
return m.deleteUserDetail(req.Locator, req.UserID, req.UserDetail)
|
m.deleteUserDetail(req.Locator, req.UserID, req.UserDetail)
|
||||||
|
return nil
|
||||||
case req.Locator.URL != "" && req.CommentID != "" && req.UserDetail == "": // delete comment
|
case req.Locator.URL != "" && req.CommentID != "" && req.UserDetail == "": // delete comment
|
||||||
return m.deleteComment(req.Locator, req.CommentID, req.DeleteMode)
|
return m.deleteComment(req.Locator, req.CommentID, req.DeleteMode)
|
||||||
|
|
||||||
@@ -332,7 +333,8 @@ func (m *MemData) Delete(req engine.DeleteRequest) error {
|
|||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m.deleteUserDetail(req.Locator, req.UserID, engine.AllUserDetails)
|
m.deleteUserDetail(req.Locator, req.UserID, engine.AllUserDetails)
|
||||||
|
return nil
|
||||||
|
|
||||||
case req.Locator.SiteID != "" && req.Locator.URL == "" && req.CommentID == "" && req.UserID == "" && req.UserDetail == "": // delete site
|
case req.Locator.SiteID != "" && req.Locator.URL == "" && req.CommentID == "" && req.UserID == "" && req.UserDetail == "": // delete site
|
||||||
if _, ok := m.posts[req.Locator.SiteID]; !ok {
|
if _, ok := m.posts[req.Locator.SiteID]; !ok {
|
||||||
@@ -389,10 +391,7 @@ func (m *MemData) checkFlag(req engine.FlagRequest) (val bool) {
|
|||||||
|
|
||||||
func (m *MemData) setFlag(req engine.FlagRequest) (res bool, err error) {
|
func (m *MemData) setFlag(req engine.FlagRequest) (res bool, err error) {
|
||||||
|
|
||||||
status := false
|
status := req.Update == engine.FlagTrue
|
||||||
if req.Update == engine.FlagTrue {
|
|
||||||
status = true
|
|
||||||
}
|
|
||||||
|
|
||||||
switch req.Flag {
|
switch req.Flag {
|
||||||
|
|
||||||
@@ -437,29 +436,29 @@ func (m *MemData) setFlag(req engine.FlagRequest) (res bool, err error) {
|
|||||||
|
|
||||||
// getUserDetail returns UserDetailEntry with requested userDetail (omitting other details)
|
// getUserDetail returns UserDetailEntry with requested userDetail (omitting other details)
|
||||||
// as an only element of the slice.
|
// as an only element of the slice.
|
||||||
func (m *MemData) getUserDetail(req engine.UserDetailRequest) ([]engine.UserDetailEntry, error) {
|
func (m *MemData) getUserDetail(req engine.UserDetailRequest) []engine.UserDetailEntry {
|
||||||
if meta, ok := m.metaUsers[req.UserID]; ok {
|
if meta, ok := m.metaUsers[req.UserID]; ok {
|
||||||
if meta.SiteID != req.Locator.SiteID {
|
if meta.SiteID != req.Locator.SiteID {
|
||||||
return []engine.UserDetailEntry{}, nil
|
return []engine.UserDetailEntry{}
|
||||||
}
|
}
|
||||||
switch req.Detail {
|
switch req.Detail {
|
||||||
case engine.UserEmail:
|
case engine.UserEmail:
|
||||||
return []engine.UserDetailEntry{{UserID: req.UserID, Email: meta.Details.Email}}, nil
|
return []engine.UserDetailEntry{{UserID: req.UserID, Email: meta.Details.Email}}
|
||||||
case engine.UserTelegram:
|
case engine.UserTelegram:
|
||||||
return []engine.UserDetailEntry{{UserID: req.UserID, Telegram: meta.Details.Telegram}}, nil
|
return []engine.UserDetailEntry{{UserID: req.UserID, Telegram: meta.Details.Telegram}}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return []engine.UserDetailEntry{}, nil
|
return []engine.UserDetailEntry{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// setUserDetail sets requested userDetail, returning complete updated UserDetailEntry as an onlyIps
|
// setUserDetail sets requested userDetail, returning complete updated UserDetailEntry as an onlyIps
|
||||||
// element of the slice in case of success
|
// element of the slice in case of success
|
||||||
func (m *MemData) setUserDetail(req engine.UserDetailRequest) ([]engine.UserDetailEntry, error) {
|
func (m *MemData) setUserDetail(req engine.UserDetailRequest) []engine.UserDetailEntry {
|
||||||
var entry metaUser
|
var entry metaUser
|
||||||
if meta, ok := m.metaUsers[req.UserID]; ok {
|
if meta, ok := m.metaUsers[req.UserID]; ok {
|
||||||
if meta.SiteID != req.Locator.SiteID {
|
if meta.SiteID != req.Locator.SiteID {
|
||||||
return []engine.UserDetailEntry{}, nil
|
return []engine.UserDetailEntry{}
|
||||||
}
|
}
|
||||||
entry = meta
|
entry = meta
|
||||||
}
|
}
|
||||||
@@ -476,42 +475,42 @@ func (m *MemData) setUserDetail(req engine.UserDetailRequest) ([]engine.UserDeta
|
|||||||
case engine.UserEmail:
|
case engine.UserEmail:
|
||||||
entry.Details.Email = req.Update
|
entry.Details.Email = req.Update
|
||||||
m.metaUsers[req.UserID] = entry
|
m.metaUsers[req.UserID] = entry
|
||||||
return []engine.UserDetailEntry{{UserID: req.UserID, Email: req.Update}}, nil
|
return []engine.UserDetailEntry{{UserID: req.UserID, Email: req.Update}}
|
||||||
case engine.UserTelegram:
|
case engine.UserTelegram:
|
||||||
entry.Details.Telegram = req.Update
|
entry.Details.Telegram = req.Update
|
||||||
m.metaUsers[req.UserID] = entry
|
m.metaUsers[req.UserID] = entry
|
||||||
return []engine.UserDetailEntry{{UserID: req.UserID, Telegram: req.Update}}, nil
|
return []engine.UserDetailEntry{{UserID: req.UserID, Telegram: req.Update}}
|
||||||
}
|
}
|
||||||
|
|
||||||
return []engine.UserDetailEntry{}, nil
|
return []engine.UserDetailEntry{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// listDetails lists all available users details for given siteID
|
// listDetails lists all available users details for given siteID
|
||||||
func (m *MemData) listDetails(loc store.Locator) ([]engine.UserDetailEntry, error) {
|
func (m *MemData) listDetails(loc store.Locator) []engine.UserDetailEntry {
|
||||||
var res []engine.UserDetailEntry
|
var res []engine.UserDetailEntry
|
||||||
for _, u := range m.metaUsers {
|
for _, u := range m.metaUsers {
|
||||||
if u.SiteID == loc.SiteID {
|
if u.SiteID == loc.SiteID {
|
||||||
res = append(res, u.Details)
|
res = append(res, u.Details)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return res, nil
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteUserDetail deletes requested UserDetail or whole UserDetailEntry,
|
// deleteUserDetail deletes requested UserDetail or whole UserDetailEntry,
|
||||||
// deletion of the absent entry doesn't produce error.
|
// deletion of the absent entry doesn't produce error.
|
||||||
// Trying to delete user with wrong siteID doesn't to anything and doesn't produce error.
|
// Trying to delete user with wrong siteID doesn't to anything and doesn't produce error.
|
||||||
func (m *MemData) deleteUserDetail(locator store.Locator, userID string, userDetail engine.UserDetail) error {
|
func (m *MemData) deleteUserDetail(locator store.Locator, userID string, userDetail engine.UserDetail) {
|
||||||
var entry metaUser
|
var entry metaUser
|
||||||
if meta, ok := m.metaUsers[userID]; ok {
|
if meta, ok := m.metaUsers[userID]; ok {
|
||||||
if meta.SiteID != locator.SiteID {
|
if meta.SiteID != locator.SiteID {
|
||||||
return nil
|
return
|
||||||
}
|
}
|
||||||
entry = meta
|
entry = meta
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry == (metaUser{}) || entry.Details == (engine.UserDetailEntry{}) {
|
if entry == (metaUser{}) || entry.Details == (engine.UserDetailEntry{}) {
|
||||||
// absent entry means that we should not do anything
|
// absent entry means that we should not do anything
|
||||||
return nil
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch userDetail {
|
switch userDetail {
|
||||||
@@ -529,7 +528,6 @@ func (m *MemData) deleteUserDetail(locator store.Locator, userID string, userDet
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.metaUsers[userID] = entry
|
m.metaUsers[userID] = entry
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MemData) get(loc store.Locator, commentID string) (store.Comment, error) {
|
func (m *MemData) get(loc store.Locator, commentID string) (store.Comment, error) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -198,7 +199,7 @@ func TestMemData_FindForUserPagination(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// write 200 comments
|
// write 200 comments
|
||||||
for i := 0; i < 200; i++ {
|
for i := range 200 {
|
||||||
c.ID = fmt.Sprintf("idd-%d", i)
|
c.ID = fmt.Sprintf("idd-%d", i)
|
||||||
c.Text = fmt.Sprintf("text #%d", i)
|
c.Text = fmt.Sprintf("text #%d", i)
|
||||||
c.Timestamp = time.Date(2017, 12, 20, 15, 18, i, 0, time.Local)
|
c.Timestamp = time.Date(2017, 12, 20, 15, 18, i, 0, time.Local)
|
||||||
@@ -286,7 +287,7 @@ func TestMemData_CountUser(t *testing.T) {
|
|||||||
|
|
||||||
func TestMemData_InfoPost(t *testing.T) {
|
func TestMemData_InfoPost(t *testing.T) {
|
||||||
b := prepMem(t)
|
b := prepMem(t)
|
||||||
ts := func(min int) time.Time { return time.Date(2017, 12, 20, 15, 18, min, 0, time.Local).In(time.UTC) }
|
ts := func(minute int) time.Time { return time.Date(2017, 12, 20, 15, 18, minute, 0, time.Local).In(time.UTC) }
|
||||||
|
|
||||||
// add one more for https://radio-t.com/2
|
// add one more for https://radio-t.com/2
|
||||||
comment := store.Comment{
|
comment := store.Comment{
|
||||||
@@ -484,7 +485,7 @@ func TestMemData_FlagVerified(t *testing.T) {
|
|||||||
func TestMemData_FlagListVerified(t *testing.T) {
|
func TestMemData_FlagListVerified(t *testing.T) {
|
||||||
|
|
||||||
b := prepMem(t)
|
b := prepMem(t)
|
||||||
toIDs := func(inp []interface{}) (res []string) {
|
toIDs := func(inp []any) (res []string) {
|
||||||
res = make([]string, len(inp))
|
res = make([]string, len(inp))
|
||||||
for i, v := range inp {
|
for i, v := range inp {
|
||||||
vv, ok := v.(string)
|
vv, ok := v.(string)
|
||||||
@@ -521,51 +522,52 @@ func TestMemData_FlagListVerified(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMemData_FlagListBlocked(t *testing.T) {
|
func TestMemData_FlagListBlocked(t *testing.T) {
|
||||||
|
synctest.Test(t, func(t *testing.T) {
|
||||||
b := prepMem(t)
|
b := prepMem(t)
|
||||||
setBlocked := func(site, user string, status engine.FlagStatus, ttl time.Duration) error {
|
setBlocked := func(site, user string, status engine.FlagStatus, ttl time.Duration) error {
|
||||||
req := engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: site}, UserID: user, Update: status,
|
req := engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: site}, UserID: user, Update: status,
|
||||||
TTL: ttl}
|
TTL: ttl}
|
||||||
_, err := b.Flag(req)
|
_, err := b.Flag(req)
|
||||||
return err
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
toBlocked := func(inp []interface{}) (res []store.BlockedUser) {
|
|
||||||
res = make([]store.BlockedUser, len(inp))
|
|
||||||
for i, v := range inp {
|
|
||||||
vv, ok := v.(store.BlockedUser)
|
|
||||||
require.True(t, ok)
|
|
||||||
res[i] = vv
|
|
||||||
}
|
}
|
||||||
return res
|
|
||||||
}
|
|
||||||
assert.NoError(t, setBlocked("radio-t", "user1", engine.FlagTrue, 0))
|
|
||||||
assert.NoError(t, setBlocked("radio-t", "user2", engine.FlagTrue, 50*time.Millisecond))
|
|
||||||
assert.NoError(t, setBlocked("radio-t", "user3", engine.FlagFalse, 0))
|
|
||||||
|
|
||||||
vv, err := b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}})
|
toBlocked := func(inp []any) (res []store.BlockedUser) {
|
||||||
assert.NoError(t, err)
|
res = make([]store.BlockedUser, len(inp))
|
||||||
|
for i, v := range inp {
|
||||||
|
vv, ok := v.(store.BlockedUser)
|
||||||
|
require.True(t, ok)
|
||||||
|
res[i] = vv
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
assert.NoError(t, setBlocked("radio-t", "user1", engine.FlagTrue, 0))
|
||||||
|
assert.NoError(t, setBlocked("radio-t", "user2", engine.FlagTrue, 50*time.Millisecond))
|
||||||
|
assert.NoError(t, setBlocked("radio-t", "user3", engine.FlagFalse, 0))
|
||||||
|
|
||||||
blockedList := toBlocked(vv)
|
vv, err := b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}})
|
||||||
var blockedIds = make([]string, len(blockedList))
|
assert.NoError(t, err)
|
||||||
for i, x := range blockedList {
|
|
||||||
blockedIds[i] = x.ID
|
|
||||||
}
|
|
||||||
require.Equal(t, 2, len(blockedList), b.metaUsers)
|
|
||||||
assert.ElementsMatch(t, []string{"user1", "user2"}, blockedIds)
|
|
||||||
t.Logf("%+v", blockedList)
|
|
||||||
|
|
||||||
// check block expiration
|
blockedList := toBlocked(vv)
|
||||||
time.Sleep(50 * time.Millisecond)
|
var blockedIDs = make([]string, len(blockedList))
|
||||||
vv, err = b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}})
|
for i, x := range blockedList {
|
||||||
assert.NoError(t, err)
|
blockedIDs[i] = x.ID
|
||||||
blockedList = toBlocked(vv)
|
}
|
||||||
require.Equal(t, 1, len(blockedList))
|
require.Equal(t, 2, len(blockedList), b.metaUsers)
|
||||||
assert.Equal(t, "user1", blockedList[0].ID)
|
assert.ElementsMatch(t, []string{"user1", "user2"}, blockedIDs)
|
||||||
|
t.Logf("%+v", blockedList)
|
||||||
|
|
||||||
vv, err = b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "bad"}})
|
// check block expiration
|
||||||
assert.NoError(t, err)
|
time.Sleep(50 * time.Millisecond)
|
||||||
assert.Equal(t, 0, len(vv))
|
vv, err = b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "radio-t"}})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
blockedList = toBlocked(vv)
|
||||||
|
require.Equal(t, 1, len(blockedList))
|
||||||
|
assert.Equal(t, "user1", blockedList[0].ID)
|
||||||
|
|
||||||
|
vv, err = b.ListFlags(engine.FlagRequest{Flag: engine.Blocked, Locator: store.Locator{SiteID: "bad"}})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, len(vv))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMemData_DeleteComment(t *testing.T) {
|
func TestMemData_DeleteComment(t *testing.T) {
|
||||||
@@ -624,6 +626,7 @@ func TestMemData_DeleteComment(t *testing.T) {
|
|||||||
func TestMemData_Close(t *testing.T) {
|
func TestMemData_Close(t *testing.T) {
|
||||||
b := prepMem(t)
|
b := prepMem(t)
|
||||||
assert.NoError(t, b.Close())
|
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) {
|
func TestMemData_DeleteHard(t *testing.T) {
|
||||||
|
|||||||
@@ -70,6 +70,17 @@ func (m *MemImage) Load(id string) ([]byte, error) {
|
|||||||
return img, nil
|
return img, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete image by ID
|
||||||
|
func (m *MemImage) Delete(id string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
// delete key from permanent and staging storage
|
||||||
|
delete(m.images, id)
|
||||||
|
delete(m.insertTime, id)
|
||||||
|
delete(m.imagesStaging, id)
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Commit moves image from staging to permanent
|
// Commit moves image from staging to permanent
|
||||||
func (m *MemImage) Commit(id string) error {
|
func (m *MemImage) Commit(id string) error {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// gopher png for test, from https://golang.org/src/image/png/example_test.go
|
// gopher png for test, from https://golang.org/src/image/png/example_test.go
|
||||||
const gopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2" +
|
const rawGopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwUVRzHf2" +
|
||||||
"+OPbo9d7tsWyiyaZti6eWGAhISoIGKECEKCAiJJkYTiUgTMYSIosYYBBIUIxoSPIINEBDi2VhwkQrVsj1ESgu9doHWdrul7ba" +
|
"+OPbo9d7tsWyiyaZti6eWGAhISoIGKECEKCAiJJkYTiUgTMYSIosYYBBIUIxoSPIINEBDi2VhwkQrVsj1ESgu9doHWdrul7ba" +
|
||||||
"73WNm3vOPtsseM9MdwvvrzTs+8/t95ze/33sI5BqiabU6m9En8oNjduLnAEDLUsQXFF8tQ5oxK3vmnNmDSMtrncks9Hhtt" +
|
"73WNm3vOPtsseM9MdwvvrzTs+8/t95ze/33sI5BqiabU6m9En8oNjduLnAEDLUsQXFF8tQ5oxK3vmnNmDSMtrncks9Hhtt" +
|
||||||
"/qeWZapHb1ha3UqYSWVl2ZmpWgaXMXGohQAvmeop3bjTRtv6SgaK/Pb9/bFzUrYslbFAmHPp+3WhAYdr+7GN/YnpN46Opv55VDs" +
|
"/qeWZapHb1ha3UqYSWVl2ZmpWgaXMXGohQAvmeop3bjTRtv6SgaK/Pb9/bFzUrYslbFAmHPp+3WhAYdr+7GN/YnpN46Opv55VDs" +
|
||||||
@@ -38,7 +38,9 @@ const gopher = "iVBORw0KGgoAAAANSUhEUgAAAEsAAAA8CAAAAAALAhhPAAAFfUlEQVRYw62XeWwU
|
|||||||
"1y98c3D27eppUjsZ6fql3jcd5rUe7+ZIlLNQny3Rd+E5Tct3WVhTM5RBCEdiEK0b6B+/ca2gYU393nFj/n1AygRQxPIUA043M42u85+z2S" +
|
"1y98c3D27eppUjsZ6fql3jcd5rUe7+ZIlLNQny3Rd+E5Tct3WVhTM5RBCEdiEK0b6B+/ca2gYU393nFj/n1AygRQxPIUA043M42u85+z2S" +
|
||||||
"nssKrPl8Mx76NL3E6eXc3be7OD+H4WHbJkKI8AU8irbITQjZ+0hQcPEgId/Fn/pl9crKH02+5o2b9T/eMx7pKoskYgAAAABJRU5ErkJggg=="
|
"nssKrPl8Mx76NL3E6eXc3be7OD+H4WHbJkKI8AU8irbITQjZ+0hQcPEgId/Fn/pl9crKH02+5o2b9T/eMx7pKoskYgAAAABJRU5ErkJggg=="
|
||||||
|
|
||||||
func gopherPNG() io.Reader { return base64.NewDecoder(base64.StdEncoding, strings.NewReader(gopher)) }
|
func gopherPNG() io.Reader {
|
||||||
|
return base64.NewDecoder(base64.StdEncoding, strings.NewReader(rawGopher))
|
||||||
|
}
|
||||||
|
|
||||||
func TestMemImage_LoadAfterSave(t *testing.T) {
|
func TestMemImage_LoadAfterSave(t *testing.T) {
|
||||||
svc := NewMemImageStore()
|
svc := NewMemImageStore()
|
||||||
@@ -57,7 +59,8 @@ func TestMemImage_LoadAfterSave(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, gopher, img)
|
assert.Equal(t, gopher, img)
|
||||||
|
|
||||||
svc.ResetCleanupTimer(id)
|
err = svc.ResetCleanupTimer(id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
err = svc.Commit(id)
|
err = svc.Commit(id)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -70,6 +73,26 @@ func TestMemImage_LoadAfterSave(t *testing.T) {
|
|||||||
assert.Equal(t, gopher, img)
|
assert.Equal(t, gopher, img)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMemImage_LoadAfterDelete(t *testing.T) {
|
||||||
|
svc := NewMemImageStore()
|
||||||
|
gopher, err := io.ReadAll(gopherPNG())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
id := "test_img"
|
||||||
|
err = svc.Save(id, gopher)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
err = svc.Delete(id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
img, err := svc.Load(id)
|
||||||
|
assert.EqualError(t, err, "image test_img not found")
|
||||||
|
assert.Empty(t, img)
|
||||||
|
|
||||||
|
err = svc.ResetCleanupTimer(id)
|
||||||
|
assert.EqualError(t, err, "image test_img not found")
|
||||||
|
}
|
||||||
|
|
||||||
func TestMemImage_CommitFail(t *testing.T) {
|
func TestMemImage_CommitFail(t *testing.T) {
|
||||||
svc := NewMemImageStore()
|
svc := NewMemImageStore()
|
||||||
err := svc.Commit("test_id")
|
err := svc.Commit("test_id")
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ services:
|
|||||||
args:
|
args:
|
||||||
- SKIP_BACKEND_TEST=true
|
- SKIP_BACKEND_TEST=true
|
||||||
- SKIP_FRONTEND_TEST=true
|
- SKIP_FRONTEND_TEST=true
|
||||||
image: umputun/remark42:dev
|
image: ghcr.io/umputun/remark42:dev
|
||||||
container_name: "remark42-dev"
|
container_name: "remark42-dev"
|
||||||
hostname: "remark42-dev"
|
hostname: "remark42-dev"
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -1,42 +1,34 @@
|
|||||||
module github.com/umputun/remark42/memory_store
|
module github.com/umputun/remark42/memory_store
|
||||||
|
|
||||||
go 1.17
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-pkgz/jrpc v0.3.0
|
github.com/go-pkgz/jrpc v0.4.2
|
||||||
github.com/go-pkgz/lgr v0.10.4
|
github.com/go-pkgz/lgr v0.12.4
|
||||||
github.com/jessevdk/go-flags v1.5.0
|
github.com/jessevdk/go-flags v1.6.1
|
||||||
github.com/stretchr/testify v1.8.0
|
github.com/stretchr/testify v1.12.1
|
||||||
github.com/umputun/remark42/backend v1.10.1
|
github.com/umputun/remark42/backend v1.1000.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Depado/bfchroma/v2 v2.0.0 // indirect
|
github.com/Depado/bfchroma/v2 v2.0.0 // indirect
|
||||||
github.com/PuerkitoBio/goquery v1.8.0 // indirect
|
github.com/PuerkitoBio/goquery v1.12.0 // indirect
|
||||||
github.com/ajg/form v1.5.1 // indirect
|
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
|
||||||
github.com/alecthomas/chroma/v2 v2.3.0 // indirect
|
github.com/andybalholm/cascadia v1.3.4 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/dlclark/regexp2/v2 v2.7.1 // indirect
|
||||||
github.com/didip/tollbooth/v7 v7.0.1 // indirect
|
github.com/go-pkgz/rest v1.24.0 // indirect
|
||||||
github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f // indirect
|
github.com/go-pkgz/routegroup v1.6.1 // indirect
|
||||||
github.com/dlclark/regexp2 v1.4.0 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/go-chi/chi/v5 v5.0.7 // indirect
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
github.com/go-chi/render v1.0.2 // indirect
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
github.com/go-pkgz/expirable-cache v0.1.0 // indirect
|
|
||||||
github.com/go-pkgz/rest v1.16.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.20 // indirect
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
|
||||||
github.com/rs/xid v1.4.0 // indirect
|
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
go.etcd.io/bbolt v1.3.6 // indirect
|
go.etcd.io/bbolt v1.5.0 // indirect
|
||||||
golang.org/x/image v0.0.0-20220617043117-41969df76e82 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b // indirect
|
golang.org/x/crypto v0.55.0 // indirect
|
||||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect
|
golang.org/x/image v0.45.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
golang.org/x/net v0.58.0 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/umputun/remark42/backend => ../../
|
replace github.com/umputun/remark42/backend v1.1000.0 => ../../
|
||||||
|
|||||||
@@ -1,90 +1,52 @@
|
|||||||
github.com/Depado/bfchroma/v2 v2.0.0 h1:IRpN9BPkNwEpR6w1ectIcNWOuhDSLx+8f1pn83fzxx8=
|
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/Depado/bfchroma/v2 v2.0.0/go.mod h1:wFwW/Pw8Tnd0irzgO9Zxtxgzp3aPS8qBWlyadxujxmw=
|
||||||
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
|
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||||
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
github.com/alecthomas/chroma/v2 v2.3.0 h1:83xfxrnjv8eK+Cf8qZDzNo3PPF9IbTWHs7z28GY6D0U=
|
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||||
github.com/alecthomas/chroma/v2 v2.3.0/go.mod h1:mZxeWZlxP2Dy+/8cBob2PYd8O2DwNAzave5AY7A2eQw=
|
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||||
github.com/alecthomas/repr v0.1.0 h1:ENn2e1+J3k09gyj2shc0dHr/yjaWSHRlrJ4DPMevDqE=
|
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
|
||||||
|
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
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/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/dlclark/regexp2/v2 v2.7.1 h1:yqDtwI1ptXXvEUNpYTk2lad4jLtAcKqkzepn4savSk4=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/dlclark/regexp2/v2 v2.7.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/go-pkgz/jrpc v0.4.2 h1:gY5mmxp9/dFd1WsHybVZILQpF11YNWWS3Ga+Pc5aIAU=
|
||||||
github.com/didip/tollbooth/v7 v7.0.0/go.mod h1:VZhDSGl5bDSPj4wPsih3PFa4Uh9Ghv8hgacaTm5PRT4=
|
github.com/go-pkgz/jrpc v0.4.2/go.mod h1:ZtnMpIXYmwXh6W44XO2lE5Lh5J+6KeeMIvw+vF9xXRQ=
|
||||||
github.com/didip/tollbooth/v7 v7.0.1 h1:TkT4sBKoQoHQFPf7blQ54iHrZiTDnr8TceU+MulVAog=
|
github.com/go-pkgz/lgr v0.12.4 h1:lDeQ4BR28ldXrKau6BOjq7A8nHzcXz+MF4xUfV4l1Ok=
|
||||||
github.com/didip/tollbooth/v7 v7.0.1/go.mod h1:VZhDSGl5bDSPj4wPsih3PFa4Uh9Ghv8hgacaTm5PRT4=
|
github.com/go-pkgz/lgr v0.12.4/go.mod h1:Lw6DkNRnCPyX07mqkiUK/p+eA1opq4GKkWfWia64RA8=
|
||||||
github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f h1:jtKwihcLmUC9BAhoJ9adCUqdSSZcOdH2KL7mPTUm2aw=
|
github.com/go-pkgz/rest v1.24.0 h1:GAUCgx7U8xCOC2OynLjhCRMhtnMQH4d1mTdKpQyX2yI=
|
||||||
github.com/didip/tollbooth_chi v0.0.0-20220719025231-d662a7f6928f/go.mod h1:q9C80dnsuVRP2dAskjnXRNWdUJqtGgwG9wNrzt0019s=
|
github.com/go-pkgz/rest v1.24.0/go.mod h1:dl3EWiuFB4hRTo2Sknj6UrQGFRAYvANK6/NyW8qQPxc=
|
||||||
github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
|
github.com/go-pkgz/routegroup v1.6.1 h1:6I/0LabazpZsHAI+jYPeyH/KU2cvZF0bFylUScMNi+Q=
|
||||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
github.com/go-pkgz/routegroup v1.6.1/go.mod h1:Pmu04fhgWhRtBMIJ8HXppnnzOPjnL/IEPBIdO2zmeqg=
|
||||||
github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns=
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/go-chi/render v1.0.2 h1:4ER/udB0+fMWB2Jlf15RV3F4A2FDuYi/9f+lFttR/Lg=
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
github.com/go-chi/render v1.0.2/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
|
github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4=
|
||||||
github.com/go-pkgz/expirable-cache v0.1.0 h1:3bw0m8vlTK8qlwz5KXuygNBTkiKRTPrAGXU0Ej2AC1g=
|
github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc=
|
||||||
github.com/go-pkgz/expirable-cache v0.1.0/go.mod h1:GTrEl0X+q0mPNqN6dtcQXksACnzCBQ5k/k1SwXJsZKs=
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/go-pkgz/jrpc v0.3.0 h1:Fls38KqPsHzvp0FWfivr6cGnncC+iFBodHBqvUPY+0U=
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
github.com/go-pkgz/jrpc v0.3.0/go.mod h1:MFtKs75JESiSqVicsQkgN2iDFFuCd3gVT1/vKiwRi00=
|
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
github.com/go-pkgz/lgr v0.10.4 h1:l7qyFjqEZgwRgaQQSEp6tve4A3OU80VrfzpvtEX8ngw=
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/go-pkgz/lgr v0.10.4/go.mod h1:CD0s1z6EFpIUplV067gitF77tn25JItzwHNKAPqeCF0=
|
|
||||||
github.com/go-pkgz/rest v1.15.6/go.mod h1:KUWAqbDteYGS/CiXftomQsKjtEOifXsJ36Ka0skYbmk=
|
|
||||||
github.com/go-pkgz/rest v1.16.0 h1:3An51XJXUq1XtICHvfAEp048ZCbUHBEga3g0WEu3zK0=
|
|
||||||
github.com/go-pkgz/rest v1.16.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/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/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/microcosm-cc/bluemonday v1.0.20 h1:flpzsq4KU3QIYAYGV/szUat7H+GPOXR0B2JU5A1Wp8Y=
|
|
||||||
github.com/microcosm-cc/bluemonday v1.0.20/go.mod h1:yfBmMi8mxvaZut3Yytv+jTXRY8mxyjJ0/kQBTElld50=
|
|
||||||
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/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY=
|
|
||||||
github.com/rs/xid v1.4.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 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
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/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
|
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||||
golang.org/x/image v0.0.0-20220617043117-41969df76e82 h1:KpZB5pUSBvrHltNEdK/tw0xlPeD13M6M6aGP32gKqiw=
|
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||||
golang.org/x/image v0.0.0-20220617043117-41969df76e82/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
|
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||||
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b h1:ZmngSVLe/wycRns9MKikG9OWIEjGcGAkacif7oYQaUY=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
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-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg=
|
|
||||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
|
||||||
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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
|
||||||
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/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=
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func (s *RPC) admEnabledHndl(id uint64, params json.RawMessage) (rr jrpc.Respons
|
|||||||
// onEvent returns nothing, callback to OnEvent
|
// onEvent returns nothing, callback to OnEvent
|
||||||
func (s *RPC) admEventHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
func (s *RPC) admEventHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||||
var siteID string
|
var siteID string
|
||||||
var ps []interface{}
|
var ps []any
|
||||||
if err := json.Unmarshal(params, &ps); err != nil {
|
if err := json.Unmarshal(params, &ps); err != nil {
|
||||||
return jrpc.Response{Error: err.Error()}
|
return jrpc.Response{Error: err.Error()}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ func TestRPC_listFlagsHndl(t *testing.T) {
|
|||||||
|
|
||||||
flags, err = re.ListFlags(verifyFlagReq)
|
flags, err = re.ListFlags(verifyFlagReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []interface{}{"u1"}, flags)
|
assert.Equal(t, []any{"u1"}, flags)
|
||||||
verifiedUsers := make([]string, 0, len(flags))
|
verifiedUsers := make([]string, 0, len(flags))
|
||||||
for _, v := range flags {
|
for _, v := range flags {
|
||||||
verifiedUsers = append(verifiedUsers, v.(string))
|
verifiedUsers = append(verifiedUsers, v.(string))
|
||||||
@@ -338,6 +338,6 @@ func TestRPC_closeHndl(t *testing.T) {
|
|||||||
api := fmt.Sprintf("http://localhost:%d/test", port)
|
api := fmt.Sprintf("http://localhost:%d/test", port)
|
||||||
|
|
||||||
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
re := engine.RPC{Client: jrpc.Client{API: api, Client: http.Client{Timeout: 1 * time.Second}}}
|
||||||
err := re.Close()
|
assert.NoError(t, re.Close())
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, re.Close(), "second call should not result in panic or errors")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ func (s *RPC) imgResetClnTimerHndl(id uint64, params json.RawMessage) (rr jrpc.R
|
|||||||
}
|
}
|
||||||
err := s.img.ResetCleanupTimer(fileID)
|
err := s.img.ResetCleanupTimer(fileID)
|
||||||
return jrpc.EncodeResponse(id, nil, err)
|
return jrpc.EncodeResponse(id, nil, err)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *RPC) imgLoadHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
func (s *RPC) imgLoadHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||||
@@ -47,6 +46,16 @@ func (s *RPC) imgLoadHndl(id uint64, params json.RawMessage) (rr jrpc.Response)
|
|||||||
return jrpc.EncodeResponse(id, value, err)
|
return jrpc.EncodeResponse(id, value, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *RPC) imgDeleteHndl(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.Delete(fileID)
|
||||||
|
return jrpc.EncodeResponse(id, nil, err)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
func (s *RPC) imgCommitHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
func (s *RPC) imgCommitHndl(id uint64, params json.RawMessage) (rr jrpc.Response) {
|
||||||
var fileID string
|
var fileID string
|
||||||
if err := json.Unmarshal(params, &fileID); err != nil {
|
if err := json.Unmarshal(params, &fileID); err != nil {
|
||||||
|
|||||||
@@ -115,14 +115,17 @@ func TestRPC_imgCleanupHndl(t *testing.T) {
|
|||||||
assert.Equal(t, 1462, len(img))
|
assert.Equal(t, 1462, len(img))
|
||||||
assert.Equal(t, gopherPNGBytes(), img)
|
assert.Equal(t, gopherPNGBytes(), img)
|
||||||
|
|
||||||
// wait for image to expire
|
// age the image past the ttl used below, so the reset that follows is what keeps it on
|
||||||
time.Sleep(time.Millisecond * 50)
|
// staging rather than the image simply being young
|
||||||
// reset the time to cleanup
|
const stagingTTL = 500 * time.Millisecond
|
||||||
|
time.Sleep(stagingTTL + 100*time.Millisecond)
|
||||||
|
|
||||||
|
// reset the time to cleanup, which leaves a full ttl before it could be collected again
|
||||||
err = ri.ResetCleanupTimer(id)
|
err = ri.ResetCleanupTimer(id)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// cleanup, should not affect the new image
|
// cleanup, should not affect the new image
|
||||||
err = ri.Cleanup(context.TODO(), time.Millisecond*45)
|
err = ri.Cleanup(context.TODO(), stagingTTL)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// load after cleanup should succeed
|
// load after cleanup should succeed
|
||||||
@@ -158,4 +161,9 @@ func TestRPC_imgInfoHndl(t *testing.T) {
|
|||||||
info, err = ri.Info()
|
info, err = ri.Info()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.False(t, info.FirstStagingImageTS.IsZero())
|
assert.False(t, info.FirstStagingImageTS.IsZero())
|
||||||
|
|
||||||
|
err = ri.Delete("test_img")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = ri.Load("test_img")
|
||||||
|
assert.EqualError(t, err, "image test_img not found")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ func (s *RPC) addHandlers() {
|
|||||||
"save_with_id": s.imgSaveWithIDHndl,
|
"save_with_id": s.imgSaveWithIDHndl,
|
||||||
"reset_cleanup_timer": s.imgResetClnTimerHndl,
|
"reset_cleanup_timer": s.imgResetClnTimerHndl,
|
||||||
"load": s.imgLoadHndl,
|
"load": s.imgLoadHndl,
|
||||||
|
"delete": s.imgDeleteHndl,
|
||||||
"commit": s.imgCommitHndl,
|
"commit": s.imgCommitHndl,
|
||||||
"cleanup": s.imgCleanupHndl,
|
"cleanup": s.imgCleanupHndl,
|
||||||
"info": s.imgInfoHndl,
|
"info": s.imgInfoHndl,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -20,27 +19,31 @@ import (
|
|||||||
"github.com/umputun/remark42/memory_store/accessor"
|
"github.com/umputun/remark42/memory_store/accessor"
|
||||||
)
|
)
|
||||||
|
|
||||||
func chooseRandomUnusedPort() (port int) {
|
// chooseUnusedPort asks the kernel for a free port from the ephemeral range, which makes a
|
||||||
for i := 0; i < 10; i++ {
|
// collision between concurrently running package test binaries very unlikely
|
||||||
port = 40000 + int(rand.Int31n(10000))
|
func chooseUnusedPort(t *testing.T) int {
|
||||||
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil {
|
t.Helper()
|
||||||
_ = ln.Close()
|
ln, err := net.Listen("tcp", ":0")
|
||||||
break
|
require.NoError(t, err, "no free port available")
|
||||||
}
|
port := ln.Addr().(*net.TCPAddr).Port
|
||||||
}
|
require.NoError(t, ln.Close())
|
||||||
return port
|
return port
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForHTTPServerStart(port int) {
|
// waitForHTTPServerStart blocks until the server on port answers, failing the test naming the
|
||||||
// wait for up to 3 seconds for server to start before returning it
|
// port if it never does
|
||||||
|
func waitForHTTPServerStart(t *testing.T, port int) {
|
||||||
|
t.Helper()
|
||||||
client := http.Client{Timeout: time.Second}
|
client := http.Client{Timeout: time.Second}
|
||||||
for i := 0; i < 300; i++ {
|
defer client.CloseIdleConnections()
|
||||||
time.Sleep(time.Millisecond * 10)
|
require.Eventually(t, func() bool {
|
||||||
if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil {
|
resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port))
|
||||||
_ = resp.Body.Close()
|
if err != nil {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
}
|
_ = resp.Body.Close()
|
||||||
|
return true
|
||||||
|
}, 30*time.Second, 10*time.Millisecond, "http server on port %d didn't start", port)
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepTestStore(t *testing.T) (port int, teardown func()) {
|
func prepTestStore(t *testing.T) (port int, teardown func()) {
|
||||||
@@ -61,14 +64,17 @@ func prepTestStore(t *testing.T) (port int, teardown func()) {
|
|||||||
admRecDisabled.Enabled = false
|
admRecDisabled.Enabled = false
|
||||||
adm.Set("test-site-disabled", admRecDisabled)
|
adm.Set("test-site-disabled", admRecDisabled)
|
||||||
|
|
||||||
port = chooseRandomUnusedPort()
|
port = chooseUnusedPort(t)
|
||||||
go func() {
|
go func() {
|
||||||
_ = s.Run(port)
|
_ = s.Run(port)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
waitForHTTPServerStart(port)
|
waitForHTTPServerStart(t, port)
|
||||||
|
|
||||||
return port, func() {
|
return port, func() {
|
||||||
|
// every test client here uses http.DefaultTransport, so their keep-alive connections
|
||||||
|
// sit in one shared pool; Shutdown waits on them and hits its own 5s deadline otherwise
|
||||||
|
http.DefaultTransport.(*http.Transport).CloseIdleConnections()
|
||||||
require.NoError(t, s.Shutdown())
|
require.NoError(t, s.Shutdown())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
log "github.com/go-pkgz/lgr"
|
log "github.com/go-pkgz/lgr"
|
||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
|
|
||||||
"github.com/go-pkgz/auth/avatar"
|
"github.com/go-pkgz/auth/v2/avatar"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AvatarCommand set of flags and command for avatar migration
|
// AvatarCommand set of flags and command for avatar migration
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/go-pkgz/auth/avatar"
|
"github.com/go-pkgz/auth/v2/avatar"
|
||||||
"github.com/jessevdk/go-flags"
|
"github.com/jessevdk/go-flags"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func (ec *BackupCommand) Execute(_ []string) error {
|
|||||||
req.SetBasicAuth("admin", ec.AdminPasswd)
|
req.SetBasicAuth("admin", ec.AdminPasswd)
|
||||||
|
|
||||||
// get with timeout
|
// get with timeout
|
||||||
resp, err := client.Do(req.WithContext(ctx))
|
resp, err := client.Do(req.WithContext(ctx)) //nolint:gosec // exportURL is built from operator-supplied CLI flags, not user input
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("request failed for %s: %w", exportURL, err)
|
return fmt.Errorf("request failed for %s: %w", exportURL, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jessevdk/go-flags"
|
"github.com/jessevdk/go-flags"
|
||||||
@@ -16,6 +18,10 @@ func TestBackup_Execute(t *testing.T) {
|
|||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, r.URL.Path, "/api/v1/admin/export")
|
assert.Equal(t, r.URL.Path, "/api/v1/admin/export")
|
||||||
assert.Equal(t, "GET", r.Method)
|
assert.Equal(t, "GET", r.Method)
|
||||||
|
t.Logf("Authorization header: %+v", r.Header.Get("Authorization"))
|
||||||
|
auth, err := base64.StdEncoding.DecodeString(strings.Split(r.Header.Get("Authorization"), " ")[1])
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "admin:secret", string(auth))
|
||||||
fmt.Fprint(w, "blah\nblah2\n12345678\n")
|
fmt.Fprint(w, "blah\nblah2\n12345678\n")
|
||||||
}))
|
}))
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
@@ -34,6 +40,28 @@ func TestBackup_Execute(t *testing.T) {
|
|||||||
assert.Equal(t, "blah\nblah2\n12345678\n", string(data))
|
assert.Equal(t, "blah\nblah2\n12345678\n", string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBackup_ExecuteNoPassword(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, r.URL.Path, "/api/v1/admin/export")
|
||||||
|
assert.Equal(t, "GET", r.Method)
|
||||||
|
t.Logf("Authorization: %+v", r.Header.Get("Authorization"))
|
||||||
|
auth, err := base64.StdEncoding.DecodeString(strings.Split(r.Header.Get("Authorization"), " ")[1])
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "admin:", string(auth))
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
fmt.Fprint(w, "Unauthorized")
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
cmd := BackupCommand{}
|
||||||
|
cmd.SetCommon(CommonOpts{RemarkURL: ts.URL})
|
||||||
|
p := flags.NewParser(&cmd, flags.Default)
|
||||||
|
_, err := p.ParseArgs([]string{"--site=remark", "--path=/tmp", "--file={{.SITE}}-test.export"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = cmd.Execute(nil)
|
||||||
|
assert.EqualError(t, err, "error response \"401 Unauthorized\", ensure you have set ADMIN_PASSWD and provided it to the command you're running: Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
func TestBackup_ExecuteFailedStatus(t *testing.T) {
|
func TestBackup_ExecuteFailedStatus(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, r.URL.Path, "/api/v1/admin/export")
|
assert.Equal(t, r.URL.Path, "/api/v1/admin/export")
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func (cc *CleanupCommand) procSpam(comments []store.Comment) int {
|
|||||||
log.Printf("[WARN] can't remove comment, %v", err)
|
log.Printf("[WARN] can't remove comment, %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
comment.Text = strings.Replace(comment.Text, "\n", " ", -1)
|
comment.Text = strings.ReplaceAll(comment.Text, "\n", " ")
|
||||||
log.Printf("[SPAM] %+v [%.0f%%]", comment, score)
|
log.Printf("[SPAM] %+v [%.0f%%]", comment, score)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,7 +179,7 @@ func (cc *CleanupCommand) listComments(postURL string) ([]store.Comment, error)
|
|||||||
|
|
||||||
commentsWithInfo := struct {
|
commentsWithInfo := struct {
|
||||||
Comments []store.Comment `json:"comments"`
|
Comments []store.Comment `json:"comments"`
|
||||||
Info store.PostInfo `json:"info,omitempty"`
|
Info store.PostInfo `json:"info"`
|
||||||
}{}
|
}{}
|
||||||
|
|
||||||
if err = json.NewDecoder(r.Body).Decode(&commentsWithInfo); err != nil {
|
if err = json.NewDecoder(r.Body).Decode(&commentsWithInfo); err != nil {
|
||||||
@@ -199,7 +199,7 @@ func (cc *CleanupCommand) deleteComment(c store.Comment) error { //nolint:dupl /
|
|||||||
|
|
||||||
client := http.Client{}
|
client := http.Client{}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
r, err := client.Do(req)
|
r, err := client.Do(req) //nolint:gosec // RemarkURL comes from operator CLI flag, not user input
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("delete request failed for comment %s, %s: %w", c.ID, c.Locator.URL, err)
|
return fmt.Errorf("delete request failed for comment %s, %s: %w", c.ID, c.Locator.URL, err)
|
||||||
}
|
}
|
||||||
@@ -221,7 +221,7 @@ func (cc *CleanupCommand) setTitle(c store.Comment) error { //nolint:dupl // not
|
|||||||
|
|
||||||
client := http.Client{}
|
client := http.Client{}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
r, err := client.Do(req)
|
r, err := client.Do(req) //nolint:gosec // RemarkURL comes from operator CLI flag, not user input
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("title request failed for comment %s, %s: %w", c.ID, c.Locator.URL, err)
|
return fmt.Errorf("title request failed for comment %s, %s: %w", c.ID, c.Locator.URL, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/jessevdk/go-flags"
|
"github.com/jessevdk/go-flags"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -46,7 +45,6 @@ func TestCleanup_IsSpam(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for n, tt := range tbl {
|
for n, tt := range tbl {
|
||||||
tt := tt
|
|
||||||
checkName := fmt.Sprintf("check-%d-%s", n, tt.name)
|
checkName := fmt.Sprintf("check-%d-%s", n, tt.name)
|
||||||
t.Run(checkName, func(t *testing.T) {
|
t.Run(checkName, func(t *testing.T) {
|
||||||
c := store.Comment{ID: checkName, Text: tt.text, Score: tt.score}
|
c := store.Comment{ID: checkName, Text: tt.text, Score: tt.score}
|
||||||
@@ -59,7 +57,7 @@ func TestCleanup_IsSpam(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanup_postsInRange(t *testing.T) {
|
func TestCleanup_postsInRange(t *testing.T) {
|
||||||
r := chi.NewRouter()
|
r := http.NewServeMux()
|
||||||
cleanupRoutes(t, r, nil)
|
cleanupRoutes(t, r, nil)
|
||||||
ts := httptest.NewServer(r)
|
ts := httptest.NewServer(r)
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
@@ -82,7 +80,7 @@ func TestCleanup_postsInRange(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCleanup_listComments(t *testing.T) {
|
func TestCleanup_listComments(t *testing.T) {
|
||||||
r := chi.NewRouter()
|
r := http.NewServeMux()
|
||||||
cleanupRoutes(t, r, nil)
|
cleanupRoutes(t, r, nil)
|
||||||
ts := httptest.NewServer(r)
|
ts := httptest.NewServer(r)
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
@@ -108,7 +106,7 @@ func TestCleanup_listComments(t *testing.T) {
|
|||||||
|
|
||||||
func TestCleanup_ExecuteSpam(t *testing.T) {
|
func TestCleanup_ExecuteSpam(t *testing.T) {
|
||||||
cleaned := cleanedComments{}
|
cleaned := cleanedComments{}
|
||||||
r := chi.NewRouter()
|
r := http.NewServeMux()
|
||||||
cleanupRoutes(t, r, &cleaned)
|
cleanupRoutes(t, r, &cleaned)
|
||||||
ts := httptest.NewServer(r)
|
ts := httptest.NewServer(r)
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
@@ -127,7 +125,7 @@ func TestCleanup_ExecuteSpam(t *testing.T) {
|
|||||||
|
|
||||||
func TestCleanup_ExecuteTitle(t *testing.T) {
|
func TestCleanup_ExecuteTitle(t *testing.T) {
|
||||||
titledComments := cleanedComments{}
|
titledComments := cleanedComments{}
|
||||||
r := chi.NewRouter()
|
r := http.NewServeMux()
|
||||||
cleanupRoutes(t, r, &titledComments)
|
cleanupRoutes(t, r, &titledComments)
|
||||||
ts := httptest.NewServer(r)
|
ts := httptest.NewServer(r)
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
@@ -143,7 +141,7 @@ func TestCleanup_ExecuteTitle(t *testing.T) {
|
|||||||
assert.Equal(t, []string{"/api/v1/admin/title/1", "/api/v1/admin/title/2", "/api/v1/admin/title/3", "/api/v1/admin/title/11"}, titledComments.ids)
|
assert.Equal(t, []string{"/api/v1/admin/title/1", "/api/v1/admin/title/2", "/api/v1/admin/title/3", "/api/v1/admin/title/11"}, titledComments.ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) {
|
func cleanupRoutes(t *testing.T, r *http.ServeMux, c *cleanedComments) {
|
||||||
r.HandleFunc("/api/v1/list", 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, "GET", r.Method)
|
||||||
require.Equal(t, "site=remark&limit=10000", r.URL.RawQuery)
|
require.Equal(t, "site=remark&limit=10000", r.URL.RawQuery)
|
||||||
@@ -174,7 +172,7 @@ func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) {
|
|||||||
|
|
||||||
commentsWithInfo := struct {
|
commentsWithInfo := struct {
|
||||||
Comments []store.Comment `json:"comments"`
|
Comments []store.Comment `json:"comments"`
|
||||||
Info store.PostInfo `json:"info,omitempty"`
|
Info store.PostInfo `json:"info"`
|
||||||
}{}
|
}{}
|
||||||
|
|
||||||
switch r.URL.Query().Get("url") {
|
switch r.URL.Query().Get("url") {
|
||||||
@@ -195,7 +193,7 @@ func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) {
|
|||||||
require.NoError(t, json.NewEncoder(w).Encode(commentsWithInfo))
|
require.NoError(t, json.NewEncoder(w).Encode(commentsWithInfo))
|
||||||
})
|
})
|
||||||
|
|
||||||
r.HandleFunc("/api/v1/admin/comment/{id}", func(w http.ResponseWriter, r *http.Request) {
|
r.HandleFunc("/api/v1/admin/comment/{id}", func(_ http.ResponseWriter, r *http.Request) {
|
||||||
require.Equal(t, "DELETE", r.Method)
|
require.Equal(t, "DELETE", r.Method)
|
||||||
t.Log("delete ", r.URL.Path)
|
t.Log("delete ", r.URL.Path)
|
||||||
c.lock.Lock()
|
c.lock.Lock()
|
||||||
@@ -203,7 +201,7 @@ func cleanupRoutes(t *testing.T, r *chi.Mux, c *cleanedComments) {
|
|||||||
c.lock.Unlock()
|
c.lock.Unlock()
|
||||||
})
|
})
|
||||||
|
|
||||||
r.HandleFunc("/api/v1/admin/title/{id}", func(w http.ResponseWriter, r *http.Request) {
|
r.HandleFunc("/api/v1/admin/title/{id}", func(_ http.ResponseWriter, r *http.Request) {
|
||||||
require.Equal(t, "PUT", r.Method)
|
require.Equal(t, "PUT", r.Method)
|
||||||
t.Log("title for ", r.URL.Path)
|
t.Log("title for ", r.URL.Path)
|
||||||
c.lock.Lock()
|
c.lock.Lock()
|
||||||
|
|||||||
@@ -115,13 +115,16 @@ func responseError(resp *http.Response) error {
|
|||||||
if e != nil {
|
if e != nil {
|
||||||
body = []byte("")
|
body = []byte("")
|
||||||
}
|
}
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
return fmt.Errorf("error response %q, ensure you have set ADMIN_PASSWD and provided it to the command you're running: %s", resp.Status, body)
|
||||||
|
}
|
||||||
return fmt.Errorf("error response %q, %s", resp.Status, body)
|
return fmt.Errorf("error response %q, %s", resp.Status, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mkdir -p for all dirs
|
// mkdir -p for all dirs
|
||||||
func makeDirs(dirs ...string) error {
|
func makeDirs(dirs ...string) error {
|
||||||
for _, dir := range dirs {
|
for _, dir := range dirs {
|
||||||
if err := os.MkdirAll(dir, 0o700); err != nil { // If path is already a directory, MkdirAll does nothing
|
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 fmt.Errorf("can't make directory %s: %w", dir, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func (ic *ImportCommand) Execute(_ []string) error {
|
|||||||
}
|
}
|
||||||
req.SetBasicAuth("admin", ic.AdminPasswd)
|
req.SetBasicAuth("admin", ic.AdminPasswd)
|
||||||
|
|
||||||
resp, err := client.Do(req.WithContext(ctx)) // closes request's reader
|
resp, err := client.Do(req.WithContext(ctx)) //nolint:gosec // importURL built from operator CLI flags, not user input; closes request's reader
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("request failed for %s: %w", importURL, err)
|
return fmt.Errorf("request failed for %s: %w", importURL, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
log "github.com/go-pkgz/lgr"
|
log "github.com/go-pkgz/lgr"
|
||||||
"github.com/jessevdk/go-flags"
|
"github.com/jessevdk/go-flags"
|
||||||
@@ -18,6 +19,10 @@ func TestImport_Execute(t *testing.T) {
|
|||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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, r.URL.Path, "/api/v1/admin/import")
|
||||||
assert.Equal(t, "POST", r.Method)
|
assert.Equal(t, "POST", r.Method)
|
||||||
|
t.Logf("Authorization header: %+v", r.Header.Get("Authorization"))
|
||||||
|
auth, err := base64.StdEncoding.DecodeString(strings.Split(r.Header.Get("Authorization"), " ")[1])
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "admin:secret", string(auth))
|
||||||
body, err := io.ReadAll(r.Body)
|
body, err := io.ReadAll(r.Body)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
|
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
|
||||||
@@ -46,6 +51,42 @@ func TestImport_Execute(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestImport_ExecuteNoPassword(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)
|
||||||
|
t.Logf("Authorization header: %+v", r.Header.Get("Authorization"))
|
||||||
|
auth, err := base64.StdEncoding.DecodeString(strings.Split(r.Header.Get("Authorization"), " ")[1])
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "admin:", string(auth))
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
|
||||||
|
|
||||||
|
w.WriteHeader(401)
|
||||||
|
fmt.Fprint(w, "Unauthorized")
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
cmd := ImportCommand{}
|
||||||
|
cmd.SetCommon(CommonOpts{RemarkURL: ts.URL})
|
||||||
|
|
||||||
|
p := flags.NewParser(&cmd, flags.Default)
|
||||||
|
_, err := p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = cmd.Execute(nil)
|
||||||
|
assert.EqualError(t, err, "error response \"401 Unauthorized\", ensure you have set ADMIN_PASSWD and provided it to the command you're running: Unauthorized")
|
||||||
|
|
||||||
|
cmd = ImportCommand{}
|
||||||
|
cmd.SetCommon(CommonOpts{RemarkURL: ts.URL})
|
||||||
|
|
||||||
|
p = flags.NewParser(&cmd, flags.Default)
|
||||||
|
_, err = p.ParseArgs([]string{"--site=remark", "--file=testdata/import.txt.gz"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = cmd.Execute(nil)
|
||||||
|
assert.EqualError(t, err, "error response \"401 Unauthorized\", ensure you have set ADMIN_PASSWD and provided it to the command you're running: Unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
func TestImport_ExecuteFailed(t *testing.T) {
|
func TestImport_ExecuteFailed(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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, r.URL.Path, "/api/v1/admin/import")
|
||||||
@@ -91,15 +132,14 @@ func TestImport_ExecuteFailed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestImport_ExecuteTimeout(t *testing.T) {
|
func TestImport_ExecuteTimeout(t *testing.T) {
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
|
assert.Equal(t, r.URL.Path, "/api/v1/admin/import")
|
||||||
assert.Equal(t, "POST", r.Method)
|
assert.Equal(t, "POST", r.Method)
|
||||||
body, err := io.ReadAll(r.Body)
|
body, err := io.ReadAll(r.Body)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
|
assert.Equal(t, "blah\nblah2\n12345678\n", string(body))
|
||||||
time.Sleep(500 * time.Millisecond)
|
// hold the response until the client gives up on its own timeout
|
||||||
fmt.Fprintln(w, "some response")
|
<-r.Context().Done()
|
||||||
fmt.Fprintln(w, string(body))
|
|
||||||
}))
|
}))
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ func (rc *RemapCommand) Execute(_ []string) error {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), rc.Timeout)
|
ctx, cancel := context.WithTimeout(context.Background(), rc.Timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
remapURL := fmt.Sprintf("%s/api/v1/admin/remap?site=%s", rc.RemarkURL, rc.Site)
|
remapURL := fmt.Sprintf("%s/api/v1/admin/remap?site=%s", rc.RemarkURL, rc.Site)
|
||||||
req, err := http.NewRequest(http.MethodPost, remapURL, rulesReader)
|
req, err := http.NewRequest(http.MethodPost, remapURL, rulesReader) //nolint:gosec // RemarkURL is operator CLI flag, not user input
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("can't make remap request for %s: %w", remapURL, err)
|
return fmt.Errorf("can't make remap request for %s: %w", remapURL, err)
|
||||||
}
|
}
|
||||||
req.SetBasicAuth("admin", rc.AdminPasswd)
|
req.SetBasicAuth("admin", rc.AdminPasswd)
|
||||||
|
|
||||||
resp, err := client.Do(req.WithContext(ctx))
|
resp, err := client.Do(req.WithContext(ctx)) //nolint:gosec // see above
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("request failed for %s: %w", remapURL, err)
|
return fmt.Errorf("request failed for %s: %w", remapURL, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jessevdk/go-flags"
|
"github.com/jessevdk/go-flags"
|
||||||
@@ -16,6 +19,10 @@ func TestRemap_Execute(t *testing.T) {
|
|||||||
assert.Equal(t, r.URL.Path, "/api/v1/admin/remap")
|
assert.Equal(t, r.URL.Path, "/api/v1/admin/remap")
|
||||||
assert.Equal(t, "POST", r.Method)
|
assert.Equal(t, "POST", r.Method)
|
||||||
assert.Equal(t, "remark", r.URL.Query().Get("site"))
|
assert.Equal(t, "remark", r.URL.Query().Get("site"))
|
||||||
|
t.Logf("Authorization header: %+v", r.Header.Get("Authorization"))
|
||||||
|
auth, err := base64.StdEncoding.DecodeString(strings.Split(r.Header.Get("Authorization"), " ")[1])
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "admin:secret", string(auth))
|
||||||
body, err := io.ReadAll(r.Body)
|
body, err := io.ReadAll(r.Body)
|
||||||
assert.NoError(t, err)
|
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))
|
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))
|
||||||
@@ -33,3 +40,31 @@ func TestRemap_Execute(t *testing.T) {
|
|||||||
err = cmd.Execute(nil)
|
err = cmd.Execute(nil)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemap_ExecuteNoPassword(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"))
|
||||||
|
t.Logf("Authorization header: %+v", r.Header.Get("Authorization"))
|
||||||
|
auth, err := base64.StdEncoding.DecodeString(strings.Split(r.Header.Get("Authorization"), " ")[1])
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "admin:", string(auth))
|
||||||
|
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))
|
||||||
|
|
||||||
|
w.WriteHeader(401)
|
||||||
|
fmt.Fprint(w, "Unauthorized")
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
cmd := RemapCommand{}
|
||||||
|
cmd.SetCommon(CommonOpts{RemarkURL: ts.URL})
|
||||||
|
|
||||||
|
p := flags.NewParser(&cmd, flags.Default)
|
||||||
|
_, err := p.ParseArgs([]string{"--site=remark", "--file=testdata/remap_urls.txt"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
err = cmd.Execute(nil)
|
||||||
|
assert.EqualError(t, err, "error response \"401 Unauthorized\", ensure you have set ADMIN_PASSWD and provided it to the command you're running: Unauthorized")
|
||||||
|
}
|
||||||
|
|||||||
+453
-151
@@ -2,38 +2,44 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha1" //nolint:gosec // used only for stable ID hashing, not for security
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path"
|
"path"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-pkgz/jrpc"
|
"github.com/go-pkgz/jrpc"
|
||||||
"github.com/go-pkgz/lcw/eventbus"
|
"github.com/go-pkgz/lcw/v2/eventbus"
|
||||||
log "github.com/go-pkgz/lgr"
|
log "github.com/go-pkgz/lgr"
|
||||||
ntf "github.com/go-pkgz/notify"
|
ntf "github.com/go-pkgz/notify"
|
||||||
"github.com/golang-jwt/jwt"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/kyokomi/emoji/v2"
|
"github.com/kyokomi/emoji/v2"
|
||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
"github.com/go-pkgz/auth"
|
"github.com/go-pkgz/auth/v2"
|
||||||
"github.com/go-pkgz/auth/avatar"
|
"github.com/go-pkgz/auth/v2/avatar"
|
||||||
"github.com/go-pkgz/auth/provider"
|
"github.com/go-pkgz/auth/v2/provider"
|
||||||
"github.com/go-pkgz/auth/provider/sender"
|
"github.com/go-pkgz/auth/v2/provider/sender"
|
||||||
"github.com/go-pkgz/auth/token"
|
"github.com/go-pkgz/auth/v2/token"
|
||||||
cache "github.com/go-pkgz/lcw"
|
cache "github.com/go-pkgz/lcw/v2"
|
||||||
|
|
||||||
"github.com/umputun/remark42/backend/app/migrator"
|
"github.com/umputun/remark42/backend/app/migrator"
|
||||||
"github.com/umputun/remark42/backend/app/notify"
|
"github.com/umputun/remark42/backend/app/notify"
|
||||||
"github.com/umputun/remark42/backend/app/providers"
|
"github.com/umputun/remark42/backend/app/providers"
|
||||||
"github.com/umputun/remark42/backend/app/rest/api"
|
"github.com/umputun/remark42/backend/app/rest/api"
|
||||||
"github.com/umputun/remark42/backend/app/rest/proxy"
|
"github.com/umputun/remark42/backend/app/rest/proxy"
|
||||||
|
"github.com/umputun/remark42/backend/app/safehttp"
|
||||||
"github.com/umputun/remark42/backend/app/store"
|
"github.com/umputun/remark42/backend/app/store"
|
||||||
"github.com/umputun/remark42/backend/app/store/admin"
|
"github.com/umputun/remark42/backend/app/store/admin"
|
||||||
"github.com/umputun/remark42/backend/app/store/engine"
|
"github.com/umputun/remark42/backend/app/store/engine"
|
||||||
@@ -58,34 +64,37 @@ type ServerCommand struct {
|
|||||||
SSL SSLGroup `group:"ssl" namespace:"ssl" env-namespace:"SSL"`
|
SSL SSLGroup `group:"ssl" namespace:"ssl" env-namespace:"SSL"`
|
||||||
ImageProxy ImageProxyGroup `group:"image-proxy" namespace:"image-proxy" env-namespace:"IMAGE_PROXY"`
|
ImageProxy ImageProxyGroup `group:"image-proxy" namespace:"image-proxy" env-namespace:"IMAGE_PROXY"`
|
||||||
|
|
||||||
Sites []string `long:"site" env:"SITE" default:"remark" description:"site names" env-delim:","`
|
Sites []string `long:"site" env:"SITE" default:"remark" description:"site names" env-delim:","`
|
||||||
AnonymousVote bool `long:"anon-vote" env:"ANON_VOTE" description:"enable anonymous votes (works only with VOTES_IP enabled)"`
|
AnonymousVote bool `long:"anon-vote" env:"ANON_VOTE" description:"enable anonymous votes (works only with VOTES_IP enabled)"`
|
||||||
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" default:"" description:"admin basic auth password"`
|
AdminPasswd string `long:"admin-passwd" env:"ADMIN_PASSWD" default:"" description:"admin basic auth password"`
|
||||||
BackupLocation string `long:"backup" env:"BACKUP_PATH" default:"./var/backup" description:"backups location"`
|
BackupLocation string `long:"backup" env:"BACKUP_PATH" default:"./var/backup" description:"backups location"`
|
||||||
MaxBackupFiles int `long:"max-back" env:"MAX_BACKUP_FILES" default:"10" description:"max backups to keep"`
|
MaxBackupFiles int `long:"max-back" env:"MAX_BACKUP_FILES" default:"10" description:"max backups to keep"`
|
||||||
LegacyImageProxy bool `long:"img-proxy" env:"IMG_PROXY" description:"[deprecated, use image-proxy.http2https] enable image proxy"`
|
LegacyImageProxy bool `long:"img-proxy" env:"IMG_PROXY" description:"[deprecated, use image-proxy.http2https] enable image proxy"`
|
||||||
MaxCommentSize int `long:"max-comment" env:"MAX_COMMENT_SIZE" default:"2048" description:"max comment size"`
|
MinCommentSize int `long:"min-comment" env:"MIN_COMMENT_SIZE" default:"0" description:"min comment size"`
|
||||||
MaxVotes int `long:"max-votes" env:"MAX_VOTES" default:"-1" description:"maximum number of votes per comment"`
|
MaxCommentSize int `long:"max-comment" env:"MAX_COMMENT_SIZE" default:"2048" description:"max comment size"`
|
||||||
RestrictVoteIP bool `long:"votes-ip" env:"VOTES_IP" description:"restrict votes from the same ip"`
|
MaxVotes int `long:"max-votes" env:"MAX_VOTES" default:"-1" description:"maximum number of votes per comment"`
|
||||||
DurationVoteIP time.Duration `long:"votes-ip-time" env:"VOTES_IP_TIME" default:"5m" description:"same ip vote duration"`
|
RestrictVoteIP bool `long:"votes-ip" env:"VOTES_IP" description:"restrict votes from the same ip"`
|
||||||
LowScore int `long:"low-score" env:"LOW_SCORE" default:"-5" description:"low score threshold"`
|
DurationVoteIP time.Duration `long:"votes-ip-time" env:"VOTES_IP_TIME" default:"5m" description:"same ip vote duration"`
|
||||||
CriticalScore int `long:"critical-score" env:"CRITICAL_SCORE" default:"-10" description:"critical score threshold"`
|
LowScore int `long:"low-score" env:"LOW_SCORE" default:"-5" description:"low score threshold"`
|
||||||
PositiveScore bool `long:"positive-score" env:"POSITIVE_SCORE" description:"enable positive score only"`
|
CriticalScore int `long:"critical-score" env:"CRITICAL_SCORE" default:"-10" description:"critical score threshold"`
|
||||||
ReadOnlyAge int `long:"read-age" env:"READONLY_AGE" default:"0" description:"read-only age of comments, days"`
|
PositiveScore bool `long:"positive-score" env:"POSITIVE_SCORE" description:"enable positive score only"`
|
||||||
EditDuration time.Duration `long:"edit-time" env:"EDIT_TIME" default:"5m" description:"edit window"`
|
ReadOnlyAge int `long:"read-age" env:"READONLY_AGE" default:"0" description:"read-only age of comments, days"`
|
||||||
AdminEdit bool `long:"admin-edit" env:"ADMIN_EDIT" description:"unlimited edit for admins"`
|
EditDuration time.Duration `long:"edit-time" env:"EDIT_TIME" default:"5m" description:"edit window; set to 0 to disable comment editing and staged image cleanup"`
|
||||||
Port int `long:"port" env:"REMARK_PORT" default:"8080" description:"port"`
|
AdminEdit bool `long:"admin-edit" env:"ADMIN_EDIT" description:"unlimited edit for admins"`
|
||||||
Address string `long:"address" env:"REMARK_ADDRESS" default:"" description:"listening address"`
|
Port int `long:"port" env:"REMARK_PORT" default:"8080" description:"port"`
|
||||||
WebRoot string `long:"web-root" env:"REMARK_WEB_ROOT" default:"./web" description:"web root directory"`
|
Address string `long:"address" env:"REMARK_ADDRESS" default:"" description:"listening address"`
|
||||||
UpdateLimit float64 `long:"update-limit" env:"UPDATE_LIMIT" default:"0.5" description:"updates/sec limit"`
|
WebRoot string `long:"web-root" env:"REMARK_WEB_ROOT" default:"./web" description:"web root directory"`
|
||||||
RestrictedWords []string `long:"restricted-words" env:"RESTRICTED_WORDS" description:"words prohibited to use in comments" env-delim:","`
|
UpdateLimit float64 `long:"update-limit" env:"UPDATE_LIMIT" default:"0.5" description:"updates/sec limit"`
|
||||||
RestrictedNames []string `long:"restricted-names" env:"RESTRICTED_NAMES" description:"names prohibited to use by user" env-delim:","`
|
TrustedProxies []string `long:"trusted-proxy" env:"TRUSTED_PROXY" description:"reverse-proxy networks (CIDR or IP) trusted to set the client IP; if unset, trusted from any client (see docs)" env-delim:","`
|
||||||
EnableEmoji bool `long:"emoji" env:"EMOJI" description:"enable emoji"`
|
RestrictedWords []string `long:"restricted-words" env:"RESTRICTED_WORDS" description:"words prohibited to use in comments" env-delim:","`
|
||||||
SimpleView bool `long:"simple-view" env:"SIMPLE_VIEW" description:"minimal comment editor mode"`
|
RestrictedNames []string `long:"restricted-names" env:"RESTRICTED_NAMES" description:"names prohibited to use by user" env-delim:","`
|
||||||
ProxyCORS bool `long:"proxy-cors" env:"PROXY_CORS" description:"disable internal CORS and delegate it to proxy"`
|
EnableEmoji bool `long:"emoji" env:"EMOJI" description:"enable emoji"`
|
||||||
AllowedHosts []string `long:"allowed-hosts" env:"ALLOWED_HOSTS" description:"limit hosts/sources allowed to embed comments" env-delim:","`
|
SimpleView bool `long:"simple-view" env:"SIMPLE_VIEW" description:"minimal comment editor mode"`
|
||||||
SubscribersOnly bool `long:"subscribers-only" env:"SUBSCRIBERS_ONLY" description:"enable commenting only for Patreon subscribers"`
|
ProxyCORS bool `long:"proxy-cors" env:"PROXY_CORS" description:"disable internal CORS and delegate it to proxy"`
|
||||||
DisableSignature bool `long:"disable-signature" env:"DISABLE_SIGNATURE" description:"disable server signature in headers"`
|
AllowedHosts []string `long:"allowed-hosts" env:"ALLOWED_HOSTS" description:"limit hosts/sources allowed to embed comments via CSP 'frame-ancestors'" env-delim:","`
|
||||||
|
SubscribersOnly bool `long:"subscribers-only" env:"SUBSCRIBERS_ONLY" description:"enable commenting only for Patreon subscribers"`
|
||||||
|
DisableSignature bool `long:"disable-signature" env:"DISABLE_SIGNATURE" description:"disable server signature in headers"`
|
||||||
|
DisableFancyTextFormatting bool `long:"disable-fancy-text-formatting" env:"DISABLE_FANCY_TEXT_FORMATTING" description:"disable fancy comments text formatting (replacement of quotes, dashes, fractions, etc)"`
|
||||||
|
|
||||||
Auth struct {
|
Auth struct {
|
||||||
TTL struct {
|
TTL struct {
|
||||||
@@ -93,29 +102,32 @@ type ServerCommand struct {
|
|||||||
Cookie time.Duration `long:"cookie" env:"COOKIE" default:"200h" description:"auth cookie TTL"`
|
Cookie time.Duration `long:"cookie" env:"COOKIE" default:"200h" description:"auth cookie TTL"`
|
||||||
} `group:"ttl" namespace:"ttl" env-namespace:"TTL"`
|
} `group:"ttl" namespace:"ttl" env-namespace:"TTL"`
|
||||||
|
|
||||||
SendJWTHeader bool `long:"send-jwt-header" env:"SEND_JWT_HEADER" description:"send JWT as a header instead of cookie"`
|
SendJWTHeader bool `long:"send-jwt-header" env:"SEND_JWT_HEADER" description:"also send JWT as a header, so the frontend can store it in a client-side cookie that survives third-party cookie blocking; server-set cookies are still sent (note: increases vulnerability to XSS attacks)"`
|
||||||
SameSite string `long:"same-site" env:"SAME_SITE" description:"set same site policy for cookies" choice:"default" choice:"none" choice:"lax" choice:"strict" default:"default"` // nolint
|
SameSite string `long:"same-site" env:"SAME_SITE" description:"set same site policy for cookies" choice:"default" choice:"none" choice:"lax" choice:"strict" default:"default"` // nolint
|
||||||
|
|
||||||
Google AuthGroup `group:"google" namespace:"google" env-namespace:"GOOGLE" description:"Google OAuth"`
|
Apple AppleGroup `group:"apple" namespace:"apple" env-namespace:"APPLE" description:"Apple OAuth"`
|
||||||
Github AuthGroup `group:"github" namespace:"github" env-namespace:"GITHUB" description:"Github OAuth"`
|
Google AuthGroup `group:"google" namespace:"google" env-namespace:"GOOGLE" description:"Google OAuth"`
|
||||||
Facebook AuthGroup `group:"facebook" namespace:"facebook" env-namespace:"FACEBOOK" description:"Facebook OAuth"`
|
Github AuthGroup `group:"github" namespace:"github" env-namespace:"GITHUB" description:"Github OAuth"`
|
||||||
Microsoft AuthGroup `group:"microsoft" namespace:"microsoft" env-namespace:"MICROSOFT" description:"Microsoft OAuth"`
|
Facebook AuthGroup `group:"facebook" namespace:"facebook" env-namespace:"FACEBOOK" description:"Facebook OAuth"`
|
||||||
Yandex AuthGroup `group:"yandex" namespace:"yandex" env-namespace:"YANDEX" description:"Yandex OAuth"`
|
Microsoft MicrosoftAuthGroup `group:"microsoft" namespace:"microsoft" env-namespace:"MICROSOFT" description:"Microsoft OAuth"`
|
||||||
Twitter AuthGroup `group:"twitter" namespace:"twitter" env-namespace:"TWITTER" description:"Twitter OAuth"`
|
Yandex AuthGroup `group:"yandex" namespace:"yandex" env-namespace:"YANDEX" description:"Yandex OAuth"`
|
||||||
Patreon AuthGroup `group:"patreon" namespace:"patreon" env-namespace:"PATREON" description:"Patreon OAuth"`
|
Twitter AuthGroup `group:"twitter" namespace:"twitter" env-namespace:"TWITTER" description:"[deprecated, doesn't work] Twitter OAuth"`
|
||||||
Telegram bool `long:"telegram" env:"TELEGRAM" description:"Enable Telegram auth (using token from telegram.token)"`
|
Patreon AuthGroup `group:"patreon" namespace:"patreon" env-namespace:"PATREON" description:"Patreon OAuth"`
|
||||||
Dev bool `long:"dev" env:"DEV" description:"enable dev (local) oauth2"`
|
Discord AuthGroup `group:"discord" namespace:"discord" env-namespace:"DISCORD" description:"Discord OAuth"`
|
||||||
Anonymous bool `long:"anon" env:"ANON" description:"enable anonymous login"`
|
Custom CustomAuthGroup `group:"custom" namespace:"custom" env-namespace:"CUSTOM" description:"Custom OAuth2 provider"`
|
||||||
|
Telegram bool `long:"telegram" env:"TELEGRAM" description:"Enable Telegram auth (using token from telegram.token)"`
|
||||||
|
Dev bool `long:"dev" env:"DEV" description:"enable dev (local) oauth2"`
|
||||||
|
Anonymous bool `long:"anon" env:"ANON" description:"enable anonymous login"`
|
||||||
Email struct {
|
Email struct {
|
||||||
Enable bool `long:"enable" env:"ENABLE" description:"enable auth via email"`
|
Enable bool `long:"enable" env:"ENABLE" description:"enable auth via email"`
|
||||||
From string `long:"from" env:"FROM" description:"from email address"`
|
From string `long:"from" env:"FROM" description:"from email address"`
|
||||||
Subject string `long:"subj" env:"SUBJ" default:"remark42 confirmation" description:"email's subject"`
|
Subject string `long:"subj" env:"SUBJ" default:"remark42 confirmation" description:"email's subject"`
|
||||||
ContentType string `long:"content-type" env:"CONTENT_TYPE" default:"text/html" description:"content type"`
|
ContentType string `long:"content-type" env:"CONTENT_TYPE" default:"text/html" description:"content type"`
|
||||||
Host string `long:"host" env:"HOST" description:"[deprecated, use --smtp.host] SMTP host"`
|
Host string `long:"host" env:"HOST" description:"[deprecated, use --smtp.host] SMTP host"`
|
||||||
Port int `long:"port" env:"PORT" description:"[deprecated, use --smtp.port] SMTP password"`
|
Port int `long:"port" env:"PORT" description:"[deprecated, use --smtp.port] SMTP port"`
|
||||||
SMTPPassword string `long:"passwd" env:"PASSWD" description:"[deprecated, use --smtp.password] SMTP port"`
|
SMTPPassword string `long:"passwd" env:"PASSWD" description:"[deprecated, use --smtp.password] SMTP password"`
|
||||||
SMTPUserName string `long:"user" env:"USER" description:"[deprecated, use --smtp.username] enable TLS"`
|
SMTPUserName string `long:"user" env:"USER" description:"[deprecated, use --smtp.username] SMTP user name"`
|
||||||
TLS bool `long:"tls" env:"TLS" description:"[deprecated, use --smtp.tls] SMTP TCP connection timeout"`
|
TLS bool `long:"tls" env:"TLS" description:"[deprecated, use --smtp.tls] enable TLS"`
|
||||||
TimeOut time.Duration `long:"timeout" env:"TIMEOUT" default:"10s" description:"[deprecated, use --smtp.timeout] SMTP TCP connection timeout"`
|
TimeOut time.Duration `long:"timeout" env:"TIMEOUT" default:"10s" description:"[deprecated, use --smtp.timeout] SMTP TCP connection timeout"`
|
||||||
MsgTemplate string `long:"template" env:"TEMPLATE" description:"[deprecated] message template file" default:"email_confirmation_login.html.tmpl"`
|
MsgTemplate string `long:"template" env:"TEMPLATE" description:"[deprecated] message template file" default:"email_confirmation_login.html.tmpl"`
|
||||||
} `group:"email" namespace:"email" env-namespace:"EMAIL"`
|
} `group:"email" namespace:"email" env-namespace:"EMAIL"`
|
||||||
@@ -133,12 +145,42 @@ type ImageProxyGroup struct {
|
|||||||
CacheExternal bool `long:"cache-external" env:"CACHE_EXTERNAL" description:"enable caching for external images"`
|
CacheExternal bool `long:"cache-external" env:"CACHE_EXTERNAL" description:"enable caching for external images"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AppleGroup defines options for Apple auth params
|
||||||
|
type AppleGroup struct {
|
||||||
|
CID string `long:"cid" env:"CID" description:"Apple client ID (App ID or Services ID)"`
|
||||||
|
TID string `long:"tid" env:"TID" description:"Apple service ID"`
|
||||||
|
KID string `long:"kid" env:"KID" description:"Private key ID"`
|
||||||
|
PrivateKeyFilePath string `long:"private-key-filepath" env:"PRIVATE_KEY_FILEPATH" description:"Private key file location" default:"/srv/var/apple.p8"`
|
||||||
|
}
|
||||||
|
|
||||||
// AuthGroup defines options group for auth params
|
// AuthGroup defines options group for auth params
|
||||||
type AuthGroup struct {
|
type AuthGroup struct {
|
||||||
CID string `long:"cid" env:"CID" description:"OAuth client ID"`
|
CID string `long:"cid" env:"CID" description:"OAuth client ID"`
|
||||||
CSEC string `long:"csec" env:"CSEC" description:"OAuth client secret"`
|
CSEC string `long:"csec" env:"CSEC" description:"OAuth client secret"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MicrosoftAuthGroup defines options group for Microsoft auth params
|
||||||
|
type MicrosoftAuthGroup struct {
|
||||||
|
CID string `long:"cid" env:"CID" description:"OAuth client ID"`
|
||||||
|
CSEC string `long:"csec" env:"CSEC" description:"OAuth client secret"`
|
||||||
|
Tenant string `long:"tenant" env:"TENANT" description:"Azure AD tenant ID, domain, or 'common' (default)" default:"common"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomAuthGroup defines options group for custom OAuth2 provider params
|
||||||
|
type CustomAuthGroup struct {
|
||||||
|
Name string `long:"name" env:"NAME" description:"custom provider name used in auth route"`
|
||||||
|
CID string `long:"cid" env:"CID" description:"OAuth client ID"`
|
||||||
|
CSEC string `long:"csec" env:"CSEC" description:"OAuth client secret"`
|
||||||
|
AuthURL string `long:"auth-url" env:"AUTH_URL" description:"OAuth authorization endpoint"`
|
||||||
|
TokenURL string `long:"token-url" env:"TOKEN_URL" description:"OAuth token endpoint"`
|
||||||
|
InfoURL string `long:"info-url" env:"INFO_URL" description:"OAuth user info endpoint"`
|
||||||
|
Scopes []string `long:"scopes" env:"SCOPES" env-delim:"," description:"OAuth scopes"`
|
||||||
|
IDField string `long:"id-field" env:"ID_FIELD" default:"sub" description:"user info field used as unique id"`
|
||||||
|
NameField string `long:"name-field" env:"NAME_FIELD" default:"name" description:"user info field used as display name"`
|
||||||
|
PictureField string `long:"picture-field" env:"PICTURE_FIELD" default:"picture" description:"user info field used as avatar url"`
|
||||||
|
EmailField string `long:"email-field" env:"EMAIL_FIELD" default:"email" description:"user info field used as email"`
|
||||||
|
}
|
||||||
|
|
||||||
// StoreGroup defines options group for store params
|
// StoreGroup defines options group for store params
|
||||||
type StoreGroup struct {
|
type StoreGroup struct {
|
||||||
Type string `long:"type" env:"TYPE" description:"type of storage" choice:"bolt" choice:"rpc" default:"bolt"` // nolint
|
Type string `long:"type" env:"TYPE" description:"type of storage" choice:"bolt" choice:"rpc" default:"bolt"` // nolint
|
||||||
@@ -197,7 +239,7 @@ type AdminGroup struct {
|
|||||||
Admins []string `long:"id" env:"ID" description:"admin(s) ids" env-delim:","`
|
Admins []string `long:"id" env:"ID" description:"admin(s) ids" env-delim:","`
|
||||||
Email []string `long:"email" env:"EMAIL" description:"admin emails" env-delim:","`
|
Email []string `long:"email" env:"EMAIL" description:"admin emails" env-delim:","`
|
||||||
} `group:"shared" namespace:"shared" env-namespace:"SHARED"`
|
} `group:"shared" namespace:"shared" env-namespace:"SHARED"`
|
||||||
RPC RPCGroup `group:"rpc" namespace:"rpc" env-namespace:"RPC"`
|
RPC AdminRPCGroup `group:"rpc" namespace:"rpc" env-namespace:"RPC"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TelegramGroup defines token for Telegram used in notify and auth modules
|
// TelegramGroup defines token for Telegram used in notify and auth modules
|
||||||
@@ -208,14 +250,16 @@ type TelegramGroup struct {
|
|||||||
|
|
||||||
// SMTPGroup defines options for SMTP server connection, used in auth and notify modules
|
// SMTPGroup defines options for SMTP server connection, used in auth and notify modules
|
||||||
type SMTPGroup struct {
|
type SMTPGroup struct {
|
||||||
Host string `long:"host" env:"HOST" description:"SMTP host"`
|
Host string `long:"host" env:"HOST" description:"SMTP host"`
|
||||||
Port int `long:"port" env:"PORT" description:"SMTP port"`
|
Port int `long:"port" env:"PORT" description:"SMTP port"`
|
||||||
Username string `long:"username" env:"USERNAME" description:"SMTP user name"`
|
HELOHost string `long:"helo_host" env:"HELO_HOST" description:"SMTP HELO/EHLO hostname"`
|
||||||
Password string `long:"password" env:"PASSWORD" description:"SMTP password"`
|
Username string `long:"username" env:"USERNAME" description:"SMTP user name"`
|
||||||
TLS bool `long:"tls" env:"TLS" description:"enable TLS"`
|
Password string `long:"password" env:"PASSWORD" description:"SMTP password"`
|
||||||
LoginAuth bool `long:"login_auth" env:"LOGIN_AUTH" description:"enable LOGIN auth instead of PLAIN"`
|
TLS bool `long:"tls" env:"TLS" description:"enable TLS"`
|
||||||
StartTLS bool `long:"starttls" env:"STARTTLS" description:"enable StartTLS"`
|
InsecureSkipVerify bool `long:"insecure_skip_verify" env:"INSECURE_SKIP_VERIFY" description:"skip certificate verification"`
|
||||||
TimeOut time.Duration `long:"timeout" env:"TIMEOUT" default:"10s" description:"SMTP TCP connection timeout"`
|
LoginAuth bool `long:"login_auth" env:"LOGIN_AUTH" description:"enable LOGIN auth instead of PLAIN"`
|
||||||
|
StartTLS bool `long:"starttls" env:"STARTTLS" description:"enable StartTLS"`
|
||||||
|
TimeOut time.Duration `long:"timeout" env:"TIMEOUT" default:"10s" description:"SMTP TCP connection timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotifyGroup defines options for notification
|
// NotifyGroup defines options for notification
|
||||||
@@ -241,8 +285,8 @@ type NotifyGroup struct {
|
|||||||
} `group:"slack" namespace:"slack" env-namespace:"SLACK"`
|
} `group:"slack" namespace:"slack" env-namespace:"SLACK"`
|
||||||
Webhook struct {
|
Webhook struct {
|
||||||
URL string `long:"url" env:"URL" description:"webhook URL for admin notifications"`
|
URL string `long:"url" env:"URL" description:"webhook URL for admin notifications"`
|
||||||
Template string `long:"template" env:"TEMPLATE" description:"webhook authentication template" default:"{\"text\": \"{{.Text}}\"}"`
|
Template string `long:"template" env:"TEMPLATE" description:"webhook payload template (Go text/template); falls back to {\"text\": {{.Text | escapeJSONString}}} when empty"`
|
||||||
Headers []string `long:"headers" description:"webhook authentication headers in format --notify.webhook.headers=Header1:Value1,Value2,... [$NOTIFY_WEBHOOK_HEADERS]"` // env NOTIFY_WEBHOOK_HEADERS split in code bellow to allow , inside ""
|
Headers []string `long:"headers" description:"webhook headers in format --notify.webhook.headers=Header1:Value1,Value2,... [$NOTIFY_WEBHOOK_HEADERS]"` // env NOTIFY_WEBHOOK_HEADERS split in code below to allow , inside ""
|
||||||
Timeout time.Duration `long:"timeout" env:"TIMEOUT" description:"webhook timeout" default:"5s"`
|
Timeout time.Duration `long:"timeout" env:"TIMEOUT" description:"webhook timeout" default:"5s"`
|
||||||
} `group:"webhook" namespace:"webhook" env-namespace:"WEBHOOK"`
|
} `group:"webhook" namespace:"webhook" env-namespace:"WEBHOOK"`
|
||||||
}
|
}
|
||||||
@@ -265,6 +309,12 @@ type RPCGroup struct {
|
|||||||
AuthPassword string `long:"auth_passwd" env:"AUTH_PASSWD" description:"basic auth user password"`
|
AuthPassword string `long:"auth_passwd" env:"AUTH_PASSWD" description:"basic auth user password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminRPCGroup defines options for remote admin store
|
||||||
|
type AdminRPCGroup struct {
|
||||||
|
RPCGroup
|
||||||
|
SecretPerSite bool `long:"secret_per_site" env:"SECRET_PER_SITE" description:"enable JWT secret retrieval per aud, which is site_id in this case"`
|
||||||
|
}
|
||||||
|
|
||||||
// LoadingCache defines interface for caching
|
// LoadingCache defines interface for caching
|
||||||
type LoadingCache interface {
|
type LoadingCache interface {
|
||||||
Get(key cache.Key, fn func() ([]byte, error)) (data []byte, err error) // load from cache if found or put to cache and return
|
Get(key cache.Key, fn func() ([]byte, error)) (data []byte, err error) // load from cache if found or put to cache and return
|
||||||
@@ -294,6 +344,7 @@ func (s *ServerCommand) Execute(_ []string) error {
|
|||||||
log.Printf("[INFO] start server on port %s:%d", s.Address, s.Port)
|
log.Printf("[INFO] start server on port %s:%d", s.Address, s.Port)
|
||||||
resetEnv(
|
resetEnv(
|
||||||
"SECRET",
|
"SECRET",
|
||||||
|
"AUTH_APPLE_KID",
|
||||||
"AUTH_GOOGLE_CSEC",
|
"AUTH_GOOGLE_CSEC",
|
||||||
"AUTH_GITHUB_CSEC",
|
"AUTH_GITHUB_CSEC",
|
||||||
"AUTH_FACEBOOK_CSEC",
|
"AUTH_FACEBOOK_CSEC",
|
||||||
@@ -301,6 +352,8 @@ func (s *ServerCommand) Execute(_ []string) error {
|
|||||||
"AUTH_TWITTER_CSEC",
|
"AUTH_TWITTER_CSEC",
|
||||||
"AUTH_YANDEX_CSEC",
|
"AUTH_YANDEX_CSEC",
|
||||||
"AUTH_PATREON_CSEC",
|
"AUTH_PATREON_CSEC",
|
||||||
|
"AUTH_DISCORD_CSEC",
|
||||||
|
"AUTH_CUSTOM_CSEC",
|
||||||
"TELEGRAM_TOKEN",
|
"TELEGRAM_TOKEN",
|
||||||
"SMTP_PASSWORD",
|
"SMTP_PASSWORD",
|
||||||
"ADMIN_PASSWD",
|
"ADMIN_PASSWD",
|
||||||
@@ -387,6 +440,12 @@ func (s *ServerCommand) HandleDeprecatedFlags() (result []DeprecatedFlag) {
|
|||||||
if s.Notify.Telegram.API != "https://api.telegram.org/bot" {
|
if s.Notify.Telegram.API != "https://api.telegram.org/bot" {
|
||||||
result = append(result, DeprecatedFlag{Old: "notify.telegram.api", Version: "1.9"})
|
result = append(result, DeprecatedFlag{Old: "notify.telegram.api", Version: "1.9"})
|
||||||
}
|
}
|
||||||
|
if s.Auth.Twitter.CID != "" {
|
||||||
|
result = append(result, DeprecatedFlag{Old: "auth.twitter.cid", Version: "1.14"})
|
||||||
|
}
|
||||||
|
if s.Auth.Twitter.CSEC != "" {
|
||||||
|
result = append(result, DeprecatedFlag{Old: "auth.twitter.csec", Version: "1.14"})
|
||||||
|
}
|
||||||
return append(result, s.findDeprecatedFlagsCollisions()...)
|
return append(result, s.findDeprecatedFlagsCollisions()...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,12 +503,87 @@ func stringsSetAndDifferent(s1, s2 string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func contains(s string, a []string) bool {
|
func contains(s string, a []string) bool {
|
||||||
for _, t := range a {
|
return slices.Contains(a, s)
|
||||||
if t == s {
|
}
|
||||||
return true
|
|
||||||
|
var reservedCustomProviderNames = map[string]struct{}{
|
||||||
|
"email": {},
|
||||||
|
"anonymous": {},
|
||||||
|
"google": {},
|
||||||
|
"github": {},
|
||||||
|
"facebook": {},
|
||||||
|
"yandex": {},
|
||||||
|
"twitter": {},
|
||||||
|
"microsoft": {},
|
||||||
|
"patreon": {},
|
||||||
|
"discord": {},
|
||||||
|
"telegram": {},
|
||||||
|
"dev": {},
|
||||||
|
"apple": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
var validCustomProviderName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||||
|
|
||||||
|
func isReservedCustomProviderName(name string) bool {
|
||||||
|
_, ok := reservedCustomProviderNames[name]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidCustomProviderName(name string) bool {
|
||||||
|
return validCustomProviderName.MatchString(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func customProviderSourceID(data provider.UserData, cfg CustomAuthGroup) string {
|
||||||
|
sourceID := data.Value(cfg.IDField)
|
||||||
|
if sourceID == "" {
|
||||||
|
sourceID = data.Value(cfg.EmailField)
|
||||||
|
}
|
||||||
|
if sourceID == "" {
|
||||||
|
sourceID = data.Value(cfg.NameField)
|
||||||
|
}
|
||||||
|
if sourceID == "" {
|
||||||
|
sourceID = data.Value(cfg.PictureField)
|
||||||
|
}
|
||||||
|
if sourceID == "" {
|
||||||
|
payload, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] failed to serialize custom oauth user data for ID fallback: %v", err)
|
||||||
|
} else {
|
||||||
|
sourceID = string(payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
if sourceID == "" || sourceID == "{}" {
|
||||||
|
log.Printf("[WARN] custom oauth provider returned no stable user identifier fields, falling back to hashed payload")
|
||||||
|
}
|
||||||
|
return sourceID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c CustomAuthGroup) isConfigured() bool {
|
||||||
|
return c.Name != "" || c.CID != "" || c.CSEC != "" || c.AuthURL != "" || c.TokenURL != "" || c.InfoURL != "" ||
|
||||||
|
len(c.Scopes) > 0 || c.IDField != "sub" || c.NameField != "name" || c.PictureField != "picture" || c.EmailField != "email"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c CustomAuthGroup) missingRequired() []string {
|
||||||
|
missing := []string{}
|
||||||
|
if c.Name == "" {
|
||||||
|
missing = append(missing, "AUTH_CUSTOM_NAME")
|
||||||
|
}
|
||||||
|
if c.CID == "" {
|
||||||
|
missing = append(missing, "AUTH_CUSTOM_CID")
|
||||||
|
}
|
||||||
|
if c.CSEC == "" {
|
||||||
|
missing = append(missing, "AUTH_CUSTOM_CSEC")
|
||||||
|
}
|
||||||
|
if c.AuthURL == "" {
|
||||||
|
missing = append(missing, "AUTH_CUSTOM_AUTH_URL")
|
||||||
|
}
|
||||||
|
if c.TokenURL == "" {
|
||||||
|
missing = append(missing, "AUTH_CUSTOM_TOKEN_URL")
|
||||||
|
}
|
||||||
|
if c.InfoURL == "" {
|
||||||
|
missing = append(missing, "AUTH_CUSTOM_INFO_URL")
|
||||||
|
}
|
||||||
|
return missing
|
||||||
}
|
}
|
||||||
|
|
||||||
// newServerApp prepares application and return it with all active parts
|
// newServerApp prepares application and return it with all active parts
|
||||||
@@ -464,6 +598,18 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
|||||||
}
|
}
|
||||||
log.Printf("[INFO] root url=%s", s.RemarkURL)
|
log.Printf("[INFO] root url=%s", s.RemarkURL)
|
||||||
|
|
||||||
|
// parse trusted proxies up front so a bad CIDR fails before any resource is allocated
|
||||||
|
trustedProxies, err := api.ParseTrustedProxies(s.TrustedProxies)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid --trusted-proxy: %w", err)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(trustedProxies) == 0:
|
||||||
|
log.Printf("[WARN] --trusted-proxy not set: forwarding headers are trusted from any client and can be spoofed to bypass rate limiting / vote dedup; set it behind a reverse proxy (see docs)")
|
||||||
|
case api.TrustsAnyPeer(trustedProxies):
|
||||||
|
log.Printf("[WARN] --trusted-proxy has a catch-all (0.0.0.0/0 or ::/0): forwarding headers are trusted from any client, re-opening the spoofing bypass; scope it to your proxy network")
|
||||||
|
}
|
||||||
|
|
||||||
storeEngine, err := s.makeDataStore()
|
storeEngine, err := s.makeDataStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to make data store engine: %w", err)
|
return nil, fmt.Errorf("failed to make data store engine: %w", err)
|
||||||
@@ -485,11 +631,12 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
|||||||
EditDuration: s.EditDuration,
|
EditDuration: s.EditDuration,
|
||||||
AdminEdits: s.AdminEdit,
|
AdminEdits: s.AdminEdit,
|
||||||
AdminStore: adminStore,
|
AdminStore: adminStore,
|
||||||
|
MinCommentSize: s.MinCommentSize,
|
||||||
MaxCommentSize: s.MaxCommentSize,
|
MaxCommentSize: s.MaxCommentSize,
|
||||||
MaxVotes: s.MaxVotes,
|
MaxVotes: s.MaxVotes,
|
||||||
PositiveScore: s.PositiveScore,
|
PositiveScore: s.PositiveScore,
|
||||||
ImageService: imageService,
|
ImageService: imageService,
|
||||||
TitleExtractor: service.NewTitleExtractor(http.Client{Timeout: time.Second * 5}),
|
TitleExtractor: service.NewTitleExtractor(http.Client{Timeout: time.Second * 5, Transport: safehttp.Transport()}, s.getAllowedDomains()),
|
||||||
RestrictedWordsMatcher: service.NewRestrictedWordsMatcher(service.StaticRestrictedWordsLister{Words: s.RestrictedWords}),
|
RestrictedWordsMatcher: service.NewRestrictedWordsMatcher(service.StaticRestrictedWordsLister{Words: s.RestrictedWords}),
|
||||||
}
|
}
|
||||||
dataService.RestrictSameIPVotes.Enabled = s.RestrictVoteIP
|
dataService.RestrictSameIPVotes.Enabled = s.RestrictVoteIP
|
||||||
@@ -510,11 +657,12 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
|||||||
authenticator := s.getAuthenticator(dataService, avatarStore, adminStore, authRefreshCache)
|
authenticator := s.getAuthenticator(dataService, avatarStore, adminStore, authRefreshCache)
|
||||||
|
|
||||||
telegramAuth := s.makeTelegramAuth(authenticator) // telegram auth requires TelegramAPI listener which is constructed below
|
telegramAuth := s.makeTelegramAuth(authenticator) // telegram auth requires TelegramAPI listener which is constructed below
|
||||||
telegramService, telegramBotUsername := s.startTelegramAuthAndNotify(ctx, telegramAuth)
|
telegramService := s.startTelegramAuthAndNotify(ctx, telegramAuth)
|
||||||
|
|
||||||
err = s.addAuthProviders(authenticator)
|
err = s.addAuthProviders(authenticator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = dataService.Close()
|
_ = dataService.Close()
|
||||||
|
_ = authRefreshCache.Close()
|
||||||
return nil, fmt.Errorf("failed to make authenticator: %w", err)
|
return nil, fmt.Errorf("failed to make authenticator: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,7 +672,7 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
|||||||
Cache: loadingCache,
|
Cache: loadingCache,
|
||||||
NativeImporter: &migrator.Native{DataStore: dataService},
|
NativeImporter: &migrator.Native{DataStore: dataService},
|
||||||
DisqusImporter: &migrator.Disqus{DataStore: dataService},
|
DisqusImporter: &migrator.Disqus{DataStore: dataService},
|
||||||
WordPressImporter: &migrator.WordPress{DataStore: dataService},
|
WordPressImporter: &migrator.WordPress{DataStore: dataService, DisableFancyTextFormatting: s.DisableFancyTextFormatting},
|
||||||
CommentoImporter: &migrator.Commento{DataStore: dataService},
|
CommentoImporter: &migrator.Commento{DataStore: dataService},
|
||||||
NativeExporter: &migrator.Native{DataStore: dataService},
|
NativeExporter: &migrator.Native{DataStore: dataService},
|
||||||
URLMapperMaker: migrator.NewURLMapper,
|
URLMapperMaker: migrator.NewURLMapper,
|
||||||
@@ -554,37 +702,41 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
|||||||
sslConfig, err := s.makeSSLConfig()
|
sslConfig, err := s.makeSSLConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = dataService.Close()
|
_ = dataService.Close()
|
||||||
|
_ = authRefreshCache.Close()
|
||||||
return nil, fmt.Errorf("failed to make config of ssl server params: %w", err)
|
return nil, fmt.Errorf("failed to make config of ssl server params: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := &api.Rest{
|
srv := &api.Rest{
|
||||||
Version: s.Revision,
|
Version: s.Revision,
|
||||||
DataService: dataService,
|
DataService: dataService,
|
||||||
WebRoot: s.WebRoot,
|
WebRoot: s.WebRoot,
|
||||||
WebFS: webFS,
|
WebFS: webFS,
|
||||||
RemarkURL: s.RemarkURL,
|
RemarkURL: s.RemarkURL,
|
||||||
ImageProxy: imgProxy,
|
ImageProxy: imgProxy,
|
||||||
CommentFormatter: commentFormatter,
|
CommentFormatter: commentFormatter,
|
||||||
Migrator: migr,
|
Migrator: migr,
|
||||||
ReadOnlyAge: s.ReadOnlyAge,
|
ReadOnlyAge: s.ReadOnlyAge,
|
||||||
SharedSecret: s.SharedSecret,
|
SharedSecret: s.SharedSecret,
|
||||||
Authenticator: authenticator,
|
TrustedProxies: trustedProxies,
|
||||||
Cache: loadingCache,
|
Authenticator: authenticator,
|
||||||
NotifyService: notifyService,
|
Cache: loadingCache,
|
||||||
TelegramService: telegramService,
|
NotifyService: notifyService,
|
||||||
SSLConfig: sslConfig,
|
TelegramService: telegramService,
|
||||||
UpdateLimiter: s.UpdateLimit,
|
SSLConfig: sslConfig,
|
||||||
ImageService: imageService,
|
UpdateLimiter: s.UpdateLimit,
|
||||||
EmailNotifications: contains("email", s.Notify.Users),
|
ImageService: imageService,
|
||||||
TelegramBotUsername: telegramBotUsername,
|
EmailNotifications: contains("email", s.Notify.Users),
|
||||||
EmojiEnabled: s.EnableEmoji,
|
TelegramNotifications: contains("telegram", s.Notify.Users) && telegramService != nil,
|
||||||
AnonVote: s.AnonymousVote && s.RestrictVoteIP,
|
EmojiEnabled: s.EnableEmoji,
|
||||||
SimpleView: s.SimpleView,
|
AnonVote: s.AnonymousVote && s.RestrictVoteIP,
|
||||||
ProxyCORS: s.ProxyCORS,
|
SimpleView: s.SimpleView,
|
||||||
AllowedAncestors: s.AllowedHosts,
|
ProxyCORS: s.ProxyCORS,
|
||||||
SendJWTHeader: s.Auth.SendJWTHeader,
|
AllowedAncestors: s.AllowedHosts,
|
||||||
SubscribersOnly: s.SubscribersOnly,
|
SendJWTHeader: s.Auth.SendJWTHeader,
|
||||||
DisableSignature: s.DisableSignature,
|
SubscribersOnly: s.SubscribersOnly,
|
||||||
|
DisableSignature: s.DisableSignature,
|
||||||
|
DisableFancyTextFormatting: s.DisableFancyTextFormatting,
|
||||||
|
ExternalImageProxy: s.ImageProxy.CacheExternal,
|
||||||
}
|
}
|
||||||
|
|
||||||
srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = s.LowScore, s.CriticalScore
|
srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = s.LowScore, s.CriticalScore
|
||||||
@@ -594,6 +746,7 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
|||||||
da, errDevAuth := authenticator.DevAuth()
|
da, errDevAuth := authenticator.DevAuth()
|
||||||
if errDevAuth != nil {
|
if errDevAuth != nil {
|
||||||
_ = dataService.Close()
|
_ = dataService.Close()
|
||||||
|
_ = authRefreshCache.Close()
|
||||||
return nil, fmt.Errorf("can't make dev oauth2 server: %w", errDevAuth)
|
return nil, fmt.Errorf("can't make dev oauth2 server: %w", errDevAuth)
|
||||||
}
|
}
|
||||||
devAuth = da
|
devAuth = da
|
||||||
@@ -615,6 +768,83 @@ func (s *ServerCommand) newServerApp(ctx context.Context) (*serverApp, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract domains from s.AllowedHosts and second level domain from s.RemarkURL.
|
||||||
|
// It can be and IP like http://127.0.0.1 in which case we need to use whole IP as domain
|
||||||
|
// Beware, if s.RemarkURL is in third-level domain like https://example.co.uk, co.uk will be returned.
|
||||||
|
func (s *ServerCommand) getAllowedDomains() []string {
|
||||||
|
rawDomains := s.AllowedHosts
|
||||||
|
rawDomains = append(rawDomains, s.RemarkURL)
|
||||||
|
allowedDomains := []string{}
|
||||||
|
for _, rawURL := range rawDomains {
|
||||||
|
// case of 'self' AllowedHosts, which is not a valid rawURL name
|
||||||
|
if rawURL == "self" || rawURL == "'self'" || rawURL == "\"self\"" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// AllowedHosts usually don't have https:// prefix, so we're adding it just to make parsing below work the same way as for RemarkURL
|
||||||
|
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||||||
|
rawURL = "https://" + rawURL
|
||||||
|
}
|
||||||
|
parsedURL, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] failed to parse URL %s for TitleExtract whitelist: %v", rawURL, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
domain := parsedURL.Hostname()
|
||||||
|
|
||||||
|
if domain == "" || // don't add empty domain as it will allow everything to be extracted
|
||||||
|
(len(strings.Split(domain, ".")) < 2 && // don't allow single-word domains like "com"
|
||||||
|
domain != "localhost") { // localhost is an exceptional single-word domain which is allowed
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// only for RemarkURL if domain is not IP and has more than two levels, extract second level domain.
|
||||||
|
// for AllowedHosts we don't do this as they are exact list of domains which can host comments, but
|
||||||
|
// remarkURL might be on a subdomain and we must allow parent domain to be used for TitleExtract.
|
||||||
|
if rawURL == s.RemarkURL && net.ParseIP(domain) == nil && len(strings.Split(domain, ".")) > 2 {
|
||||||
|
domain = strings.Join(strings.Split(domain, ".")[len(strings.Split(domain, "."))-2:], ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedDomains = append(allowedDomains, domain)
|
||||||
|
}
|
||||||
|
return allowedDomains
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAllowedRedirectHosts normalises s.AllowedHosts into the form that
|
||||||
|
// go-pkgz/auth's redirect validator expects. Strips http(s) schemes and
|
||||||
|
// paths; preserves explicit ports (the validator matches both host-only
|
||||||
|
// and host:port, so an entry without a port accepts any port while an
|
||||||
|
// entry with a port restricts to that port). Skips CSP sentinels
|
||||||
|
// ('self' / "self") and wildcard entries (*, *.example.com) that are
|
||||||
|
// valid CSP source expressions but not valid hostnames.
|
||||||
|
func (s *ServerCommand) getAllowedRedirectHosts() []string {
|
||||||
|
out := make([]string, 0, len(s.AllowedHosts))
|
||||||
|
for _, raw := range s.AllowedHosts {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" || raw == "self" || raw == "'self'" || raw == `"self"` {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.ContainsRune(raw, '*') { // CSP wildcard, not a host
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// add scheme so url.Parse populates Hostname()/Host consistently for bare hosts
|
||||||
|
toParse := raw
|
||||||
|
if !strings.HasPrefix(toParse, "http://") && !strings.HasPrefix(toParse, "https://") {
|
||||||
|
toParse = "https://" + toParse
|
||||||
|
}
|
||||||
|
u, err := url.Parse(toParse)
|
||||||
|
if err != nil || u.Hostname() == "" {
|
||||||
|
log.Printf("[WARN] skipping invalid AllowedHosts entry %q for redirect allowlist: %v", raw, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if u.Port() != "" {
|
||||||
|
out = append(out, u.Host) // preserve explicit host:port so allowlist is port-specific
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, u.Hostname())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Run all application objects
|
// Run all application objects
|
||||||
func (a *serverApp) run(ctx context.Context) error {
|
func (a *serverApp) run(ctx context.Context) error {
|
||||||
if a.AdminPasswd != "" {
|
if a.AdminPasswd != "" {
|
||||||
@@ -804,37 +1034,53 @@ func (s *ServerCommand) makeAdminStore() (admin.Store, error) {
|
|||||||
|
|
||||||
func (s *ServerCommand) makeCache() (LoadingCache, error) {
|
func (s *ServerCommand) makeCache() (LoadingCache, error) {
|
||||||
log.Printf("[INFO] make cache, type=%s", s.Cache.Type)
|
log.Printf("[INFO] make cache, type=%s", s.Cache.Type)
|
||||||
|
o := cache.NewOpts[[]byte]()
|
||||||
switch s.Cache.Type {
|
switch s.Cache.Type {
|
||||||
case "redis_pub_sub":
|
case "redis_pub_sub":
|
||||||
redisPubSub, err := eventbus.NewRedisPubSub(s.Cache.RedisAddr, "remark42-cache")
|
redisPubSub, err := eventbus.NewRedisPubSub(s.Cache.RedisAddr, "remark42-cache")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cache backend initialization, redis PubSub initialisation: %w", err)
|
return nil, fmt.Errorf("cache backend initialization, redis PubSub initialisation: %w", err)
|
||||||
}
|
}
|
||||||
backend, err := cache.NewLruCache(cache.MaxCacheSize(s.Cache.Max.Size), cache.MaxValSize(s.Cache.Max.Value),
|
backend, err := cache.NewLruCache(o.MaxCacheSize(s.Cache.Max.Size), o.MaxValSize(s.Cache.Max.Value),
|
||||||
cache.MaxKeys(s.Cache.Max.Items), cache.EventBus(redisPubSub))
|
o.MaxKeys(s.Cache.Max.Items), o.EventBus(redisPubSub))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cache backend initialization: %w", err)
|
return nil, fmt.Errorf("cache backend initialization: %w", err)
|
||||||
}
|
}
|
||||||
return cache.NewScache(backend), nil
|
return cache.NewScache[[]byte](backend), nil
|
||||||
case "mem":
|
case "mem":
|
||||||
backend, err := cache.NewLruCache(cache.MaxCacheSize(s.Cache.Max.Size), cache.MaxValSize(s.Cache.Max.Value),
|
backend, err := cache.NewLruCache(o.MaxCacheSize(s.Cache.Max.Size), o.MaxValSize(s.Cache.Max.Value),
|
||||||
cache.MaxKeys(s.Cache.Max.Items))
|
o.MaxKeys(s.Cache.Max.Items))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cache backend initialization: %w", err)
|
return nil, fmt.Errorf("cache backend initialization: %w", err)
|
||||||
}
|
}
|
||||||
return cache.NewScache(backend), nil
|
return cache.NewScache[[]byte](backend), nil
|
||||||
case "none":
|
case "none":
|
||||||
return cache.NewScache(&cache.Nop{}), nil
|
return cache.NewScache[[]byte](&cache.Nop[[]byte]{}), nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("unsupported cache type %s", s.Cache.Type)
|
return nil, fmt.Errorf("unsupported cache type %s", s.Cache.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:gocyclo // simple code but many if checks
|
||||||
func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error {
|
func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error {
|
||||||
providersCount := 0
|
providersCount := 0
|
||||||
if s.Auth.Telegram {
|
if s.Auth.Telegram {
|
||||||
providersCount++
|
providersCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.Auth.Apple.CID != "" && s.Auth.Apple.TID != "" && s.Auth.Apple.KID != "" {
|
||||||
|
err := authenticator.AddAppleProvider(
|
||||||
|
provider.AppleConfig{
|
||||||
|
ClientID: s.Auth.Apple.CID,
|
||||||
|
TeamID: s.Auth.Apple.TID,
|
||||||
|
KeyID: s.Auth.Apple.KID,
|
||||||
|
},
|
||||||
|
provider.LoadApplePrivateKeyFromFile(s.Auth.Apple.PrivateKeyFilePath),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
providersCount++
|
||||||
|
}
|
||||||
if s.Auth.Google.CID != "" && s.Auth.Google.CSEC != "" {
|
if s.Auth.Google.CID != "" && s.Auth.Google.CSEC != "" {
|
||||||
authenticator.AddProvider("google", s.Auth.Google.CID, s.Auth.Google.CSEC)
|
authenticator.AddProvider("google", s.Auth.Google.CID, s.Auth.Google.CSEC)
|
||||||
providersCount++
|
providersCount++
|
||||||
@@ -848,7 +1094,7 @@ func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error {
|
|||||||
providersCount++
|
providersCount++
|
||||||
}
|
}
|
||||||
if s.Auth.Microsoft.CID != "" && s.Auth.Microsoft.CSEC != "" {
|
if s.Auth.Microsoft.CID != "" && s.Auth.Microsoft.CSEC != "" {
|
||||||
authenticator.AddProvider("microsoft", s.Auth.Microsoft.CID, s.Auth.Microsoft.CSEC)
|
authenticator.AddMicrosoftProvider(s.Auth.Microsoft.CID, s.Auth.Microsoft.CSEC, s.Auth.Microsoft.Tenant)
|
||||||
providersCount++
|
providersCount++
|
||||||
}
|
}
|
||||||
if s.Auth.Yandex.CID != "" && s.Auth.Yandex.CSEC != "" {
|
if s.Auth.Yandex.CID != "" && s.Auth.Yandex.CSEC != "" {
|
||||||
@@ -863,6 +1109,49 @@ func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error {
|
|||||||
authenticator.AddProvider("patreon", s.Auth.Patreon.CID, s.Auth.Patreon.CSEC)
|
authenticator.AddProvider("patreon", s.Auth.Patreon.CID, s.Auth.Patreon.CSEC)
|
||||||
providersCount++
|
providersCount++
|
||||||
}
|
}
|
||||||
|
if s.Auth.Discord.CID != "" && s.Auth.Discord.CSEC != "" {
|
||||||
|
authenticator.AddProvider("discord", s.Auth.Discord.CID, s.Auth.Discord.CSEC)
|
||||||
|
providersCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Auth.Custom.isConfigured() {
|
||||||
|
missing := s.Auth.Custom.missingRequired()
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return fmt.Errorf("custom oauth provider configuration is incomplete, missing: %s", strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
customName := strings.ToLower(strings.TrimSpace(s.Auth.Custom.Name))
|
||||||
|
if !isValidCustomProviderName(customName) {
|
||||||
|
return fmt.Errorf("custom oauth provider name %q is invalid, expected pattern %q", customName, validCustomProviderName.String())
|
||||||
|
}
|
||||||
|
if isReservedCustomProviderName(customName) {
|
||||||
|
return fmt.Errorf("custom oauth provider name %q is reserved", customName)
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticator.AddCustomProvider(customName, auth.Client{Cid: s.Auth.Custom.CID, Csecret: s.Auth.Custom.CSEC}, provider.CustomHandlerOpt{
|
||||||
|
Endpoint: oauth2.Endpoint{
|
||||||
|
AuthURL: s.Auth.Custom.AuthURL,
|
||||||
|
TokenURL: s.Auth.Custom.TokenURL,
|
||||||
|
},
|
||||||
|
InfoURL: s.Auth.Custom.InfoURL,
|
||||||
|
Scopes: s.Auth.Custom.Scopes,
|
||||||
|
MapUserFn: func(data provider.UserData, _ []byte) token.User {
|
||||||
|
sourceID := customProviderSourceID(data, s.Auth.Custom)
|
||||||
|
hashID := token.HashID(sha1.New(), sourceID) //nolint:gosec // stable provider user id hash
|
||||||
|
user := token.User{
|
||||||
|
ID: customName + "_" + hashID,
|
||||||
|
Name: data.Value(s.Auth.Custom.NameField),
|
||||||
|
Picture: data.Value(s.Auth.Custom.PictureField),
|
||||||
|
Email: data.Value(s.Auth.Custom.EmailField),
|
||||||
|
}
|
||||||
|
if user.Name == "" {
|
||||||
|
user.Name = "noname_" + hashID[:4]
|
||||||
|
}
|
||||||
|
return user
|
||||||
|
},
|
||||||
|
})
|
||||||
|
providersCount++
|
||||||
|
}
|
||||||
|
|
||||||
if s.Auth.Dev {
|
if s.Auth.Dev {
|
||||||
log.Print("[INFO] dev access enabled")
|
log.Print("[INFO] dev access enabled")
|
||||||
@@ -876,18 +1165,20 @@ func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error {
|
|||||||
|
|
||||||
if s.Auth.Email.Enable {
|
if s.Auth.Email.Enable {
|
||||||
params := sender.EmailParams{
|
params := sender.EmailParams{
|
||||||
Host: s.SMTP.Host,
|
Host: s.SMTP.Host,
|
||||||
Port: s.SMTP.Port,
|
Port: s.SMTP.Port,
|
||||||
SMTPUserName: s.SMTP.Username,
|
HELOHost: s.SMTP.HELOHost,
|
||||||
SMTPPassword: s.SMTP.Password,
|
SMTPUserName: s.SMTP.Username,
|
||||||
TimeOut: s.SMTP.TimeOut,
|
SMTPPassword: s.SMTP.Password,
|
||||||
StartTLS: s.SMTP.StartTLS,
|
TimeOut: s.SMTP.TimeOut,
|
||||||
LoginAuth: s.SMTP.LoginAuth,
|
StartTLS: s.SMTP.StartTLS,
|
||||||
TLS: s.SMTP.TLS,
|
LoginAuth: s.SMTP.LoginAuth,
|
||||||
Charset: "UTF-8",
|
TLS: s.SMTP.TLS,
|
||||||
From: s.Auth.Email.From,
|
InsecureSkipVerify: s.SMTP.InsecureSkipVerify,
|
||||||
Subject: s.Auth.Email.Subject,
|
Charset: "UTF-8",
|
||||||
ContentType: s.Auth.Email.ContentType,
|
From: s.Auth.Email.From,
|
||||||
|
Subject: s.Auth.Email.Subject,
|
||||||
|
ContentType: s.Auth.Email.ContentType,
|
||||||
}
|
}
|
||||||
sndr := sender.NewEmailClient(params, log.Default())
|
sndr := sender.NewEmailClient(params, log.Default())
|
||||||
tmpl, err := templates.Read(s.Auth.Email.MsgTemplate)
|
tmpl, err := templates.Read(s.Auth.Email.MsgTemplate)
|
||||||
@@ -924,7 +1215,7 @@ func (s *ServerCommand) addAuthProviders(authenticator *auth.Service) error {
|
|||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}),
|
}),
|
||||||
// Custom user ID generator, used to distinguish anonymous users with the same login
|
// custom user ID generator, used to distinguish anonymous users with the same login
|
||||||
// coming from different IPs
|
// coming from different IPs
|
||||||
func(user string, r *http.Request) string {
|
func(user string, r *http.Request) string {
|
||||||
return user + r.RemoteAddr
|
return user + r.RemoteAddr
|
||||||
@@ -1009,14 +1300,14 @@ func (s *ServerCommand) makeNotifyDestinations(authenticator *auth.Service) ([]n
|
|||||||
VerificationSubject: s.Notify.Email.VerificationSubject,
|
VerificationSubject: s.Notify.Email.VerificationSubject,
|
||||||
UnsubscribeURL: s.RemarkURL + "/email/unsubscribe.html",
|
UnsubscribeURL: s.RemarkURL + "/email/unsubscribe.html",
|
||||||
// TODO: uncomment after #560 frontend part is ready and URL is known
|
// TODO: uncomment after #560 frontend part is ready and URL is known
|
||||||
// SubscribeURL: s.RemarkURL + "/subscribe.html?token=",
|
// subscribeURL: s.RemarkURL + "/subscribe.html?token=",
|
||||||
TokenGenFn: func(userID, email, site string) (string, error) {
|
TokenGenFn: func(userID, email, site string) (string, error) {
|
||||||
claims := token.Claims{
|
claims := token.Claims{
|
||||||
Handshake: &token.Handshake{ID: userID + "::" + email},
|
Handshake: &token.Handshake{ID: userID + "::" + email},
|
||||||
StandardClaims: jwt.StandardClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Audience: site,
|
Audience: jwt.ClaimStrings{site},
|
||||||
ExpiresAt: time.Now().Add(100 * 365 * 24 * time.Hour).Unix(),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(100 * 365 * 24 * time.Hour)),
|
||||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||||
Issuer: "remark42",
|
Issuer: "remark42",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1031,16 +1322,18 @@ func (s *ServerCommand) makeNotifyDestinations(authenticator *auth.Service) ([]n
|
|||||||
emailParams.AdminEmails = s.Admin.Shared.Email
|
emailParams.AdminEmails = s.Admin.Shared.Email
|
||||||
}
|
}
|
||||||
smtpParams := ntf.SMTPParams{
|
smtpParams := ntf.SMTPParams{
|
||||||
Host: s.SMTP.Host,
|
Host: s.SMTP.Host,
|
||||||
Port: s.SMTP.Port,
|
Port: s.SMTP.Port,
|
||||||
TLS: s.SMTP.TLS,
|
HELOHost: s.SMTP.HELOHost,
|
||||||
StartTLS: s.SMTP.StartTLS,
|
TLS: s.SMTP.TLS,
|
||||||
LoginAuth: s.SMTP.LoginAuth,
|
StartTLS: s.SMTP.StartTLS,
|
||||||
Username: s.SMTP.Username,
|
InsecureSkipVerify: s.SMTP.InsecureSkipVerify,
|
||||||
Password: s.SMTP.Password,
|
LoginAuth: s.SMTP.LoginAuth,
|
||||||
TimeOut: s.SMTP.TimeOut,
|
Username: s.SMTP.Username,
|
||||||
ContentType: "text/html",
|
Password: s.SMTP.Password,
|
||||||
Charset: "UTF-8",
|
TimeOut: s.SMTP.TimeOut,
|
||||||
|
ContentType: "text/html",
|
||||||
|
Charset: "UTF-8",
|
||||||
}
|
}
|
||||||
emailService, err := notify.NewEmail(emailParams, smtpParams)
|
emailService, err := notify.NewEmail(emailParams, smtpParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1111,6 +1404,12 @@ func (s *ServerCommand) getAuthenticator(ds *service.DataStore, avas avatar.Stor
|
|||||||
SendJWTHeader: s.Auth.SendJWTHeader,
|
SendJWTHeader: s.Auth.SendJWTHeader,
|
||||||
SameSiteCookie: s.parseSameSite(s.Auth.SameSite),
|
SameSiteCookie: s.parseSameSite(s.Auth.SameSite),
|
||||||
SecureCookies: strings.HasPrefix(s.RemarkURL, "https://"),
|
SecureCookies: strings.HasPrefix(s.RemarkURL, "https://"),
|
||||||
|
// enable the `from` redirect allowlist in go-pkgz/auth v2.1.2+ — limits
|
||||||
|
// post-auth redirects to RemarkURL's own host plus any configured
|
||||||
|
// AllowedHosts. Prevents the OAuth open-redirect / phishing vector.
|
||||||
|
AllowedRedirectHosts: token.AllowedHostsFunc(func() ([]string, error) {
|
||||||
|
return s.getAllowedRedirectHosts(), nil
|
||||||
|
}),
|
||||||
SecretReader: token.SecretFunc(func(aud string) (string, error) { // get secret per site
|
SecretReader: token.SecretFunc(func(aud string) (string, error) { // get secret per site
|
||||||
return admns.Key(aud)
|
return admns.Key(aud)
|
||||||
}),
|
}),
|
||||||
@@ -1118,10 +1417,16 @@ func (s *ServerCommand) getAuthenticator(ds *service.DataStore, avas avatar.Stor
|
|||||||
if c.User == nil {
|
if c.User == nil {
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
c.User.SetAdmin(ds.IsAdmin(c.Audience, c.User.ID))
|
// audience is a slice but we set it to a single element, and situation when there is no audience or there are more than one is unexpected
|
||||||
c.User.SetBoolAttr("blocked", ds.IsBlocked(c.Audience, c.User.ID))
|
if len(c.Audience) != 1 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
audience := c.Audience[0]
|
||||||
|
|
||||||
|
c.User.SetAdmin(ds.IsAdmin(audience, c.User.ID))
|
||||||
|
c.User.SetBoolAttr("blocked", ds.IsBlocked(audience, c.User.ID))
|
||||||
var err error
|
var err error
|
||||||
c.User.Email, err = ds.GetUserEmail(c.Audience, c.User.ID)
|
c.User.Email, err = ds.GetUserEmail(audience, c.User.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[WARN] can't read email for %s, %v", c.User.ID, err)
|
log.Printf("[WARN] can't read email for %s, %v", c.User.ID, err)
|
||||||
}
|
}
|
||||||
@@ -1141,7 +1446,7 @@ func (s *ServerCommand) getAuthenticator(ds *service.DataStore, avas avatar.Stor
|
|||||||
return c
|
return c
|
||||||
}),
|
}),
|
||||||
AdminPasswd: s.AdminPasswd,
|
AdminPasswd: s.AdminPasswd,
|
||||||
Validator: token.ValidatorFunc(func(token string, claims token.Claims) bool { // check on each auth call (in middleware)
|
Validator: token.ValidatorFunc(func(_ string, claims token.Claims) bool { // check on each auth call (in middleware)
|
||||||
if claims.User == nil {
|
if claims.User == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1157,6 +1462,7 @@ func (s *ServerCommand) getAuthenticator(ds *service.DataStore, avas avatar.Stor
|
|||||||
Logger: log.Default(),
|
Logger: log.Default(),
|
||||||
RefreshCache: authRefreshCache,
|
RefreshCache: authRefreshCache,
|
||||||
UseGravatar: true,
|
UseGravatar: true,
|
||||||
|
AudSecrets: s.Admin.RPC.SecretPerSite,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1177,20 +1483,15 @@ func (s *ServerCommand) parseSameSite(ss string) http.SameSite {
|
|||||||
|
|
||||||
// startTelegramAuthAndNotify initializes telegram notify and auth Telegram Bot listen loop.
|
// startTelegramAuthAndNotify initializes telegram notify and auth Telegram Bot listen loop.
|
||||||
// Does nothing if telegram auth and notifications are disabled.
|
// Does nothing if telegram auth and notifications are disabled.
|
||||||
// Doesn't return telegram bot username if user notifications are disabled, as that is the way frontend knows they are enabled.
|
func (s *ServerCommand) startTelegramAuthAndNotify(ctx context.Context, telegramAuth providers.TGUpdatesReceiver) (tg *notify.Telegram) {
|
||||||
func (s *ServerCommand) startTelegramAuthAndNotify(ctx context.Context, telegramAuth providers.TGUpdatesReceiver) (tg *notify.Telegram, telegramBotUsername string) {
|
|
||||||
if !contains("telegram", s.Notify.Users) && !contains("telegram", s.Notify.Admins) && !s.Auth.Telegram {
|
if !contains("telegram", s.Notify.Users) && !contains("telegram", s.Notify.Admins) && !s.Auth.Telegram {
|
||||||
return nil, ""
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if tg, err = s.makeTelegramNotify(); err != nil {
|
if tg, err = s.makeTelegramNotify(); err != nil {
|
||||||
log.Printf("[WARN] failed to make telegram notify service, %s", err)
|
log.Printf("[WARN] failed to make telegram notify service, %s", err)
|
||||||
return nil, ""
|
return nil
|
||||||
}
|
|
||||||
|
|
||||||
if contains("telegram", s.Notify.Users) {
|
|
||||||
telegramBotUsername = tg.GetBotUsername()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
telegramReceivers := []providers.TGUpdatesReceiver{tg}
|
telegramReceivers := []providers.TGUpdatesReceiver{tg}
|
||||||
@@ -1200,7 +1501,7 @@ func (s *ServerCommand) startTelegramAuthAndNotify(ctx context.Context, telegram
|
|||||||
// start bot messages receiver for both notify and auth services
|
// start bot messages receiver for both notify and auth services
|
||||||
go providers.DispatchTelegramUpdates(ctx, tg, telegramReceivers, time.Second*5)
|
go providers.DispatchTelegramUpdates(ctx, tg, telegramReceivers, time.Second*5)
|
||||||
|
|
||||||
return tg, telegramBotUsername
|
return tg
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitAtCommas split s at commas, ignoring commas in strings.
|
// splitAtCommas split s at commas, ignoring commas in strings.
|
||||||
@@ -1247,20 +1548,21 @@ func splitAtCommas(s string) []string {
|
|||||||
|
|
||||||
// authRefreshCache used by authenticator to minimize repeatable token refreshes
|
// authRefreshCache used by authenticator to minimize repeatable token refreshes
|
||||||
type authRefreshCache struct {
|
type authRefreshCache struct {
|
||||||
cache.LoadingCache
|
cache.LoadingCache[token.Claims]
|
||||||
}
|
}
|
||||||
|
|
||||||
func newAuthRefreshCache() *authRefreshCache {
|
func newAuthRefreshCache() *authRefreshCache {
|
||||||
expirableCache, _ := cache.NewExpirableCache(cache.TTL(5 * time.Minute))
|
o := cache.NewOpts[token.Claims]()
|
||||||
|
expirableCache, _ := cache.NewExpirableCache(o.TTL(5 * time.Minute))
|
||||||
return &authRefreshCache{LoadingCache: expirableCache}
|
return &authRefreshCache{LoadingCache: expirableCache}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get implements cache getter with key converted to string
|
// Get implements cache getter with key converted to string
|
||||||
func (c *authRefreshCache) Get(key interface{}) (interface{}, bool) {
|
func (c *authRefreshCache) Get(key string) (token.Claims, bool) {
|
||||||
return c.LoadingCache.Peek(key.(string))
|
return c.Peek(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set implements cache setter with key converted to string
|
// Set implements cache setter with key converted to string
|
||||||
func (c *authRefreshCache) Set(key, value interface{}) {
|
func (c *authRefreshCache) Set(key string, value token.Claims) {
|
||||||
_, _ = c.LoadingCache.Get(key.(string), func() (interface{}, error) { return value, nil })
|
_, _ = c.LoadingCache.Get(key, func() (token.Claims, error) { return value, nil })
|
||||||
}
|
}
|
||||||
|
|||||||
+525
-112
@@ -5,7 +5,6 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -15,8 +14,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-pkgz/auth/token"
|
"github.com/go-pkgz/auth/v2/provider"
|
||||||
"github.com/golang-jwt/jwt"
|
"github.com/go-pkgz/auth/v2/token"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/jessevdk/go-flags"
|
"github.com/jessevdk/go-flags"
|
||||||
"go.uber.org/goleak"
|
"go.uber.org/goleak"
|
||||||
|
|
||||||
@@ -24,15 +24,33 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// budget for a server to bind and answer, generous enough for a loaded CI runner
|
||||||
|
serverStartTimeout = 30 * time.Second
|
||||||
|
serverStartPoll = 10 * time.Millisecond
|
||||||
|
|
||||||
|
// budget for a server to stop once asked. tight enough to catch a shutdown that hangs,
|
||||||
|
// loose enough not to depend on how loaded the runner is
|
||||||
|
serverStopTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// connect budget for a single probe. kept off the poll interval so a slow loopback connect
|
||||||
|
// on a loaded runner does not look like a server that is not listening
|
||||||
|
probeDialTimeout = time.Second
|
||||||
|
|
||||||
|
// the /auth/ group is limited to 2 req/s, so retries sit at its refill interval rather than
|
||||||
|
// above it, which would only manufacture more 429s
|
||||||
|
authRetryPoll = 500 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
func TestServerApp(t *testing.T) {
|
func TestServerApp(t *testing.T) {
|
||||||
port := chooseRandomUnusedPort()
|
port := chooseUnusedPort(t)
|
||||||
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
||||||
o.Port = port
|
o.Port = port
|
||||||
return o
|
return o
|
||||||
})
|
})
|
||||||
|
|
||||||
go func() { _ = app.run(ctx) }()
|
go func() { _ = app.run(ctx) }()
|
||||||
waitForHTTPServerStart(port)
|
waitForHTTPServerStart(t, port)
|
||||||
|
|
||||||
// send ping
|
// send ping
|
||||||
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
||||||
@@ -47,7 +65,7 @@ func TestServerApp(t *testing.T) {
|
|||||||
// add comment
|
// add comment
|
||||||
client := http.Client{Timeout: 10 * time.Second}
|
client := http.Client{Timeout: 10 * time.Second}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment?site=remark", port),
|
||||||
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`))
|
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.SetBasicAuth("admin", "password")
|
req.SetBasicAuth("admin", "password")
|
||||||
@@ -67,7 +85,7 @@ func TestServerApp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestServerApp_DevMode(t *testing.T) {
|
func TestServerApp_DevMode(t *testing.T) {
|
||||||
port := chooseRandomUnusedPort()
|
port := chooseUnusedPort(t)
|
||||||
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
||||||
o.Port = port
|
o.Port = port
|
||||||
o.AdminPasswd = "password"
|
o.AdminPasswd = "password"
|
||||||
@@ -76,10 +94,10 @@ func TestServerApp_DevMode(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
go func() { _ = app.run(ctx) }()
|
go func() { _ = app.run(ctx) }()
|
||||||
waitForHTTPServerStart(port)
|
waitForHTTPServerStart(t, port)
|
||||||
|
|
||||||
providers := app.restSrv.Authenticator.Providers()
|
providers := app.restSrv.Authenticator.Providers()
|
||||||
require.Equal(t, 9+1, len(providers), "extra auth provider")
|
require.Equal(t, 11+1, len(providers), "extra auth provider")
|
||||||
assert.Equal(t, "dev", providers[len(providers)-2].Name(), "dev auth provider")
|
assert.Equal(t, "dev", providers[len(providers)-2].Name(), "dev auth provider")
|
||||||
// send ping
|
// send ping
|
||||||
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
||||||
@@ -95,8 +113,32 @@ func TestServerApp_DevMode(t *testing.T) {
|
|||||||
app.Wait()
|
app.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServerApp_CustomOAuthProvider(t *testing.T) {
|
||||||
|
port := chooseUnusedPort(t)
|
||||||
|
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
||||||
|
o.Port = port
|
||||||
|
o.Auth.Custom.Name = "oidc"
|
||||||
|
o.Auth.Custom.CID = "cid"
|
||||||
|
o.Auth.Custom.CSEC = "csec"
|
||||||
|
o.Auth.Custom.AuthURL = "https://example.com/oauth2/authorize"
|
||||||
|
o.Auth.Custom.TokenURL = "https://example.com/oauth2/token"
|
||||||
|
o.Auth.Custom.InfoURL = "https://example.com/oauth2/userinfo"
|
||||||
|
return o
|
||||||
|
})
|
||||||
|
|
||||||
|
go func() { _ = app.run(ctx) }()
|
||||||
|
waitForHTTPServerStart(t, port)
|
||||||
|
|
||||||
|
providers := app.restSrv.Authenticator.Providers()
|
||||||
|
require.Equal(t, 11+1, len(providers), "extra auth provider")
|
||||||
|
assert.Equal(t, "oidc", providers[len(providers)-2].Name(), "custom auth provider")
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
app.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
func TestServerApp_AnonMode(t *testing.T) {
|
func TestServerApp_AnonMode(t *testing.T) {
|
||||||
port := chooseRandomUnusedPort()
|
port := chooseUnusedPort(t)
|
||||||
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
||||||
o.Port = port
|
o.Port = port
|
||||||
o.Auth.Anonymous = true
|
o.Auth.Anonymous = true
|
||||||
@@ -104,10 +146,10 @@ func TestServerApp_AnonMode(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
go func() { _ = app.run(ctx) }()
|
go func() { _ = app.run(ctx) }()
|
||||||
waitForHTTPServerStart(port)
|
waitForHTTPServerStart(t, port)
|
||||||
|
|
||||||
providers := app.restSrv.Authenticator.Providers()
|
providers := app.restSrv.Authenticator.Providers()
|
||||||
require.Equal(t, 9+1, len(providers), "extra auth provider for anon")
|
require.Equal(t, 11+1, len(providers), "extra auth provider for anon")
|
||||||
assert.Equal(t, "anonymous", providers[len(providers)-1].Name(), "anon auth provider")
|
assert.Equal(t, "anonymous", providers[len(providers)-1].Name(), "anon auth provider")
|
||||||
|
|
||||||
client := http.Client{Timeout: 10 * time.Second}
|
client := http.Client{Timeout: 10 * time.Second}
|
||||||
@@ -123,13 +165,12 @@ func TestServerApp_AnonMode(t *testing.T) {
|
|||||||
assert.Equal(t, "pong", string(body))
|
assert.Equal(t, "pong", string(body))
|
||||||
|
|
||||||
// try to login with good name
|
// try to login with good name
|
||||||
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=blah123&aud=remark", port))
|
resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=blah123&aud=remark", port))
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
// try to add a comment as good anonymous
|
// try to add a comment as good anonymous
|
||||||
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment?site=remark", port),
|
||||||
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`))
|
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -143,58 +184,44 @@ func TestServerApp_AnonMode(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
// try to login with non-latin name
|
// try to login with non-latin name
|
||||||
time.Sleep(time.Second)
|
nonLatin := fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=Раз_Два%20%20Три_34567&aud=remark", port)
|
||||||
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=Раз_Два%20%20Три_34567&aud=remark", port))
|
resp = getRetryThrottled(t, &client, nonLatin)
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
// try to login with bad name
|
// try to login with bad name
|
||||||
time.Sleep(time.Second)
|
resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=**blah123&aud=remark", 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()
|
defer resp.Body.Close()
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
// try to login with short name
|
// try to login with short name
|
||||||
time.Sleep(time.Second)
|
resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=bl%%20%%20&aud=remark", port))
|
||||||
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()
|
defer resp.Body.Close()
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
// try to login with name what have space in prefix
|
// try to login with name what have space in prefix
|
||||||
time.Sleep(time.Second)
|
resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=%%20somebody&aud=remark", port))
|
||||||
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()
|
defer resp.Body.Close()
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
// try to login with name what have space in suffix
|
// try to login with name what have space in suffix
|
||||||
time.Sleep(time.Second)
|
resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=somebody%%20&aud=remark", port))
|
||||||
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()
|
defer resp.Body.Close()
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
// try to login with long name
|
// try to login with long name
|
||||||
time.Sleep(time.Second)
|
|
||||||
ln := strings.Repeat("x", 65)
|
ln := strings.Repeat("x", 65)
|
||||||
resp, err = client.Get(fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=%s&aud=remark", port, ln))
|
resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=%s&aud=remark", port, ln))
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
// try to login with admin name
|
// try to login with admin name
|
||||||
time.Sleep(time.Second)
|
resp = getRetryThrottled(t, &client, fmt.Sprintf("http://localhost:%d/auth/anonymous/login?user=umpUtun&aud=remark", port))
|
||||||
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()
|
defer resp.Body.Close()
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
// try to add a comment as anonymous with admin name
|
// 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?site=remark", port),
|
||||||
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"}}`))
|
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -225,12 +252,12 @@ func getAuthFromCookie(t *testing.T, app *serverApp, resp *http.Response) (tkn s
|
|||||||
|
|
||||||
func TestServerApp_WithSSL(t *testing.T) {
|
func TestServerApp_WithSSL(t *testing.T) {
|
||||||
opts := ServerCommand{}
|
opts := ServerCommand{}
|
||||||
sslPort := chooseRandomUnusedPort()
|
sslPort := chooseUnusedPort(t)
|
||||||
opts.SetCommon(CommonOpts{RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort), SharedSecret: "123456"})
|
opts.SetCommon(CommonOpts{RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort), SharedSecret: "123456"})
|
||||||
|
|
||||||
// prepare options
|
// prepare options
|
||||||
p := flags.NewParser(&opts, flags.Default)
|
p := flags.NewParser(&opts, flags.Default)
|
||||||
port := chooseRandomUnusedPort()
|
port := chooseUnusedPort(t)
|
||||||
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--port=" + strconv.Itoa(port), "--store.bolt.path=/tmp/xyz", "--backup=/tmp",
|
_, 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",
|
"--avatar.type=bolt", "--avatar.bolt.file=/tmp/ava-test.db",
|
||||||
"--ssl.type=static", "--ssl.cert=testdata/cert.pem", "--ssl.key=testdata/key.pem",
|
"--ssl.type=static", "--ssl.cert=testdata/cert.pem", "--ssl.key=testdata/key.pem",
|
||||||
@@ -245,12 +272,13 @@ func TestServerApp_WithSSL(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel() // this context is not the one createAppFromCmd registers for cleanup
|
||||||
go func() { _ = app.run(ctx) }()
|
go func() { _ = app.run(ctx) }()
|
||||||
waitForHTTPSServerStart(sslPort)
|
waitForServerStart(t, sslPort, port) // the redirect check below uses the plain http port
|
||||||
|
|
||||||
client := http.Client{
|
client := http.Client{
|
||||||
// prevent http redirect
|
// prevent http redirect
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -287,10 +315,11 @@ func TestServerApp_WithRemote(t *testing.T) {
|
|||||||
|
|
||||||
// prepare options
|
// prepare options
|
||||||
p := flags.NewParser(&opts, flags.Default)
|
p := flags.NewParser(&opts, flags.Default)
|
||||||
port := chooseRandomUnusedPort()
|
port := chooseUnusedPort(t)
|
||||||
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--cache.type=none",
|
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--cache.type=none",
|
||||||
"--store.type=rpc", "--store.rpc.api=http://127.0.0.1",
|
"--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)
|
require.NoError(t, err)
|
||||||
opts.Auth.Github.CSEC, opts.Auth.Github.CID = "csec", "cid"
|
opts.Auth.Github.CSEC, opts.Auth.Github.CID = "csec", "cid"
|
||||||
opts.BackupLocation, opts.Image.FS.Path = "/tmp", "/tmp"
|
opts.BackupLocation, opts.Image.FS.Path = "/tmp", "/tmp"
|
||||||
@@ -300,8 +329,9 @@ func TestServerApp_WithRemote(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel() // this context is not the one createAppFromCmd registers for cleanup
|
||||||
go func() { _ = app.run(ctx) }()
|
go func() { _ = app.run(ctx) }()
|
||||||
waitForHTTPServerStart(port)
|
waitForHTTPServerStart(t, port)
|
||||||
|
|
||||||
// send ping
|
// send ping
|
||||||
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
||||||
@@ -351,6 +381,17 @@ func TestServerApp_Failed(t *testing.T) {
|
|||||||
assert.EqualError(t, err, "invalid remark42 url demo.remark42.com")
|
assert.EqualError(t, err, "invalid remark42 url demo.remark42.com")
|
||||||
t.Log(err)
|
t.Log(err)
|
||||||
|
|
||||||
|
// invalid trusted proxy CIDR fails fast, before any resource is created
|
||||||
|
opts = ServerCommand{}
|
||||||
|
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
||||||
|
p = flags.NewParser(&opts, flags.Default)
|
||||||
|
_, err = p.ParseArgs([]string{"--backup=/tmp", "--trusted-proxy=nonsense"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = opts.newServerApp(context.Background())
|
||||||
|
assert.EqualError(t, err, `invalid --trusted-proxy: invalid trusted proxy "nonsense"`)
|
||||||
|
t.Log(err)
|
||||||
|
|
||||||
|
// wrong store type
|
||||||
opts = ServerCommand{}
|
opts = ServerCommand{}
|
||||||
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
||||||
|
|
||||||
@@ -374,37 +415,222 @@ func TestServerApp_Failed(t *testing.T) {
|
|||||||
"problem subscribing to channel remark42-cache on address wrong_address: "+
|
"problem subscribing to channel remark42-cache on address wrong_address: "+
|
||||||
"dial tcp: address wrong_address: missing port in address")
|
"dial tcp: address wrong_address: missing port in address")
|
||||||
t.Log(err)
|
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)
|
||||||
|
|
||||||
|
// incomplete custom oauth config
|
||||||
|
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", "--backup=/tmp", "--image.fs.path=/tmp", "--auth.custom.name=oidc", "--auth.custom.cid=123"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = opts.newServerApp(context.Background())
|
||||||
|
assert.EqualError(t, err,
|
||||||
|
"failed to make authenticator: custom oauth provider configuration is incomplete, missing: "+
|
||||||
|
"AUTH_CUSTOM_CSEC, AUTH_CUSTOM_AUTH_URL, AUTH_CUSTOM_TOKEN_URL, AUTH_CUSTOM_INFO_URL")
|
||||||
|
t.Log(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsReservedCustomProviderName(t *testing.T) {
|
||||||
|
reserved := []string{
|
||||||
|
"email", "anonymous", "google", "github", "facebook", "yandex", "twitter",
|
||||||
|
"microsoft", "patreon", "discord", "telegram", "dev", "apple",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range reserved {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
assert.True(t, isReservedCustomProviderName(name))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.False(t, isReservedCustomProviderName("oidc"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidCustomProviderName(t *testing.T) {
|
||||||
|
valid := []string{"oidc", "codeberg", "provider_1", "provider-1", "a1"}
|
||||||
|
for _, name := range valid {
|
||||||
|
t.Run("valid_"+name, func(t *testing.T) {
|
||||||
|
assert.True(t, isValidCustomProviderName(name))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid := []string{"", " has-space", "has space", "Uppercase", "provider!", "-provider", "_provider"}
|
||||||
|
for _, name := range invalid {
|
||||||
|
t.Run("invalid_"+strings.ReplaceAll(name, " ", "_"), func(t *testing.T) {
|
||||||
|
assert.False(t, isValidCustomProviderName(name))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomProviderSourceID(t *testing.T) {
|
||||||
|
cfg := CustomAuthGroup{IDField: "sub", EmailField: "email", NameField: "name", PictureField: "picture"}
|
||||||
|
|
||||||
|
assert.Equal(t, "user-1", customProviderSourceID(provider.UserData{"sub": "user-1", "email": "a@example.com"}, cfg))
|
||||||
|
assert.Equal(t, "a@example.com", customProviderSourceID(provider.UserData{"email": "a@example.com"}, cfg))
|
||||||
|
assert.Equal(t, "alice", customProviderSourceID(provider.UserData{"name": "alice"}, cfg))
|
||||||
|
assert.Equal(t, "https://example.com/avatar.png", customProviderSourceID(provider.UserData{"picture": "https://example.com/avatar.png"}, cfg))
|
||||||
|
assert.Equal(t, `{"login":"alice"}`, customProviderSourceID(provider.UserData{"login": "alice"}, cfg))
|
||||||
|
assert.Equal(t, "{}", customProviderSourceID(provider.UserData{}, cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerApp_InvalidCustomOAuthProviderName(t *testing.T) {
|
||||||
|
baseArgs := []string{
|
||||||
|
"--store.bolt.path=/tmp",
|
||||||
|
"--backup=/tmp",
|
||||||
|
"--image.fs.path=/tmp",
|
||||||
|
"--auth.custom.cid=123",
|
||||||
|
"--auth.custom.csec=456",
|
||||||
|
"--auth.custom.auth-url=https://example.com/oauth2/authorize",
|
||||||
|
"--auth.custom.token-url=https://example.com/oauth2/token",
|
||||||
|
"--auth.custom.info-url=https://example.com/oauth2/userinfo",
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("reserved", func(t *testing.T) {
|
||||||
|
opts := ServerCommand{}
|
||||||
|
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
||||||
|
p := flags.NewParser(&opts, flags.Default)
|
||||||
|
_, err := p.ParseArgs(append(baseArgs, "--auth.custom.name=twitter"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = opts.newServerApp(context.Background())
|
||||||
|
assert.EqualError(t, err, `failed to make authenticator: custom oauth provider name "twitter" is reserved`)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("not_url_safe", func(t *testing.T) {
|
||||||
|
opts := ServerCommand{}
|
||||||
|
opts.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
||||||
|
p := flags.NewParser(&opts, flags.Default)
|
||||||
|
_, err := p.ParseArgs(append(baseArgs, "--auth.custom.name=bad name"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = opts.newServerApp(context.Background())
|
||||||
|
assert.EqualError(t, err, `failed to make authenticator: custom oauth provider name "bad name" is invalid, expected pattern "^[a-z0-9][a-z0-9_-]*$"`)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServerApp_Shutdown(t *testing.T) {
|
func TestServerApp_Shutdown(t *testing.T) {
|
||||||
|
port := chooseUnusedPort(t)
|
||||||
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
||||||
o.Port = chooseRandomUnusedPort()
|
o.Port = port
|
||||||
return o
|
return o
|
||||||
})
|
})
|
||||||
time.AfterFunc(100*time.Millisecond, func() {
|
|
||||||
cancel()
|
// cancel once the server actually answers, so the test measures shutdown and not startup.
|
||||||
})
|
// the deferred cancel also covers a failed wait, keeping app.run from racing the next test
|
||||||
st := time.Now()
|
errCh := make(chan error, 1)
|
||||||
err := app.run(ctx)
|
go func() { errCh <- app.run(ctx) }()
|
||||||
assert.NoError(t, err)
|
defer cancel()
|
||||||
assert.True(t, time.Since(st).Seconds() < 1, "should take about 100msec")
|
waitForHTTPServerStart(t, port)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
assert.NoError(t, err)
|
||||||
|
case <-time.After(serverStopTimeout):
|
||||||
|
t.Fatal("server app did not stop after context cancel")
|
||||||
|
}
|
||||||
app.Wait()
|
app.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServerApp_MainSignal(t *testing.T) {
|
// TestServerApp_ClaimsUpd covers the hook the authenticator runs on every token mint, refresh
|
||||||
done := make(chan struct{})
|
// included: it stamps admin, blocked and email onto the claims and blocks impersonation of a
|
||||||
go func() {
|
// restricted name. Calling the updater directly keeps it independent of when a token expires.
|
||||||
<-done
|
func TestServerApp_ClaimsUpd(t *testing.T) {
|
||||||
time.Sleep(250 * time.Millisecond)
|
port := chooseUnusedPort(t)
|
||||||
err := syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
|
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
||||||
|
o.Port = port
|
||||||
|
return o
|
||||||
|
})
|
||||||
|
|
||||||
|
// the app owns stores and services that only run closes, so it goes through the usual
|
||||||
|
// lifecycle here rather than being built and abandoned
|
||||||
|
go func() { _ = app.run(ctx) }()
|
||||||
|
waitForHTTPServerStart(t, port)
|
||||||
|
defer app.Wait()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
upd := app.restSrv.Authenticator.TokenService().ClaimsUpd
|
||||||
|
require.NotNil(t, upd, "claims updater wired into the token service")
|
||||||
|
|
||||||
|
claimsFor := func(id, name string) token.Claims {
|
||||||
|
return token.Claims{
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"remark"}},
|
||||||
|
User: &token.User{ID: id, Name: name},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("plain user gets no attributes", func(t *testing.T) {
|
||||||
|
res := upd.Update(claimsFor("provider1_dev", "developer"))
|
||||||
|
assert.False(t, res.User.IsAdmin(), "not an admin")
|
||||||
|
assert.False(t, res.User.BoolAttr("blocked"), "not blocked")
|
||||||
|
assert.Empty(t, res.User.Email, "no email on file")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("admin from the admin store", func(t *testing.T) {
|
||||||
|
res := upd.Update(claimsFor("id1", "admin one"))
|
||||||
|
assert.True(t, res.User.IsAdmin(), "id1 is listed as admin")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("blocked user carries the blocked attribute", func(t *testing.T) {
|
||||||
|
require.NoError(t, app.restSrv.DataService.SetBlock("remark", "blocked_user", true, time.Hour))
|
||||||
|
res := upd.Update(claimsFor("blocked_user", "blocked"))
|
||||||
|
assert.True(t, res.User.BoolAttr("blocked"), "block is reflected on refresh")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("email is read from the store", func(t *testing.T) {
|
||||||
|
_, err := app.restSrv.DataService.SetUserEmail("remark", "with_email", "user@example.com")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}()
|
res := upd.Update(claimsFor("with_email", "someone"))
|
||||||
|
assert.Equal(t, "user@example.com", res.User.Email)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("anonymous impersonating a restricted name is blocked", func(t *testing.T) {
|
||||||
|
res := upd.Update(claimsFor("anonymous_x", " UmpUtun "))
|
||||||
|
assert.True(t, res.User.BoolAttr("blocked"), "restricted name matched case and space insensitively")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("email user impersonating a restricted name is blocked", func(t *testing.T) {
|
||||||
|
res := upd.Update(claimsFor("email_x", "bobuk"))
|
||||||
|
assert.True(t, res.User.BoolAttr("blocked"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("regular user may carry a restricted name", func(t *testing.T) {
|
||||||
|
res := upd.Update(claimsFor("provider1_someone", "umputun"))
|
||||||
|
assert.False(t, res.User.BoolAttr("blocked"), "only anonymous and email logins are checked")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("claims without a user pass through", func(t *testing.T) {
|
||||||
|
res := upd.Update(token.Claims{RegisteredClaims: jwt.RegisteredClaims{Audience: jwt.ClaimStrings{"remark"}}})
|
||||||
|
assert.Nil(t, res.User)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("claims without exactly one audience pass through", func(t *testing.T) {
|
||||||
|
c := claimsFor("id1", "admin one")
|
||||||
|
c.Audience = jwt.ClaimStrings{"remark", "second"}
|
||||||
|
res := upd.Update(c)
|
||||||
|
assert.False(t, res.User.IsAdmin(), "attributes need a single audience to resolve the site")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerApp_MainSignal(t *testing.T) {
|
||||||
|
sigErr := make(chan error, 1)
|
||||||
|
|
||||||
s := ServerCommand{}
|
s := ServerCommand{}
|
||||||
s.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
s.SetCommon(CommonOpts{RemarkURL: "https://demo.remark42.com", SharedSecret: "123456"})
|
||||||
|
|
||||||
p := flags.NewParser(&s, flags.Default)
|
p := flags.NewParser(&s, flags.Default)
|
||||||
port := chooseRandomUnusedPort()
|
port := chooseUnusedPort(t)
|
||||||
args := []string{"test", "--store.bolt.path=/tmp/xyz", "--backup=/tmp", "--avatar.type=bolt",
|
args := []string{"test", "--store.bolt.path=/tmp/xyz", "--backup=/tmp", "--avatar.type=bolt",
|
||||||
"--avatar.bolt.file=/tmp/ava-test.db", "--port=" + strconv.Itoa(port), "--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")
|
||||||
@@ -412,11 +638,52 @@ func TestServerApp_MainSignal(t *testing.T) {
|
|||||||
defer os.Remove("/tmp/ava-test.db")
|
defer os.Remove("/tmp/ava-test.db")
|
||||||
_, err := p.ParseArgs(args)
|
_, err := p.ParseArgs(args)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
st := time.Now()
|
// the signal goes out only once the server answers: SIGTERM landing before the handler is
|
||||||
close(done)
|
// installed kills the test process, so a wait that timed out reports instead of sending it
|
||||||
|
go func() {
|
||||||
|
started := waitForServerPort(port, serverStartTimeout)
|
||||||
|
// signal either way: Execute blocks until it gets one, so bailing out here would hang
|
||||||
|
// the test until the package timeout instead of failing with the reason
|
||||||
|
killErr := syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
|
||||||
|
if !started {
|
||||||
|
killErr = fmt.Errorf("server on port %d didn't start", port)
|
||||||
|
}
|
||||||
|
sigErr <- killErr
|
||||||
|
}()
|
||||||
|
|
||||||
err = s.Execute(args)
|
err = s.Execute(args)
|
||||||
assert.NoError(t, err, "execute should be without errors")
|
assert.NoError(t, err, "execute should be without errors")
|
||||||
assert.True(t, time.Since(st).Seconds() < 5, "should take under five sec", time.Since(st).Seconds())
|
require.NoError(t, <-sigErr, "SIGTERM not delivered")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerApp_RunCanceledBeforeRESTStart(t *testing.T) {
|
||||||
|
port := chooseUnusedPort(t)
|
||||||
|
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
||||||
|
o.Port = port
|
||||||
|
return o
|
||||||
|
})
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() { errCh <- app.run(ctx) }()
|
||||||
|
|
||||||
|
// the budget is generous on purpose: the assertion is that run exits rather than hangs, and
|
||||||
|
// store construction can take a while on a loaded runner
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
require.NoError(t, err)
|
||||||
|
app.Wait()
|
||||||
|
case <-time.After(serverStartTimeout):
|
||||||
|
waitForHTTPServerStart(t, port)
|
||||||
|
app.restSrv.Shutdown()
|
||||||
|
select {
|
||||||
|
case <-errCh:
|
||||||
|
app.Wait()
|
||||||
|
case <-time.After(serverStartTimeout):
|
||||||
|
t.Fatal("server app did not stop after forced REST shutdown")
|
||||||
|
}
|
||||||
|
t.Fatal("server app should exit when context is canceled before REST server starts")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServerApp_DeprecatedArgs(t *testing.T) {
|
func TestServerApp_DeprecatedArgs(t *testing.T) {
|
||||||
@@ -442,6 +709,8 @@ func TestServerApp_DeprecatedArgs(t *testing.T) {
|
|||||||
"--notify.telegram.token=abcd",
|
"--notify.telegram.token=abcd",
|
||||||
"--notify.telegram.timeout=3m",
|
"--notify.telegram.timeout=3m",
|
||||||
"--notify.telegram.api=http://example.org",
|
"--notify.telegram.api=http://example.org",
|
||||||
|
"--auth.twitter.cid=123",
|
||||||
|
"--auth.twitter.csec=456",
|
||||||
}
|
}
|
||||||
assert.Empty(t, s.SMTP.Host)
|
assert.Empty(t, s.SMTP.Host)
|
||||||
assert.Empty(t, s.SMTP.Port)
|
assert.Empty(t, s.SMTP.Port)
|
||||||
@@ -467,6 +736,8 @@ func TestServerApp_DeprecatedArgs(t *testing.T) {
|
|||||||
{Old: "notify.telegram.token", New: "telegram.token", 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.timeout", New: "telegram.timeout", Version: "1.9"},
|
||||||
{Old: "notify.telegram.api", Version: "1.9"},
|
{Old: "notify.telegram.api", Version: "1.9"},
|
||||||
|
{Old: "auth.twitter.cid", Version: "1.14"},
|
||||||
|
{Old: "auth.twitter.csec", Version: "1.14"},
|
||||||
},
|
},
|
||||||
deprecatedFlags)
|
deprecatedFlags)
|
||||||
assert.Equal(t, "smtp.example.org", s.SMTP.Host)
|
assert.Equal(t, "smtp.example.org", s.SMTP.Host)
|
||||||
@@ -576,28 +847,29 @@ func Test_ACMEEmail(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestServerAuthHooks(t *testing.T) {
|
func TestServerAuthHooks(t *testing.T) {
|
||||||
port := chooseRandomUnusedPort()
|
port := chooseUnusedPort(t)
|
||||||
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
app, ctx, cancel := prepServerApp(t, func(o ServerCommand) ServerCommand {
|
||||||
o.Port = port
|
o.Port = port
|
||||||
return o
|
return o
|
||||||
})
|
})
|
||||||
|
|
||||||
go func() { _ = app.run(ctx) }()
|
go func() { _ = app.run(ctx) }()
|
||||||
waitForHTTPServerStart(port)
|
waitForHTTPServerStart(t, port)
|
||||||
|
|
||||||
// make a token for user dev
|
// make a token for user dev. nothing here checks expiry, so the lifetime only has to
|
||||||
|
// outlast the whole test
|
||||||
tkService := app.restSrv.Authenticator.TokenService()
|
tkService := app.restSrv.Authenticator.TokenService()
|
||||||
tkService.TokenDuration = time.Second
|
tkService.TokenDuration = time.Hour
|
||||||
|
|
||||||
claims := token.Claims{
|
claims := token.Claims{
|
||||||
StandardClaims: jwt.StandardClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Audience: "remark",
|
Audience: jwt.ClaimStrings{"remark"},
|
||||||
Issuer: "remark",
|
Issuer: "remark",
|
||||||
ExpiresAt: time.Now().Add(time.Second).Unix(),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||||
},
|
},
|
||||||
User: &token.User{
|
User: &token.User{
|
||||||
ID: "dev",
|
ID: "github_dev",
|
||||||
Name: "developer one",
|
Name: "developer one",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -609,7 +881,7 @@ func TestServerAuthHooks(t *testing.T) {
|
|||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
|
|
||||||
// add comment
|
// add comment
|
||||||
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment?site=remark", port),
|
||||||
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/p/2018/12/29/podcast-630/", "site": "remark"}}`))
|
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/p/2018/12/29/podcast-630/", "site": "remark"}}`))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.Header.Set("X-JWT", tk)
|
req.Header.Set("X-JWT", tk)
|
||||||
@@ -618,13 +890,13 @@ func TestServerAuthHooks(t *testing.T) {
|
|||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode, "non-blocked user able to post")
|
assert.Equal(t, http.StatusCreated, resp.StatusCode, "non-blocked user able to post")
|
||||||
|
|
||||||
// add comment with no-aud claim
|
// try to add comment with no-aud claim
|
||||||
claimsNoAud := claims
|
badClaimsNoAud := claims
|
||||||
claimsNoAud.Audience = ""
|
badClaimsNoAud.Audience = jwt.ClaimStrings{""}
|
||||||
tkNoAud, err := tkService.Token(claimsNoAud)
|
tkNoAud, err := tkService.Token(badClaimsNoAud)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Logf("no-aud claims: %s", tkNoAud)
|
t.Logf("no-aud claims: %s", tkNoAud)
|
||||||
req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment?site=remark", port),
|
||||||
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/p/2018/12/29/podcast-631/",
|
strings.NewReader(`{"text": "test 123", "locator":{"url": "https://radio-t.com/p/2018/12/29/podcast-631/",
|
||||||
"site": "remark"}}`))
|
"site": "remark"}}`))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -636,21 +908,58 @@ func TestServerAuthHooks(t *testing.T) {
|
|||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "user without aud claim rejected, \n"+tkNoAud+"\n"+string(body))
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "user without aud claim rejected, \n"+tkNoAud+"\n"+string(body))
|
||||||
|
|
||||||
// block user dev as admin
|
// try to add comment with multiple auds
|
||||||
|
badClaimsMultipleAud := claims
|
||||||
|
badClaimsMultipleAud.Audience = jwt.ClaimStrings{"remark", "second_aud"}
|
||||||
|
tkMultipleAuds, err := tkService.Token(badClaimsMultipleAud)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Logf("multiple aud claims: %s", tkMultipleAuds)
|
||||||
|
req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment?site=remark", port),
|
||||||
|
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", tkMultipleAuds)
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
body, err = io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "user with multiple auds claim rejected, \n"+tkMultipleAuds+"\n"+string(body))
|
||||||
|
|
||||||
|
// try to add comment without user set
|
||||||
|
badClaimsNoUser := claims
|
||||||
|
badClaimsNoUser.Audience = jwt.ClaimStrings{"remark"}
|
||||||
|
badClaimsNoUser.User = nil
|
||||||
|
tkNoUser, err := tkService.Token(badClaimsNoUser)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Logf("no user claims: %s", tkNoUser)
|
||||||
|
req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment?site=remark", port),
|
||||||
|
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", tkNoUser)
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
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 user information rejected, \n"+tkNoUser+"\n"+string(body))
|
||||||
|
|
||||||
|
// block user github_dev as admin
|
||||||
req, err = http.NewRequest(http.MethodPut,
|
req, err = http.NewRequest(http.MethodPut,
|
||||||
fmt.Sprintf("http://localhost:%d/api/v1/admin/user/dev?site=remark&block=1&ttl=10d", port), http.NoBody)
|
fmt.Sprintf("http://localhost:%d/api/v1/admin/user/github_dev?site=remark&block=1&ttl=10d", port), http.NoBody)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
req.SetBasicAuth("admin", "password")
|
req.SetBasicAuth("admin", "password")
|
||||||
resp, err = client.Do(req)
|
resp, err = client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode, "user dev blocked")
|
assert.Equal(t, http.StatusOK, resp.StatusCode, "user github_dev blocked")
|
||||||
b, err := io.ReadAll(resp.Body)
|
b, err := io.ReadAll(resp.Body)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
t.Log(string(b))
|
t.Log(string(b))
|
||||||
|
|
||||||
// try add a comment with blocked user
|
// try add a comment with blocked user
|
||||||
req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment", port),
|
req, err = http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/api/v1/comment?site=remark", port),
|
||||||
strings.NewReader(`{"text": "test 123 blah", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`))
|
strings.NewReader(`{"text": "test 123 blah", "locator":{"url": "https://radio-t.com/blah1", "site": "remark"}}`))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.Header.Set("X-JWT", tk)
|
req.Header.Set("X-JWT", tk)
|
||||||
@@ -659,8 +968,7 @@ func TestServerAuthHooks(t *testing.T) {
|
|||||||
body, err = io.ReadAll(resp.Body)
|
body, err = io.ReadAll(resp.Body)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
assert.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized,
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode, "blocked user can't post, \n"+tk+"\n"+string(body))
|
||||||
"blocked user can't post, \n"+tk+"\n"+string(body))
|
|
||||||
|
|
||||||
cancel()
|
cancel()
|
||||||
app.Wait()
|
app.Wait()
|
||||||
@@ -682,7 +990,6 @@ func TestServerCommand_parseSameSite(t *testing.T) {
|
|||||||
|
|
||||||
cmd := ServerCommand{}
|
cmd := ServerCommand{}
|
||||||
for i, tt := range tbl {
|
for i, tt := range tbl {
|
||||||
tt := tt
|
|
||||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||||
assert.Equal(t, tt.res, cmd.parseSameSite(tt.inp))
|
assert.Equal(t, tt.res, cmd.parseSameSite(tt.inp))
|
||||||
})
|
})
|
||||||
@@ -711,40 +1018,129 @@ func Test_splitAtCommas(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func chooseRandomUnusedPort() (port int) {
|
func Test_getAllowedDomains(t *testing.T) {
|
||||||
for i := 0; i < 10; i++ {
|
tbl := []struct {
|
||||||
port = 40000 + int(rand.Int31n(10000))
|
s ServerCommand
|
||||||
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil {
|
allowedDomains []string
|
||||||
_ = ln.Close()
|
}{
|
||||||
break
|
// correct example, parsed and returned as allowed domain
|
||||||
}
|
{ServerCommand{AllowedHosts: []string{}, CommonOpts: CommonOpts{RemarkURL: "https://remark42.example.org"}}, []string{"example.org"}},
|
||||||
|
{ServerCommand{AllowedHosts: []string{}, CommonOpts: CommonOpts{RemarkURL: "http://remark42.example.org"}}, []string{"example.org"}},
|
||||||
|
{ServerCommand{AllowedHosts: []string{}, CommonOpts: CommonOpts{RemarkURL: "http://localhost"}}, []string{"localhost"}},
|
||||||
|
// incorrect URLs, so Hostname is empty but returned list doesn't include empty string as it would allow any domain
|
||||||
|
{ServerCommand{AllowedHosts: []string{}, CommonOpts: CommonOpts{RemarkURL: "bad hostname"}}, []string{}},
|
||||||
|
{ServerCommand{AllowedHosts: []string{}, CommonOpts: CommonOpts{RemarkURL: "not_a_hostname"}}, []string{}},
|
||||||
|
// test removal of 'self', multiple AllowedHosts. No deduplication is expected
|
||||||
|
{ServerCommand{AllowedHosts: []string{"'self'", "example.org", "test.example.org", "remark42.com"}, CommonOpts: CommonOpts{RemarkURL: "https://example.org"}}, []string{"example.org", "test.example.org", "remark42.com", "example.org"}},
|
||||||
}
|
}
|
||||||
|
for i, tt := range tbl {
|
||||||
|
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.allowedDomains, tt.s.getAllowedDomains())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_getAllowedRedirectHosts(t *testing.T) {
|
||||||
|
tbl := []struct {
|
||||||
|
name string
|
||||||
|
hosts []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{name: "empty", hosts: nil, want: []string{}},
|
||||||
|
{name: "bare hostnames pass through", hosts: []string{"example.com", "admin.example.com"}, want: []string{"example.com", "admin.example.com"}},
|
||||||
|
{name: "https scheme stripped", hosts: []string{"https://example.com"}, want: []string{"example.com"}},
|
||||||
|
{name: "http scheme stripped", hosts: []string{"http://example.com"}, want: []string{"example.com"}},
|
||||||
|
{name: "scheme with path strips path", hosts: []string{"https://example.com/embed"}, want: []string{"example.com"}},
|
||||||
|
{name: "explicit port preserved as host:port", hosts: []string{"example.com:8080"}, want: []string{"example.com:8080"}},
|
||||||
|
{name: "scheme with explicit port preserved", hosts: []string{"https://example.com:8443"}, want: []string{"example.com:8443"}},
|
||||||
|
{name: "scheme without port stays bare host", hosts: []string{"https://example.com"}, want: []string{"example.com"}},
|
||||||
|
{name: "self sentinel filtered", hosts: []string{"'self'", "self", `"self"`, "example.com"}, want: []string{"example.com"}},
|
||||||
|
{name: "wildcards filtered", hosts: []string{"*", "*.example.com", "https://*.example.com", "example.com"}, want: []string{"example.com"}},
|
||||||
|
{name: "empty entries filtered", hosts: []string{"", " ", "example.com"}, want: []string{"example.com"}},
|
||||||
|
{name: "mixed real-world", hosts: []string{"'self'", "https://blog.example.com", "admin.example.com:8443", "*.cdn.example.com"},
|
||||||
|
want: []string{"blog.example.com", "admin.example.com:8443"}},
|
||||||
|
}
|
||||||
|
for _, tt := range tbl {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
s := ServerCommand{AllowedHosts: tt.hosts}
|
||||||
|
assert.Equal(t, tt.want, s.getAllowedRedirectHosts())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// chooseUnusedPort asks the kernel for a free port from the ephemeral range, which makes a
|
||||||
|
// collision between concurrently running package test binaries very unlikely
|
||||||
|
func chooseUnusedPort(t *testing.T) int {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := net.Listen("tcp", ":0")
|
||||||
|
require.NoError(t, err, "no free port available")
|
||||||
|
port := ln.Addr().(*net.TCPAddr).Port
|
||||||
|
require.NoError(t, ln.Close())
|
||||||
return port
|
return port
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForHTTPServerStart(port int) {
|
// waitForHTTPServerStart blocks until the server on port answers, failing the test naming the
|
||||||
// wait for up to 3 seconds for server to start before returning it
|
// port if it never does
|
||||||
|
func waitForHTTPServerStart(t *testing.T, port int) {
|
||||||
|
t.Helper()
|
||||||
client := http.Client{Timeout: time.Second}
|
client := http.Client{Timeout: time.Second}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
for i := 0; i < 300; i++ {
|
require.Eventually(t, func() bool {
|
||||||
time.Sleep(time.Millisecond * 10)
|
resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port))
|
||||||
if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil {
|
if err != nil {
|
||||||
_ = resp.Body.Close()
|
return false
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
return true
|
||||||
|
}, serverStartTimeout, serverStartPoll, "http server on port %d didn't start", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForServerStart blocks until something accepts on every listed port, failing the test
|
||||||
|
// naming the port that never came up
|
||||||
|
func waitForServerStart(t *testing.T, ports ...int) {
|
||||||
|
t.Helper()
|
||||||
|
for _, port := range ports {
|
||||||
|
require.True(t, waitForServerPort(port, serverStartTimeout), "server on port %d didn't start", port)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForHTTPSServerStart(port int) {
|
// getRetryThrottled issues a GET and retries while the auth routes answer 429, since the /auth/
|
||||||
// wait for up to 3 seconds for HTTPS server to start
|
// group is limited to 2 req/s and this test logs in more often than that. a transport error is
|
||||||
for i := 0; i < 300; i++ {
|
// retried a couple of times and then reported as itself, so a dead server is not read as throttling
|
||||||
time.Sleep(time.Millisecond * 10)
|
func getRetryThrottled(t *testing.T, client *http.Client, url string) *http.Response {
|
||||||
conn, _ := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), time.Millisecond*10)
|
t.Helper()
|
||||||
if conn != nil {
|
const transportRetries = 2
|
||||||
_ = conn.Close()
|
errCount := 0
|
||||||
break
|
for deadline := time.Now().Add(serverStartTimeout); time.Now().Before(deadline); time.Sleep(authRetryPoll) {
|
||||||
|
r, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
errCount++
|
||||||
|
require.LessOrEqual(t, errCount, transportRetries, "request to %s failed: %v", url, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
if r.StatusCode == http.StatusTooManyRequests {
|
||||||
|
_ = r.Body.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return r
|
||||||
}
|
}
|
||||||
|
t.Fatalf("request to %s kept being rate limited", url)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForServerPort blocks until something accepts on port, reporting whether it came up.
|
||||||
|
// unlike the require-based helpers it is safe to call off the test goroutine.
|
||||||
|
func waitForServerPort(port int, timeout time.Duration) bool {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), probeDialTimeout)
|
||||||
|
if err == nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
time.Sleep(serverStartPoll)
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serverApp, context.Context, context.CancelFunc) {
|
func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serverApp, context.Context, context.CancelFunc) {
|
||||||
@@ -756,8 +1152,9 @@ func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serve
|
|||||||
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--site=remark"})
|
_, err := p.ParseArgs([]string{"--admin-passwd=password", "--site=remark"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
cmd.Avatar.FS.Path, cmd.Avatar.Type, cmd.BackupLocation, cmd.Image.FS.Path = "/tmp/remark42_test", "fs", "/tmp/remark42_test", "/tmp/remark42_test"
|
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.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.Github.CSEC, cmd.Auth.Github.CID = "csec", "cid"
|
||||||
cmd.Auth.Google.CSEC, cmd.Auth.Google.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.Facebook.CSEC, cmd.Auth.Facebook.CID = "csec", "cid"
|
||||||
@@ -765,6 +1162,7 @@ func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serve
|
|||||||
cmd.Auth.Microsoft.CSEC, cmd.Auth.Microsoft.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.Twitter.CSEC, cmd.Auth.Twitter.CID = "csec", "cid"
|
||||||
cmd.Auth.Patreon.CSEC, cmd.Auth.Patreon.CID = "csec", "cid"
|
cmd.Auth.Patreon.CSEC, cmd.Auth.Patreon.CID = "csec", "cid"
|
||||||
|
cmd.Auth.Discord.CSEC, cmd.Auth.Discord.CID = "csec", "cid"
|
||||||
cmd.Auth.Telegram = true
|
cmd.Auth.Telegram = true
|
||||||
cmd.Telegram.Token = "token"
|
cmd.Telegram.Token = "token"
|
||||||
cmd.Auth.Email.Enable = true
|
cmd.Auth.Email.Enable = true
|
||||||
@@ -785,7 +1183,10 @@ func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serve
|
|||||||
cmd.RestrictedNames = []string{"umputun", "bobuk"}
|
cmd.RestrictedNames = []string{"umputun", "bobuk"}
|
||||||
cmd.emailMsgTemplatePath = "../../templates/email_reply.html.tmpl"
|
cmd.emailMsgTemplatePath = "../../templates/email_reply.html.tmpl"
|
||||||
cmd.emailVerificationTemplatePath = "../../templates/email_confirmation_subscription.html.tmpl"
|
cmd.emailVerificationTemplatePath = "../../templates/email_confirmation_subscription.html.tmpl"
|
||||||
|
|
||||||
cmd = fn(cmd)
|
cmd = fn(cmd)
|
||||||
|
// as is uses port, call it after fn which could set it
|
||||||
|
cmd.Store.Bolt.Path = fmt.Sprintf("/tmp/%d", cmd.Port)
|
||||||
|
|
||||||
app, ctx, cancel := createAppFromCmd(t, cmd)
|
app, ctx, cancel := createAppFromCmd(t, cmd)
|
||||||
|
|
||||||
@@ -802,14 +1203,26 @@ func prepServerApp(t *testing.T, fn func(o ServerCommand) ServerCommand) (*serve
|
|||||||
|
|
||||||
func createAppFromCmd(t *testing.T, cmd ServerCommand) (*serverApp, context.Context, context.CancelFunc) {
|
func createAppFromCmd(t *testing.T, cmd ServerCommand) (*serverApp, context.Context, context.CancelFunc) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
// a require in a readiness wait exits the test goroutine, so without this an app started in
|
||||||
|
// a goroutine would never be stopped and goleak would report it instead of the failure
|
||||||
|
t.Cleanup(cancel)
|
||||||
app, err := cmd.newServerApp(ctx)
|
app, err := cmd.newServerApp(ctx)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
rand.Seed(time.Now().UnixNano())
|
|
||||||
return app, ctx, cancel
|
return app, ctx, cancel
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
// ignore is added only for GitHub Actions, can't reproduce locally
|
// ignore is added only for GitHub Actions, can't reproduce locally
|
||||||
goleak.VerifyTestMain(m, goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"))
|
goleak.VerifyTestMain(
|
||||||
|
m,
|
||||||
|
// the shutdown goroutine in serverApp.run is not joined by Wait, and Rest.Shutdown gives
|
||||||
|
// httpServer.Shutdown a second, which can outlast goleak's retry budget on a loaded runner
|
||||||
|
goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"),
|
||||||
|
// this will be fixed in https://github.com/hashicorp/golang-lru/issues/159
|
||||||
|
goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"),
|
||||||
|
// regexp2, pulled in by chroma for syntax highlighting, keeps one shared clock goroutine
|
||||||
|
// alive for up to a second after the last match with a timeout, sleeping in 100ms ticks.
|
||||||
|
// it ends on its own, but a binary that finishes inside that window is reported as leaking
|
||||||
|
goleak.IgnoreAnyFunction("github.com/dlclark/regexp2/v2.runClock"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+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-----
|
||||||
+8
-9
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -23,7 +24,8 @@ type Opts struct {
|
|||||||
CleanupCmd cmd.CleanupCommand `command:"cleanup"`
|
CleanupCmd cmd.CleanupCommand `command:"cleanup"`
|
||||||
RemapCmd cmd.RemapCommand `command:"remap"`
|
RemapCmd cmd.RemapCommand `command:"remap"`
|
||||||
|
|
||||||
RemarkURL string `long:"url" env:"REMARK_URL" required:"true" description:"url to remark"`
|
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"`
|
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"`
|
Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"`
|
||||||
@@ -54,11 +56,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := p.Parse(); err != nil {
|
if _, err := p.Parse(); err != nil {
|
||||||
if flagsErr, ok := err.(*flags.Error); ok && flagsErr.Type == flags.ErrHelp {
|
var flagsErr *flags.Error
|
||||||
|
if errors.As(err, &flagsErr) && flagsErr.Type == flags.ErrHelp {
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
} else {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,14 +92,11 @@ func logDeprecatedParams(params []cmd.DeprecatedFlag) {
|
|||||||
func getDump() string {
|
func getDump() string {
|
||||||
maxSize := 5 * 1024 * 1024
|
maxSize := 5 * 1024 * 1024
|
||||||
stacktrace := make([]byte, maxSize)
|
stacktrace := make([]byte, maxSize)
|
||||||
length := runtime.Stack(stacktrace, true)
|
length := min(runtime.Stack(stacktrace, true), maxSize)
|
||||||
if length > maxSize {
|
|
||||||
length = maxSize
|
|
||||||
}
|
|
||||||
return string(stacktrace[:length])
|
return string(stacktrace[:length])
|
||||||
}
|
}
|
||||||
|
|
||||||
// nolint:gochecknoinits // can't avoid it in this place
|
//nolint:gochecknoinits // can't avoid it in this place
|
||||||
func init() {
|
func init() {
|
||||||
// catch SIGQUIT and print stack traces
|
// catch SIGQUIT and print stack traces
|
||||||
sigChan := make(chan os.Signal, 1)
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
|||||||
+43
-31
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -25,7 +24,7 @@ func Test_Main(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(dir)
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
port := chooseRandomUnusedPort()
|
port := chooseUnusedPort(t)
|
||||||
os.Args = []string{"test", "server", "--secret=123456", "--store.bolt.path=" + dir, "--backup=/tmp",
|
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", "--notify.type=none"}
|
"--avatar.fs.path=" + dir, "--port=" + strconv.Itoa(port), "--url=https://demo.remark42.com", "--dbg", "--notify.type=none"}
|
||||||
|
|
||||||
@@ -48,7 +47,7 @@ func Test_Main(t *testing.T) {
|
|||||||
<-finished
|
<-finished
|
||||||
}()
|
}()
|
||||||
|
|
||||||
waitForHTTPServerStart(port)
|
waitForHTTPServerStart(t, port)
|
||||||
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/v1/ping", port))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -63,9 +62,9 @@ func TestMain_WithWebhook(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer os.RemoveAll(dir)
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
var webhookSent int32
|
var webhookSent atomic.Int32
|
||||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
ts := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
atomic.StoreInt32(&webhookSent, 1)
|
webhookSent.Store(1)
|
||||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||||
|
|
||||||
b, e := io.ReadAll(r.Body)
|
b, e := io.ReadAll(r.Body)
|
||||||
@@ -76,7 +75,7 @@ func TestMain_WithWebhook(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
|
|
||||||
port := chooseRandomUnusedPort()
|
port := chooseUnusedPort(t)
|
||||||
os.Args = []string{"test", "server", "--secret=123456", "--store.bolt.path=" + dir, "--backup=/tmp",
|
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",
|
"--avatar.fs.path=" + dir, "--port=" + strconv.Itoa(port), "--url=https://demo.remark42.com", "--dbg",
|
||||||
"--admin-passwd=password", "--site=remark", "--notify.admins=webhook"}
|
"--admin-passwd=password", "--site=remark", "--notify.admins=webhook"}
|
||||||
@@ -98,9 +97,6 @@ func TestMain_WithWebhook(t *testing.T) {
|
|||||||
finished := make(chan struct{})
|
finished := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
main()
|
main()
|
||||||
assert.Eventually(t, func() bool {
|
|
||||||
return atomic.LoadInt32(&webhookSent) == int32(1)
|
|
||||||
}, time.Second, 100*time.Millisecond, "webhook was not sent")
|
|
||||||
close(finished)
|
close(finished)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -110,52 +106,68 @@ func TestMain_WithWebhook(t *testing.T) {
|
|||||||
<-finished
|
<-finished
|
||||||
}()
|
}()
|
||||||
|
|
||||||
waitForHTTPServerStart(port)
|
waitForHTTPServerStart(t, port)
|
||||||
|
|
||||||
resp, err := http.Post(fmt.Sprintf("http://admin:password@localhost:%d/api/v1/comment", 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"}}`))
|
strings.NewReader(`{"text": "env test", "locator":{"url": "https://radio-t.com", "site": "remark"}}`))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
|
// wait for webhook to be sent before shutting down
|
||||||
|
assert.Eventually(t, func() bool {
|
||||||
|
return webhookSent.Load() == int32(1)
|
||||||
|
}, 30*time.Second, 10*time.Millisecond, "webhook was not sent")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetDump(t *testing.T) {
|
func TestGetDump(t *testing.T) {
|
||||||
dump := getDump()
|
dump := getDump()
|
||||||
assert.True(t, strings.Contains(dump, "goroutine"))
|
assert.Contains(t, dump, "goroutine")
|
||||||
assert.True(t, strings.Contains(dump, "[running]"))
|
assert.Contains(t, dump, "[running]")
|
||||||
assert.True(t, strings.Contains(dump, "backend/app/main.go"))
|
assert.Contains(t, dump, "backend/app/main.go")
|
||||||
t.Logf("\n dump: %s", dump)
|
t.Logf("\n dump: %s", dump)
|
||||||
}
|
}
|
||||||
|
|
||||||
func chooseRandomUnusedPort() (port int) {
|
// chooseUnusedPort asks the kernel for a free port from the ephemeral range, which makes a
|
||||||
for i := 0; i < 10; i++ {
|
// collision between concurrently running package test binaries very unlikely
|
||||||
port = 40000 + int(rand.Int31n(10000))
|
func chooseUnusedPort(t *testing.T) int {
|
||||||
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil {
|
t.Helper()
|
||||||
_ = ln.Close()
|
ln, err := net.Listen("tcp", ":0")
|
||||||
break
|
require.NoError(t, err, "no free port available")
|
||||||
}
|
port := ln.Addr().(*net.TCPAddr).Port
|
||||||
}
|
require.NoError(t, ln.Close())
|
||||||
return port
|
return port
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForHTTPServerStart(port int) {
|
// waitForHTTPServerStart blocks until the server on port answers, failing the test naming the
|
||||||
// wait for up to 10 seconds for server to start before returning it
|
// port if it never does
|
||||||
|
func waitForHTTPServerStart(t *testing.T, port int) {
|
||||||
|
t.Helper()
|
||||||
client := http.Client{Timeout: time.Second}
|
client := http.Client{Timeout: time.Second}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
for i := 0; i < 100; i++ {
|
require.Eventually(t, func() bool {
|
||||||
time.Sleep(time.Millisecond * 100)
|
resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port))
|
||||||
if resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)); err == nil {
|
if err != nil {
|
||||||
_ = resp.Body.Close()
|
return false
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
_ = resp.Body.Close()
|
||||||
|
return true
|
||||||
|
}, 30*time.Second, 10*time.Millisecond, "http server on port %d didn't start", port)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
// both ignores are for leaks which are detected locally
|
// both ignores are for leaks which are detected locally
|
||||||
goleak.VerifyTestMain(
|
goleak.VerifyTestMain(
|
||||||
m,
|
m,
|
||||||
goleak.IgnoreTopFunction("github.com/umputun/remark42/backend/app.init.0.func1"),
|
// the shutdown goroutine in serverApp.run is not joined by Wait, and Rest.Shutdown gives
|
||||||
|
// httpServer.Shutdown a second, which can outlast goleak's retry budget on a loaded runner
|
||||||
goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"),
|
goleak.IgnoreTopFunction("net/http.(*Server).Shutdown"),
|
||||||
|
goleak.IgnoreTopFunction("github.com/umputun/remark42/backend/app.init.0.func1"),
|
||||||
|
// this will be fixed in https://github.com/hashicorp/golang-lru/issues/159
|
||||||
|
goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"),
|
||||||
|
// regexp2, pulled in by chroma for syntax highlighting, keeps one shared clock goroutine
|
||||||
|
// alive for up to a second after the last match with a timeout, sleeping in 100ms ticks.
|
||||||
|
// it ends on its own, but a binary that finishes inside that window is reported as leaking
|
||||||
|
goleak.IgnoreAnyFunction("github.com/dlclark/regexp2/v2.runClock"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package migrator
|
package migrator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -49,9 +51,7 @@ func TestBackup_MakeBackup(t *testing.T) {
|
|||||||
expFile := fmt.Sprintf("/tmp/remark-backups.test/backup-site1-%s.gz", time.Now().Format("20060102"))
|
expFile := fmt.Sprintf("/tmp/remark-backups.test/backup-site1-%s.gz", time.Now().Format("20060102"))
|
||||||
assert.Equal(t, expFile, fname)
|
assert.Equal(t, expFile, fname)
|
||||||
|
|
||||||
fi, err := os.Lstat(expFile)
|
assert.Equal(t, exportedPayload, gzContent(t, expFile))
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, int64(52), fi.Size())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBackup_Do(t *testing.T) {
|
func TestBackup_Do(t *testing.T) {
|
||||||
@@ -59,24 +59,42 @@ func TestBackup_Do(t *testing.T) {
|
|||||||
defer os.RemoveAll(loc)
|
defer os.RemoveAll(loc)
|
||||||
assert.NoError(t, os.MkdirAll(loc, 0o700))
|
assert.NoError(t, os.MkdirAll(loc, 0o700))
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
synctest.Test(t, func(t *testing.T) {
|
||||||
go func() {
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
time.Sleep(time.Second)
|
go func() {
|
||||||
cancel()
|
time.Sleep(time.Second)
|
||||||
}()
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
bk := AutoBackup{BackupLocation: loc, SiteID: "site1", KeepMax: 3, Exporter: &mockExporter{}, Duration: 600 * time.Millisecond}
|
bk := AutoBackup{BackupLocation: loc, SiteID: "site1", KeepMax: 3, Exporter: &mockExporter{}, Duration: 600 * time.Millisecond}
|
||||||
bk.Do(ctx)
|
bk.Do(ctx)
|
||||||
|
|
||||||
expFile := fmt.Sprintf("/tmp/remark-backups.test/backup-site1-%s.gz", time.Now().Format("20060102"))
|
expFile := fmt.Sprintf("/tmp/remark-backups.test/backup-site1-%s.gz", time.Now().Format("20060102"))
|
||||||
fi, err := os.Lstat(expFile)
|
assert.Equal(t, exportedPayload, gzContent(t, expFile))
|
||||||
assert.NoError(t, err)
|
})
|
||||||
assert.Equal(t, int64(52), fi.Size())
|
}
|
||||||
|
|
||||||
|
const exportedPayload = "some export blah blah 1234567890"
|
||||||
|
|
||||||
|
// the compressed size is not assertable: it moves with the compress/flate version
|
||||||
|
func gzContent(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
fh, err := os.Open(name) //nolint:gosec // path is built by the test
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { assert.NoError(t, fh.Close()) }()
|
||||||
|
|
||||||
|
gz, err := gzip.NewReader(fh)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { assert.NoError(t, gz.Close()) }()
|
||||||
|
|
||||||
|
b, err := io.ReadAll(gz)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
type mockExporter struct{}
|
type mockExporter struct{}
|
||||||
|
|
||||||
func (mock *mockExporter) Export(w io.Writer, _ string) (int, error) {
|
func (mock *mockExporter) Export(w io.Writer, _ string) (int, error) {
|
||||||
_, err := w.Write([]byte("some export blah blah 1234567890"))
|
_, err := w.Write([]byte(exportedPayload))
|
||||||
return 1000, err
|
return 1000, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/umputun/remark42/backend/app/store"
|
"github.com/umputun/remark42/backend/app/store"
|
||||||
@@ -47,7 +48,7 @@ type commentoCommenter struct {
|
|||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
Photo string `json:"photo"`
|
Photo string `json:"photo"`
|
||||||
Provider string `json:"provider,omitempty"`
|
Provider string `json:"provider,omitempty"`
|
||||||
JoinDate time.Time `json:"joinDate,omitempty"`
|
JoinDate time.Time `json:"joinDate"`
|
||||||
IsModerator bool `json:"isModerator"`
|
IsModerator bool `json:"isModerator"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +101,11 @@ func (d *Commento) convert(r io.Reader, siteID string) (ch chan store.Comment) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
usersMap["anonymous"] = store.User{
|
||||||
|
Name: "Anonymous",
|
||||||
|
ID: "commento_" + store.EncodeID("anonymous"),
|
||||||
|
}
|
||||||
|
|
||||||
for _, comment := range exportedData.Comments {
|
for _, comment := range exportedData.Comments {
|
||||||
u, ok := usersMap[comment.CommenterHex]
|
u, ok := usersMap[comment.CommenterHex]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -110,16 +116,28 @@ func (d *Commento) convert(r io.Reader, siteID string) (ch chan store.Comment) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
parentID := comment.ParentHex
|
||||||
|
// comments with ParentHex == "root" are top-level comments
|
||||||
|
if parentID == "root" {
|
||||||
|
parentID = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
commentURL, e := url.JoinPath("https://", comment.Domain, comment.Path)
|
||||||
|
if e != nil {
|
||||||
|
log.Printf("[WARN] can't construct comment URL in commento import, %s", err.Error())
|
||||||
|
}
|
||||||
|
log.Printf("[ERROR] commentoURL: %s", commentURL)
|
||||||
|
|
||||||
c := store.Comment{
|
c := store.Comment{
|
||||||
ID: comment.CommentHex,
|
ID: comment.CommentHex,
|
||||||
Locator: store.Locator{
|
Locator: store.Locator{
|
||||||
URL: comment.Path,
|
URL: commentURL,
|
||||||
SiteID: siteID,
|
SiteID: siteID,
|
||||||
},
|
},
|
||||||
User: u,
|
User: u,
|
||||||
Text: comment.Markdown,
|
Text: comment.Markdown,
|
||||||
Timestamp: comment.CreationDate,
|
Timestamp: comment.CreationDate,
|
||||||
ParentID: comment.ParentHex,
|
ParentID: parentID,
|
||||||
Imported: true,
|
Imported: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,11 @@ func TestCommento_Import(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
size, err := d.Import(fh, "test")
|
size, err := d.Import(fh, "test")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, 2, size)
|
assert.Equal(t, 3, size)
|
||||||
|
|
||||||
last, err := dataStore.Last("test", 10, time.Time{}, adminUser)
|
last, err := dataStore.Last("test", 10, time.Time{}, adminUser)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
require.Equal(t, 2, len(last), "2 comments imported")
|
require.Equal(t, 3, len(last), "3 comments imported")
|
||||||
|
|
||||||
t.Log(last[0])
|
t.Log(last[0])
|
||||||
|
|
||||||
@@ -44,11 +44,24 @@ func TestCommento_Import(t *testing.T) {
|
|||||||
assert.Equal(t, "commento_35369aeb6ac5255de30410a0f86dc71eb9c6d0ca", c.User.ID)
|
assert.Equal(t, "commento_35369aeb6ac5255de30410a0f86dc71eb9c6d0ca", c.User.ID)
|
||||||
assert.True(t, c.Imported)
|
assert.True(t, c.Imported)
|
||||||
|
|
||||||
|
c = last[2] // anonymous comment
|
||||||
|
assert.Equal(t, "Example comment created by user.", c.Text)
|
||||||
|
assert.Equal(t, "e7069a7dfcfaed43caf62300a9b0edb1c124ad79d0f5887c93649c15d7f69945", c.ID)
|
||||||
|
assert.Equal(t, "", c.ParentID)
|
||||||
|
assert.Equal(t, store.Locator{SiteID: "test", URL: "https://example.com/blog/post/2"}, c.Locator)
|
||||||
|
assert.Equal(t, "Anonymous", c.User.Name)
|
||||||
|
assert.Equal(t, "commento_0a92fab3230134cca6eadd9898325b9b2ae67998", c.User.ID)
|
||||||
|
assert.True(t, c.Imported)
|
||||||
|
|
||||||
posts, err := dataStore.List("test", 0, 0)
|
posts, err := dataStore.List("test", 0, 0)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, 1, len(posts), "1 post")
|
assert.Equal(t, 2, len(posts), "2 posts")
|
||||||
|
|
||||||
count, err := dataStore.Count(store.Locator{SiteID: "test", URL: "https://example.com/blog/post/1"})
|
count, err := dataStore.Count(store.Locator{SiteID: "test", URL: "https://example.com/blog/post/1"})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, 2, count)
|
assert.Equal(t, 2, count)
|
||||||
|
|
||||||
|
count, err = dataStore.Count(store.Locator{SiteID: "test", URL: "https://example.com/blog/post/2"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, count)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ func (d *Disqus) convert(r io.Reader, siteID string) (ch chan store.Comment) {
|
|||||||
|
|
||||||
func (*Disqus) cleanText(text string) string {
|
func (*Disqus) cleanText(text string) string {
|
||||||
text = strings.TrimSpace(text)
|
text = strings.TrimSpace(text)
|
||||||
text = strings.Replace(text, "\n", "", -1)
|
text = strings.ReplaceAll(text, "\n", "")
|
||||||
text = strings.Replace(text, "\t", "", -1)
|
text = strings.ReplaceAll(text, "\t", "")
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ func TestDisqus_Convert(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
ch := d.convert(fh, "test")
|
ch := d.convert(fh, "test")
|
||||||
|
|
||||||
res := []store.Comment{}
|
res := make([]store.Comment, 0, 4)
|
||||||
for comment := range ch {
|
for comment := range ch {
|
||||||
res = append(res, comment)
|
res = append(res, comment)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func (u *URLMapper) loadRules(reader io.Reader) error {
|
|||||||
|
|
||||||
u.rules = make(map[string]string)
|
u.rules = make(map[string]string)
|
||||||
|
|
||||||
for _, row := range strings.Split(rulesText, "\n") {
|
for row := range strings.SplitSeq(rulesText, "\n") {
|
||||||
row = strings.TrimSpace(row)
|
row = strings.TrimSpace(row)
|
||||||
urls := strings.Split(row, " ")
|
urls := strings.Split(row, " ")
|
||||||
if len(urls) != 2 {
|
if len(urls) != 2 {
|
||||||
@@ -64,8 +64,8 @@ func (u *URLMapper) URL(url string) string {
|
|||||||
}
|
}
|
||||||
oldURL = strings.TrimSuffix(oldURL, "*")
|
oldURL = strings.TrimSuffix(oldURL, "*")
|
||||||
newURL = strings.TrimSuffix(newURL, "*")
|
newURL = strings.TrimSuffix(newURL, "*")
|
||||||
if strings.HasPrefix(url, oldURL) {
|
if after, ok := strings.CutPrefix(url, oldURL); ok {
|
||||||
return newURL + strings.TrimPrefix(url, oldURL)
|
return newURL + after
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// search failed, return given url
|
// search failed, return given url
|
||||||
|
|||||||
@@ -77,11 +77,11 @@ func TestMigrator_ImportCommento(t *testing.T) {
|
|||||||
Provider: "commento",
|
Provider: "commento",
|
||||||
})
|
})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, 2, size)
|
assert.Equal(t, 3, size)
|
||||||
|
|
||||||
last, err := dataStore.Last("test", 10, time.Time{}, store.User{})
|
last, err := dataStore.Last("test", 10, time.Time{}, store.User{})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, 2, len(last), "2 comments imported")
|
assert.Equal(t, 3, len(last), "3 comments imported")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMigrator_ImportNative(t *testing.T) {
|
func TestMigrator_ImportNative(t *testing.T) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"slices"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
log "github.com/go-pkgz/lgr"
|
log "github.com/go-pkgz/lgr"
|
||||||
@@ -46,8 +47,8 @@ func (n *Native) Export(w io.Writer, siteID string) (size int, err error) {
|
|||||||
|
|
||||||
log.Printf("[DEBUG] exporting %d topics", len(topics))
|
log.Printf("[DEBUG] exporting %d topics", len(topics))
|
||||||
commentsCount := 0
|
commentsCount := 0
|
||||||
for i := len(topics) - 1; i >= 0; i-- { // topics from List sorted in opposite direction
|
for _, topic := range slices.Backward(topics) { // topics from List sorted in opposite direction
|
||||||
topic := topics[i]
|
|
||||||
comments, e := n.DataStore.Find(store.Locator{SiteID: siteID, URL: topic.URL}, "time", adminUser)
|
comments, e := n.DataStore.Find(store.Locator{SiteID: siteID, URL: topic.URL}, "time", adminUser)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return commentsCount, e
|
return commentsCount, e
|
||||||
|
|||||||
@@ -162,8 +162,8 @@ func TestNative_ImportManyWithError(t *testing.T) {
|
|||||||
|
|
||||||
buf := &bytes.Buffer{}
|
buf := &bytes.Buffer{}
|
||||||
buf.WriteString(`{"version":1, "users":[], "posts":[]}` + "\n")
|
buf.WriteString(`{"version":1, "users":[], "posts":[]}` + "\n")
|
||||||
for i := 0; i < 100; i++ {
|
for i := range 100 {
|
||||||
buf.WriteString(fmt.Sprintf(goodRec, i))
|
fmt.Fprintf(buf, goodRec, i)
|
||||||
}
|
}
|
||||||
buf.WriteString("{}\n")
|
buf.WriteString("{}\n")
|
||||||
buf.WriteString("{}\n")
|
buf.WriteString("{}\n")
|
||||||
|
|||||||
+3
-3
@@ -4,7 +4,7 @@
|
|||||||
{
|
{
|
||||||
"commentHex": "e7069a7dfcfaed43caf62300a9b0edb1c124ad79d0f5887c93649c15d7f69945",
|
"commentHex": "e7069a7dfcfaed43caf62300a9b0edb1c124ad79d0f5887c93649c15d7f69945",
|
||||||
"domain": "example.com",
|
"domain": "example.com",
|
||||||
"url": "https://example.com/blog/post/1",
|
"url": "/blog/post/2",
|
||||||
"commenterHex": "anonymous",
|
"commenterHex": "anonymous",
|
||||||
"markdown": "Example comment created by user.",
|
"markdown": "Example comment created by user.",
|
||||||
"html": "",
|
"html": "",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
{
|
{
|
||||||
"commentHex": "7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854",
|
"commentHex": "7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854",
|
||||||
"domain": "example.com",
|
"domain": "example.com",
|
||||||
"url": "https://example.com/blog/post/1",
|
"url": "/blog/post/1",
|
||||||
"commenterHex": "a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
|
"commenterHex": "a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
|
||||||
"markdown": "Example 2 comment created by user.",
|
"markdown": "Example 2 comment created by user.",
|
||||||
"html": "",
|
"html": "",
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
{
|
{
|
||||||
"commentHex": "ea5f7bcd6ac9bb7b657f7d0569831104e1bcf9c253d03c1e16bf9654c49a5ce9",
|
"commentHex": "ea5f7bcd6ac9bb7b657f7d0569831104e1bcf9c253d03c1e16bf9654c49a5ce9",
|
||||||
"domain": "example.com",
|
"domain": "example.com",
|
||||||
"url": "https://example.com/blog/post/1",
|
"url": "/blog/post/1",
|
||||||
"commenterHex": "bd1290ab5c858cf2a05903c2a9a61fd63399c6635db38cc6597002195e22e061",
|
"commenterHex": "bd1290ab5c858cf2a05903c2a9a61fd63399c6635db38cc6597002195e22e061",
|
||||||
"markdown": "Great reply!",
|
"markdown": "Great reply!",
|
||||||
"html": "",
|
"html": "",
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ const wpTimeLayout = "2006-01-02 15:04:05"
|
|||||||
|
|
||||||
// WordPress implements Importer from WP xml
|
// WordPress implements Importer from WP xml
|
||||||
type WordPress struct {
|
type WordPress struct {
|
||||||
DataStore Store
|
DataStore Store
|
||||||
|
DisableFancyTextFormatting bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type wpItem struct {
|
type wpItem struct {
|
||||||
@@ -138,7 +139,7 @@ func (w *WordPress) convert(r io.Reader, siteID string) chan store.Comment {
|
|||||||
ParentID: comment.PID,
|
ParentID: comment.PID,
|
||||||
Imported: true,
|
Imported: true,
|
||||||
}
|
}
|
||||||
commentsCh <- commentFormatter.Format(c)
|
commentsCh <- commentFormatter.Format(c, w.DisableFancyTextFormatting)
|
||||||
stats.inpComments++
|
stats.inpComments++
|
||||||
if stats.inpComments%1000 == 0 {
|
if stats.inpComments%1000 == 0 {
|
||||||
log.Printf("[DEBUG] processed %d comments", stats.inpComments)
|
log.Printf("[DEBUG] processed %d comments", stats.inpComments)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func TestWordPress_Import(t *testing.T) {
|
|||||||
|
|
||||||
dataStore := service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
|
dataStore := service.DataStore{Engine: b, AdminStore: admin.NewStaticStore("12345", nil, []string{}, "")}
|
||||||
defer dataStore.Close()
|
defer dataStore.Close()
|
||||||
wp := WordPress{DataStore: &dataStore}
|
wp := WordPress{DataStore: &dataStore, DisableFancyTextFormatting: false}
|
||||||
size, err := wp.Import(strings.NewReader(xmlTestWP), siteID)
|
size, err := wp.Import(strings.NewReader(xmlTestWP), siteID)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, 3, size)
|
assert.Equal(t, 3, size)
|
||||||
@@ -41,7 +41,7 @@ func TestWordPress_Import(t *testing.T) {
|
|||||||
assert.Equal(t, "e8b1e92bbcf5b9bb88472f9bdb82d1b8c7ed39d6", c.User.IP)
|
assert.Equal(t, "e8b1e92bbcf5b9bb88472f9bdb82d1b8c7ed39d6", c.User.IP)
|
||||||
ts, _ := time.Parse(wpTimeLayout, "2010-08-18 15:19:14")
|
ts, _ := time.Parse(wpTimeLayout, "2010-08-18 15:19:14")
|
||||||
assert.Equal(t, ts, c.Timestamp)
|
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.Equal(t, "<p>«Mekkatorque» was over in that tent up to the right</p>\n", c.Text)
|
||||||
assert.True(t, c.Imported)
|
assert.True(t, c.Imported)
|
||||||
|
|
||||||
posts, err := dataStore.List(siteID, 0, 0)
|
posts, err := dataStore.List(siteID, 0, 0)
|
||||||
@@ -54,13 +54,25 @@ func TestWordPress_Import(t *testing.T) {
|
|||||||
count, err := dataStore.Count(store.Locator{URL: "https://realmenweardress.es/2010/07/do-you-rp/", SiteID: siteID})
|
count, err := dataStore.Count(store.Locator{URL: "https://realmenweardress.es/2010/07/do-you-rp/", SiteID: siteID})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, 3, count)
|
assert.Equal(t, 3, count)
|
||||||
|
|
||||||
|
// test with DisableFancyTextFormatting
|
||||||
|
wp = WordPress{DataStore: &dataStore, DisableFancyTextFormatting: true}
|
||||||
|
size, err = wp.Import(strings.NewReader(xmlTestWP), siteID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 3, size)
|
||||||
|
|
||||||
|
last, err = dataStore.Last(siteID, 10, time.Time{}, adminUser)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
require.Equal(t, 3, len(last), "3 comments imported")
|
||||||
|
|
||||||
|
assert.Equal(t, "<p>"Mekkatorque" was over in that tent up to the right</p>\n", last[0].Text)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWordPress_Convert(t *testing.T) {
|
func TestWordPress_Convert(t *testing.T) {
|
||||||
wp := WordPress{}
|
wp := WordPress{}
|
||||||
ch := wp.convert(strings.NewReader(xmlTestWP), "testWP")
|
ch := wp.convert(strings.NewReader(xmlTestWP), "testWP")
|
||||||
|
|
||||||
comments := []store.Comment{}
|
comments := make([]store.Comment, 0, 3)
|
||||||
for c := range ch {
|
for c := range ch {
|
||||||
comments = append(comments, c)
|
comments = append(comments, c)
|
||||||
}
|
}
|
||||||
@@ -88,7 +100,7 @@ func TestWP_Convert_MD(t *testing.T) {
|
|||||||
wp := WordPress{}
|
wp := WordPress{}
|
||||||
ch := wp.convert(strings.NewReader(xmlTestWPmd), "siteID")
|
ch := wp.convert(strings.NewReader(xmlTestWPmd), "siteID")
|
||||||
|
|
||||||
comments := []store.Comment{}
|
comments := make([]store.Comment, 0, 3)
|
||||||
for c := range ch {
|
for c := range ch {
|
||||||
comments = append(comments, c)
|
comments = append(comments, c)
|
||||||
}
|
}
|
||||||
@@ -247,7 +259,7 @@ var xmlTestWP = `
|
|||||||
<wp:comment_author_IP><![CDATA[128.243.253.117]]></wp:comment_author_IP>
|
<wp:comment_author_IP><![CDATA[128.243.253.117]]></wp:comment_author_IP>
|
||||||
<wp:comment_date><![CDATA[2010-08-18 15:19:14]]></wp:comment_date>
|
<wp:comment_date><![CDATA[2010-08-18 15:19:14]]></wp:comment_date>
|
||||||
<wp:comment_date_gmt><![CDATA[2010-08-18 15:19:14]]></wp:comment_date_gmt>
|
<wp:comment_date_gmt><![CDATA[2010-08-18 15:19:14]]></wp:comment_date_gmt>
|
||||||
<wp:comment_content><![CDATA[Mekkatorque was over in that tent up to the right]]></wp:comment_content>
|
<wp:comment_content><![CDATA["Mekkatorque" was over in that tent up to the right]]></wp:comment_content>
|
||||||
<wp:comment_approved><![CDATA[1]]></wp:comment_approved>
|
<wp:comment_approved><![CDATA[1]]></wp:comment_approved>
|
||||||
<wp:comment_type><![CDATA[]]></wp:comment_type>
|
<wp:comment_type><![CDATA[]]></wp:comment_type>
|
||||||
<wp:comment_parent>13</wp:comment_parent>
|
<wp:comment_parent>13</wp:comment_parent>
|
||||||
|
|||||||
+41
-16
@@ -3,15 +3,16 @@ package notify
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"net/url"
|
"net/url"
|
||||||
"text/template"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/go-pkgz/lgr"
|
log "github.com/go-pkgz/lgr"
|
||||||
ntf "github.com/go-pkgz/notify"
|
ntf "github.com/go-pkgz/notify"
|
||||||
"github.com/go-pkgz/repeater"
|
"github.com/go-pkgz/repeater/v2"
|
||||||
"github.com/hashicorp/go-multierror"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
|
|
||||||
"github.com/umputun/remark42/backend/app/templates"
|
"github.com/umputun/remark42/backend/app/templates"
|
||||||
)
|
)
|
||||||
@@ -26,7 +27,7 @@ type EmailParams struct {
|
|||||||
SubscribeURL string // full subscribe handler URL
|
SubscribeURL string // full subscribe handler URL
|
||||||
UnsubscribeURL string // full unsubscribe handler URL
|
UnsubscribeURL string // full unsubscribe handler URL
|
||||||
|
|
||||||
TokenGenFn func(userID, email, site string) (string, error) // Unsubscribe token generation function
|
TokenGenFn func(userID, email, site string) (string, error) // unsubscribe token generation function
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email implements notify.Destination for email
|
// Email implements notify.Destination for email
|
||||||
@@ -42,12 +43,12 @@ type Email struct {
|
|||||||
type msgTmplData struct {
|
type msgTmplData struct {
|
||||||
UserName string
|
UserName string
|
||||||
UserPicture string
|
UserPicture string
|
||||||
CommentText string
|
CommentText template.HTML
|
||||||
CommentLink string
|
CommentLink string
|
||||||
CommentDate time.Time
|
CommentDate time.Time
|
||||||
ParentUserName string
|
ParentUserName string
|
||||||
ParentUserPicture string
|
ParentUserPicture string
|
||||||
ParentCommentText string
|
ParentCommentText template.HTML
|
||||||
ParentCommentLink string
|
ParentCommentLink string
|
||||||
ParentCommentDate time.Time
|
ParentCommentDate time.Time
|
||||||
PostTitle string
|
PostTitle string
|
||||||
@@ -56,6 +57,30 @@ type msgTmplData struct {
|
|||||||
ForAdmin bool
|
ForAdmin bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emailCommentPolicy sanitizes comment HTML for inclusion in notification emails.
|
||||||
|
// It is intentionally stricter than the store-level UGC policy used for web rendering:
|
||||||
|
// links (<a>) and images (<img>) are dropped so a comment can't smuggle phishing links
|
||||||
|
// or remote tracking pixels into an email sent from the legitimate remark42 address,
|
||||||
|
// while basic inline and block text formatting is preserved.
|
||||||
|
var emailCommentPolicy = func() *bluemonday.Policy {
|
||||||
|
p := bluemonday.NewPolicy()
|
||||||
|
p.AllowElements(
|
||||||
|
"p", "br", "hr", "div", "span",
|
||||||
|
"b", "strong", "i", "em", "u", "s", "strike", "del", "ins", "sub", "sup", "mark", "small",
|
||||||
|
"blockquote", "q", "cite",
|
||||||
|
"code", "pre", "kbd", "samp", "var",
|
||||||
|
"ul", "ol", "li", "dl", "dt", "dd",
|
||||||
|
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
|
)
|
||||||
|
return p
|
||||||
|
}()
|
||||||
|
|
||||||
|
// emailSafeHTML strips links and images from pre-rendered comment HTML and returns
|
||||||
|
// it as template.HTML so html/template renders the remaining safe formatting as-is.
|
||||||
|
func emailSafeHTML(commentHTML string) template.HTML {
|
||||||
|
return template.HTML(emailCommentPolicy.Sanitize(commentHTML)) //nolint:gosec // sanitized above: <a>/<img> dropped, only formatting tags survive
|
||||||
|
}
|
||||||
|
|
||||||
// verifyTmplData store data for verification message template execution
|
// verifyTmplData store data for verification message template execution
|
||||||
type verifyTmplData struct {
|
type verifyTmplData struct {
|
||||||
User string
|
User string
|
||||||
@@ -135,23 +160,23 @@ func (e *Email) Send(ctx context.Context, req Request) error {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
result := new(multierror.Error)
|
var errs []error
|
||||||
|
|
||||||
for _, email := range req.Emails {
|
for _, email := range req.Emails {
|
||||||
err := e.buildAndSendMessage(ctx, req, email, false)
|
err := e.buildAndSendMessage(ctx, req, email, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result = multierror.Append(fmt.Errorf("problem sending user email notification to %q: %w", email, err))
|
errs = append(errs, fmt.Errorf("problem sending user email notification to %q: %w", email, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, email := range e.AdminEmails {
|
for _, email := range e.AdminEmails {
|
||||||
err := e.buildAndSendMessage(ctx, req, email, true)
|
err := e.buildAndSendMessage(ctx, req, email, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result = multierror.Append(fmt.Errorf("problem sending admin email notification to %q: %w", email, err))
|
errs = append(errs, fmt.Errorf("problem sending admin email notification to %q: %w", email, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.ErrorOrNil()
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Email) buildAndSendMessage(ctx context.Context, req Request, email string, forAdmin bool) error {
|
func (e *Email) buildAndSendMessage(ctx context.Context, req Request, email string, forAdmin bool) error {
|
||||||
@@ -161,14 +186,14 @@ func (e *Email) buildAndSendMessage(ctx context.Context, req Request, email stri
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return repeater.NewDefault(5, time.Millisecond*250).Do(
|
return repeater.NewFixed(5, time.Millisecond*250).Do(
|
||||||
ctx,
|
ctx,
|
||||||
func() error {
|
func() error {
|
||||||
return e.Email.Send(
|
return e.Email.Send(
|
||||||
ctx,
|
ctx,
|
||||||
fmt.Sprintf("mailto:%s?from=%s&unsubscribeLink=%s&subject=%s",
|
fmt.Sprintf("mailto:%s?from=%s&unsubscribeLink=%s&subject=%s",
|
||||||
email,
|
email,
|
||||||
e.From,
|
url.QueryEscape(e.From),
|
||||||
url.QueryEscape(msg.unsubscribeLink),
|
url.QueryEscape(msg.unsubscribeLink),
|
||||||
url.QueryEscape(msg.subject),
|
url.QueryEscape(msg.subject),
|
||||||
),
|
),
|
||||||
@@ -196,14 +221,14 @@ func (e *Email) SendVerification(ctx context.Context, req VerificationRequest) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return repeater.NewDefault(5, time.Millisecond*250).Do(
|
return repeater.NewFixed(5, time.Millisecond*250).Do(
|
||||||
ctx,
|
ctx,
|
||||||
func() error {
|
func() error {
|
||||||
return e.Email.Send(
|
return e.Email.Send(
|
||||||
ctx,
|
ctx,
|
||||||
fmt.Sprintf("mailto:%s?from=%s&subject=%s",
|
fmt.Sprintf("mailto:%s?from=%s&subject=%s",
|
||||||
req.Email,
|
req.Email,
|
||||||
e.From,
|
url.QueryEscape(e.From),
|
||||||
url.QueryEscape(e.VerificationSubject),
|
url.QueryEscape(e.VerificationSubject),
|
||||||
),
|
),
|
||||||
msg,
|
msg,
|
||||||
@@ -257,7 +282,7 @@ func (e *Email) buildMessageFromRequest(req Request, email string, forAdmin bool
|
|||||||
tmplData := msgTmplData{
|
tmplData := msgTmplData{
|
||||||
UserName: req.Comment.User.Name,
|
UserName: req.Comment.User.Name,
|
||||||
UserPicture: req.Comment.User.Picture,
|
UserPicture: req.Comment.User.Picture,
|
||||||
CommentText: req.Comment.Text,
|
CommentText: emailSafeHTML(req.Comment.Text),
|
||||||
CommentLink: commentURLPrefix + req.Comment.ID,
|
CommentLink: commentURLPrefix + req.Comment.ID,
|
||||||
CommentDate: req.Comment.Timestamp,
|
CommentDate: req.Comment.Timestamp,
|
||||||
PostTitle: req.Comment.PostTitle,
|
PostTitle: req.Comment.PostTitle,
|
||||||
@@ -269,7 +294,7 @@ func (e *Email) buildMessageFromRequest(req Request, email string, forAdmin bool
|
|||||||
if req.Comment.ParentID != "" {
|
if req.Comment.ParentID != "" {
|
||||||
tmplData.ParentUserName = req.parent.User.Name
|
tmplData.ParentUserName = req.parent.User.Name
|
||||||
tmplData.ParentUserPicture = req.parent.User.Picture
|
tmplData.ParentUserPicture = req.parent.User.Picture
|
||||||
tmplData.ParentCommentText = req.parent.Text
|
tmplData.ParentCommentText = emailSafeHTML(req.parent.Text)
|
||||||
tmplData.ParentCommentLink = commentURLPrefix + req.parent.ID
|
tmplData.ParentCommentLink = commentURLPrefix + req.parent.ID
|
||||||
tmplData.ParentCommentDate = req.parent.Timestamp
|
tmplData.ParentCommentDate = req.parent.Timestamp
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package notify
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"testing"
|
"testing"
|
||||||
"text/template"
|
|
||||||
|
|
||||||
ntf "github.com/go-pkgz/notify"
|
ntf "github.com/go-pkgz/notify"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -34,7 +34,7 @@ func TestEmailNew(t *testing.T) {
|
|||||||
assert.NotNil(t, email, "email returned")
|
assert.NotNil(t, email, "email returned")
|
||||||
|
|
||||||
assert.NotNil(t, email.msgTmpl, "e.template is set")
|
assert.NotNil(t, email.msgTmpl, "e.template is set")
|
||||||
assert.Equal(t, emailParams.From, email.EmailParams.From, "emailParams.From unchanged after creation")
|
assert.Equal(t, emailParams.From, email.From, "emailParams.From unchanged after creation")
|
||||||
if smtpParams.TimeOut == 0 {
|
if smtpParams.TimeOut == 0 {
|
||||||
assert.Equal(t, defaultEmailTimeout, email.TimeOut, "empty emailParams.TimeOut changed to default")
|
assert.Equal(t, defaultEmailTimeout, email.TimeOut, "empty emailParams.TimeOut changed to default")
|
||||||
} else {
|
} else {
|
||||||
@@ -88,7 +88,6 @@ func Test_initTemplatesErr(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, d := range testSet {
|
for _, d := range testSet {
|
||||||
d := d
|
|
||||||
t.Run(d.name, func(t *testing.T) {
|
t.Run(d.name, func(t *testing.T) {
|
||||||
e, err := NewEmail(d.emailParams, ntf.SMTPParams{})
|
e, err := NewEmail(d.emailParams, ntf.SMTPParams{})
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
@@ -111,10 +110,10 @@ func TestEmailSendErrors(t *testing.T) {
|
|||||||
e.msgTmpl, err = template.New("test").Parse("{{.Test}}")
|
e.msgTmpl, err = template.New("test").Parse("{{.Test}}")
|
||||||
assert.NoError(t, err)
|
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"}}),
|
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\": "+
|
"problem sending user email notification to \"bad@example.org\": "+
|
||||||
"error executing template to build comment reply message: "+
|
"error executing template to build comment reply message: "+
|
||||||
"template: test:1:2: executing \"test\" at <.Test>: "+
|
"template: test:1:2: executing \"test\" at <.Test>: "+
|
||||||
"can't evaluate field Test in type notify.msgTmplData\n\n")
|
"can't evaluate field Test in type notify.msgTmplData")
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel()
|
cancel()
|
||||||
@@ -122,8 +121,14 @@ func TestEmailSendErrors(t *testing.T) {
|
|||||||
"sending email messages about comment \"999\" aborted due to canceled context")
|
"sending email messages about comment \"999\" aborted due to canceled context")
|
||||||
|
|
||||||
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"}}),
|
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\":"+
|
"problem sending user email notification to \"bad@example.org\":"+
|
||||||
" error creating token for unsubscribe link: token generation error\n\n")
|
" error creating token for unsubscribe link: token generation error")
|
||||||
|
|
||||||
|
// errors for all failed recipients are reported, not just the last one
|
||||||
|
assert.EqualError(t, e.Send(context.Background(),
|
||||||
|
Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "error"}}, Emails: []string{"bad1@example.org", "bad2@example.org"}}),
|
||||||
|
"problem sending user email notification to \"bad1@example.org\": error creating token for unsubscribe link: token generation error\n"+
|
||||||
|
"problem sending user email notification to \"bad2@example.org\": error creating token for unsubscribe link: token generation error")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEmailSend_ExitConditions(t *testing.T) {
|
func TestEmailSend_ExitConditions(t *testing.T) {
|
||||||
@@ -165,7 +170,7 @@ User: test_user
|
|||||||
01.01.0001 at 00:00
|
01.01.0001 at 00:00
|
||||||
Comment:
|
Comment:
|
||||||
test@example.org for parent_user
|
test@example.org for parent_user
|
||||||
Unsubscribe link: https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token
|
Unsubscribe link: https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token
|
||||||
`, msg.body)
|
`, msg.body)
|
||||||
assert.Equal(t, "https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token", msg.unsubscribeLink)
|
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)
|
assert.Equal(t, `New reply to your comment for "test_title"`, msg.subject)
|
||||||
@@ -191,6 +196,50 @@ admin@example.org
|
|||||||
assert.Empty(t, msg.unsubscribeLink)
|
assert.Empty(t, msg.unsubscribeLink)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEmail_CommentTextSanitizedForEmail(t *testing.T) {
|
||||||
|
// comment HTML reaching the email path is sanitized by the store-level UGC policy,
|
||||||
|
// which permits <a> and <img>. The email must drop both so a comment can't inject
|
||||||
|
// phishing links or remote tracking pixels into a notification (GHSA-74pc-3r2m-ppx3).
|
||||||
|
email, err := NewEmail(EmailParams{
|
||||||
|
From: "from@example.org",
|
||||||
|
MsgTemplatePath: "testdata/msg.html.tmpl",
|
||||||
|
}, ntf.SMTPParams{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
email.TokenGenFn = TokenGenFn
|
||||||
|
|
||||||
|
malicious := `hello <a href="https://phishing.example/verify">click to verify</a>` +
|
||||||
|
` <img src="https://attacker.example/track.png" width="1" height="1"> <b>kept</b>`
|
||||||
|
req := Request{
|
||||||
|
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, PostTitle: "test_title", Text: malicious},
|
||||||
|
Emails: []string{"test@example.org"},
|
||||||
|
}
|
||||||
|
msg, err := email.buildMessageFromRequest(req, req.Emails[0], false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.NotContains(t, msg.body, "phishing.example", "phishing link must be stripped")
|
||||||
|
assert.NotContains(t, msg.body, "attacker.example", "tracking pixel must be stripped")
|
||||||
|
assert.NotContains(t, msg.body, "<img", "no image tags in email body")
|
||||||
|
assert.NotContains(t, msg.body, "<a ", "no anchor tags in email body")
|
||||||
|
assert.Contains(t, msg.body, "click to verify", "anchor text is preserved, only the link is dropped")
|
||||||
|
assert.Contains(t, msg.body, "<b>kept</b>", "basic formatting is preserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
// emailSafeHTML drops links/images while keeping inline/block formatting and escaping nothing extra.
|
||||||
|
func TestEmailSafeHTML(t *testing.T) {
|
||||||
|
tbl := []struct{ name, in, want string }{
|
||||||
|
{"strips anchor keeps text", `<a href="http://evil">x</a>`, "x"},
|
||||||
|
{"strips image entirely", `a<img src="http://evil/t.png">b`, "ab"},
|
||||||
|
{"keeps bold/italic/code", `<b>b</b><i>i</i><code>c</code>`, `<b>b</b><i>i</i><code>c</code>`},
|
||||||
|
{"keeps blockquote and lists", `<blockquote>q</blockquote><ul><li>x</li></ul>`, `<blockquote>q</blockquote><ul><li>x</li></ul>`},
|
||||||
|
{"drops onclick handlers", `<span onclick="alert(1)">s</span>`, `<span>s</span>`},
|
||||||
|
}
|
||||||
|
for _, tt := range tbl {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, string(emailSafeHTML(tt.in)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEmail_SendVerification(t *testing.T) {
|
func TestEmail_SendVerification(t *testing.T) {
|
||||||
email, err := NewEmail(EmailParams{
|
email, err := NewEmail(EmailParams{
|
||||||
From: "from@example.org",
|
From: "from@example.org",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type Service struct {
|
|||||||
queue chan Request
|
queue chan Request
|
||||||
verificationQueue chan VerificationRequest
|
verificationQueue chan VerificationRequest
|
||||||
|
|
||||||
closed uint32 // non-zero means closed. uses uint instead of bool for atomic
|
closed atomic.Uint32 // non-zero means closed. uses uint instead of bool for atomic
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,7 @@ func NewService(dataService Store, size int, destinations ...Destination) *Servi
|
|||||||
|
|
||||||
// Submit Request to internal channel if not busy, drop if can't send
|
// Submit Request to internal channel if not busy, drop if can't send
|
||||||
func (s *Service) Submit(req Request) {
|
func (s *Service) Submit(req Request) {
|
||||||
if len(s.destinations) == 0 || atomic.LoadUint32(&s.closed) != 0 {
|
if len(s.destinations) == 0 || s.closed.Load() != 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.dataService != nil && req.Comment.ParentID != "" {
|
if s.dataService != nil && req.Comment.ParentID != "" {
|
||||||
@@ -130,7 +130,7 @@ func (s *Service) getNotificationTargets(
|
|||||||
|
|
||||||
// SubmitVerification to internal channel if not busy, drop if can't send
|
// SubmitVerification to internal channel if not busy, drop if can't send
|
||||||
func (s *Service) SubmitVerification(req VerificationRequest) {
|
func (s *Service) SubmitVerification(req VerificationRequest) {
|
||||||
if len(s.destinations) == 0 || atomic.LoadUint32(&s.closed) != 0 {
|
if len(s.destinations) == 0 || s.closed.Load() != 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
@@ -143,13 +143,19 @@ func (s *Service) SubmitVerification(req VerificationRequest) {
|
|||||||
// Close queue channel and wait for completion
|
// Close queue channel and wait for completion
|
||||||
func (s *Service) Close() {
|
func (s *Service) Close() {
|
||||||
if s.queue != nil {
|
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")
|
log.Print("[DEBUG] close notifier")
|
||||||
close(s.queue)
|
close(s.queue)
|
||||||
close(s.verificationQueue)
|
close(s.verificationQueue)
|
||||||
s.cancel()
|
s.cancel()
|
||||||
<-s.ctx.Done()
|
<-s.ctx.Done()
|
||||||
}
|
}
|
||||||
atomic.StoreUint32(&s.closed, 1)
|
s.closed.Store(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) do() {
|
func (s *Service) do() {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
log "github.com/go-pkgz/lgr"
|
log "github.com/go-pkgz/lgr"
|
||||||
)
|
)
|
||||||
@@ -16,35 +15,40 @@ type MockDest struct {
|
|||||||
id int
|
id int
|
||||||
closed bool
|
closed bool
|
||||||
lock sync.Mutex
|
lock sync.Mutex
|
||||||
|
block chan struct{} // if non-nil, Send/SendVerification wait on it before recording, letting tests pin the consumer
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send mock
|
// Send mock
|
||||||
func (m *MockDest) Send(ctx context.Context, r Request) error {
|
func (m *MockDest) Send(ctx context.Context, r Request) error {
|
||||||
|
if m.block != nil {
|
||||||
|
<-m.block
|
||||||
|
}
|
||||||
m.lock.Lock()
|
m.lock.Lock()
|
||||||
defer m.lock.Unlock()
|
defer m.lock.Unlock()
|
||||||
select {
|
if err := ctx.Err(); err != nil {
|
||||||
case <-time.After(10 * time.Millisecond):
|
|
||||||
m.data = append(m.data, r)
|
|
||||||
log.Printf("sent %s -> %d", r.Comment.ID, m.id)
|
|
||||||
case <-ctx.Done():
|
|
||||||
log.Printf("ctx closed %d", m.id)
|
log.Printf("ctx closed %d", m.id)
|
||||||
m.closed = true
|
m.closed = true
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
m.data = append(m.data, r)
|
||||||
|
log.Printf("sent %s -> %d", r.Comment.ID, m.id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendVerification mock
|
// SendVerification mock
|
||||||
func (m *MockDest) SendVerification(ctx context.Context, v VerificationRequest) error {
|
func (m *MockDest) SendVerification(ctx context.Context, v VerificationRequest) error {
|
||||||
|
if m.block != nil {
|
||||||
|
<-m.block
|
||||||
|
}
|
||||||
m.lock.Lock()
|
m.lock.Lock()
|
||||||
defer m.lock.Unlock()
|
defer m.lock.Unlock()
|
||||||
select {
|
if err := ctx.Err(); err != nil {
|
||||||
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)
|
log.Printf("verification ctx closed %d", m.id)
|
||||||
m.closed = true
|
m.closed = true
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
m.verificationData = append(m.verificationData, v)
|
||||||
|
log.Printf("sent verification %s -> %d", v.User, m.id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,4 +70,15 @@ func (m *MockDest) GetVerify() []VerificationRequest {
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockDest) String() string { return fmt.Sprintf("mock id=%d, closed=%v", m.id, m.closed) }
|
// IsClosed returns closed status safely
|
||||||
|
func (m *MockDest) IsClosed() bool {
|
||||||
|
m.lock.Lock()
|
||||||
|
defer m.lock.Unlock()
|
||||||
|
return m.closed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockDest) String() string {
|
||||||
|
m.lock.Lock()
|
||||||
|
defer m.lock.Unlock()
|
||||||
|
return fmt.Sprintf("mock id=%d, closed=%v", m.id, m.closed)
|
||||||
|
}
|
||||||
|
|||||||
+215
-190
@@ -2,10 +2,8 @@ package notify
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"testing/synctest"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -21,258 +19,285 @@ func TestService_NoDestinations(t *testing.T) {
|
|||||||
s.Submit(Request{Comment: store.Comment{ID: "123"}})
|
s.Submit(Request{Comment: store.Comment{ID: "123"}})
|
||||||
s.Submit(Request{Comment: store.Comment{ID: "123"}})
|
s.Submit(Request{Comment: store.Comment{ID: "123"}})
|
||||||
s.Close()
|
s.Close()
|
||||||
|
// second call should not result in panic
|
||||||
|
s.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_WithDestinations(t *testing.T) {
|
func TestService_WithDestinations(t *testing.T) {
|
||||||
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
synctest.Test(t, func(t *testing.T) {
|
||||||
s := NewService(nil, 1, d1, d2)
|
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
||||||
assert.NotNil(t, s)
|
s := NewService(nil, 1, d1, d2)
|
||||||
|
assert.NotNil(t, s)
|
||||||
|
|
||||||
s.Submit(Request{Comment: store.Comment{ID: "100"}})
|
s.Submit(Request{Comment: store.Comment{ID: "100"}})
|
||||||
time.Sleep(time.Millisecond * 110)
|
synctest.Wait()
|
||||||
s.Submit(Request{Comment: store.Comment{ID: "101"}})
|
s.Submit(Request{Comment: store.Comment{ID: "101"}})
|
||||||
time.Sleep(time.Millisecond * 110)
|
synctest.Wait()
|
||||||
s.Submit(Request{Comment: store.Comment{ID: "102"}})
|
s.Submit(Request{Comment: store.Comment{ID: "102"}})
|
||||||
time.Sleep(time.Millisecond * 110)
|
synctest.Wait()
|
||||||
s.Close()
|
s.Close()
|
||||||
|
|
||||||
require.Equal(t, 3, len(d1.Get()), "got all comments to d1")
|
require.Equal(t, 3, len(d1.Get()), "got all comments to d1")
|
||||||
require.Equal(t, 3, len(d2.Get()), "got all comments to d2")
|
require.Equal(t, 3, len(d2.Get()), "got all comments to d2")
|
||||||
|
|
||||||
assert.Equal(t, "100", d1.Get()[0].Comment.ID)
|
assert.Equal(t, "100", d1.Get()[0].Comment.ID)
|
||||||
assert.Equal(t, "101", d1.Get()[1].Comment.ID)
|
assert.Equal(t, "101", d1.Get()[1].Comment.ID)
|
||||||
assert.Equal(t, "102", d1.Get()[2].Comment.ID)
|
assert.Equal(t, "102", d1.Get()[2].Comment.ID)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_WithDrops(t *testing.T) {
|
func TestService_WithDrops(t *testing.T) {
|
||||||
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
synctest.Test(t, func(t *testing.T) {
|
||||||
s := NewService(nil, 1, d1, d2)
|
// gated destinations pin the consumer on the first item so the size-1 queue
|
||||||
assert.NotNil(t, s)
|
// fills deterministically and the overflow is dropped regardless of scheduling
|
||||||
|
gate := make(chan struct{})
|
||||||
|
d1, d2 := &MockDest{id: 1, block: gate}, &MockDest{id: 2, block: gate}
|
||||||
|
s := NewService(nil, 1, d1, d2)
|
||||||
|
assert.NotNil(t, s)
|
||||||
|
|
||||||
s.Submit(Request{Comment: store.Comment{ID: "100"}})
|
s.Submit(Request{Comment: store.Comment{ID: "100"}}) // consumed, consumer blocks in Send on the gate
|
||||||
s.Submit(Request{Comment: store.Comment{ID: "101"}})
|
synctest.Wait()
|
||||||
s.Submit(Request{Comment: store.Comment{ID: "102"}})
|
s.Submit(Request{Comment: store.Comment{ID: "101"}}) // fills the size-1 queue
|
||||||
time.Sleep(time.Millisecond * 21)
|
s.Submit(Request{Comment: store.Comment{ID: "102"}}) // queue full, dropped
|
||||||
s.Close()
|
synctest.Wait()
|
||||||
|
|
||||||
s.Submit(Request{Comment: store.Comment{ID: "111"}}) // safe to send after close
|
close(gate) // release the consumer: it finishes 100 then processes 101
|
||||||
|
synctest.Wait()
|
||||||
|
s.Close()
|
||||||
|
|
||||||
assert.LessOrEqual(t, len(d1.Get()), 2, "at least one comment from three dropped from d1, got: %v", d1.Get())
|
s.Submit(Request{Comment: store.Comment{ID: "111"}}) // safe to send after close
|
||||||
assert.LessOrEqual(t, len(d2.Get()), 2, "at least one comment from three dropped from d2, got: %v", d2.Get())
|
|
||||||
|
require.Len(t, d1.Get(), 2, "one comment of three dropped from d1, got: %v", d1.Get())
|
||||||
|
require.Len(t, d2.Get(), 2, "one comment of three dropped from d2, got: %v", d2.Get())
|
||||||
|
assert.Equal(t, "100", d1.Get()[0].Comment.ID)
|
||||||
|
assert.Equal(t, "101", d1.Get()[1].Comment.ID)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_SubmitVerificationWithDrops(t *testing.T) {
|
func TestService_SubmitVerificationWithDrops(t *testing.T) {
|
||||||
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
synctest.Test(t, func(t *testing.T) {
|
||||||
s := NewService(nil, 1, d1, d2)
|
// gated destinations pin the consumer on the first item so the size-1 queue
|
||||||
assert.NotNil(t, s)
|
// fills deterministically and the overflow is dropped regardless of scheduling
|
||||||
|
gate := make(chan struct{})
|
||||||
|
d1, d2 := &MockDest{id: 1, block: gate}, &MockDest{id: 2, block: gate}
|
||||||
|
s := NewService(nil, 1, d1, d2)
|
||||||
|
assert.NotNil(t, s)
|
||||||
|
|
||||||
s.SubmitVerification(VerificationRequest{
|
s.SubmitVerification(VerificationRequest{
|
||||||
SiteID: "remark",
|
SiteID: "remark",
|
||||||
User: "testUser",
|
User: "testUser",
|
||||||
Email: "test@example.org",
|
Email: "test@example.org",
|
||||||
Token: "testToken",
|
Token: "testToken",
|
||||||
|
}) // consumed, consumer blocks in SendVerification on the gate
|
||||||
|
synctest.Wait()
|
||||||
|
s.SubmitVerification(VerificationRequest{User: "second"}) // fills the size-1 queue
|
||||||
|
s.SubmitVerification(VerificationRequest{User: "dropped"}) // queue full, dropped
|
||||||
|
synctest.Wait()
|
||||||
|
|
||||||
|
close(gate) // release the consumer: it finishes testUser then processes second
|
||||||
|
synctest.Wait()
|
||||||
|
s.Close()
|
||||||
|
|
||||||
|
s.SubmitVerification(VerificationRequest{}) // safe to send after close
|
||||||
|
|
||||||
|
require.Len(t, d2.GetVerify(), 2, "one request of three dropped from d2, got: %v", d2.GetVerify())
|
||||||
|
|
||||||
|
verifyDest := d1.GetVerify()
|
||||||
|
require.Len(t, verifyDest, 2, "one request of 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)
|
||||||
|
assert.Equal(t, "second", verifyDest[1].User)
|
||||||
})
|
})
|
||||||
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) {
|
func TestService_Many(t *testing.T) {
|
||||||
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
synctest.Test(t, func(t *testing.T) {
|
||||||
s := NewService(nil, 5, d1, d2)
|
d1, d2 := &MockDest{id: 1}, &MockDest{id: 2}
|
||||||
assert.NotNil(t, s)
|
s := NewService(nil, 5, d1, d2)
|
||||||
|
assert.NotNil(t, s)
|
||||||
|
|
||||||
for i := 0; i < 10; i++ {
|
for i := range 10 {
|
||||||
s.Submit(Request{Comment: store.Comment{ID: fmt.Sprintf("%d", 100+i)}})
|
s.Submit(Request{Comment: store.Comment{ID: fmt.Sprintf("%d", 100+i)}})
|
||||||
s.SubmitVerification(VerificationRequest{User: 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()
|
||||||
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.Get()), "some comments dropped from d1")
|
||||||
assert.NotEqual(t, 10, len(d1.GetVerify()), "some verifications 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.Get()), "some comments dropped from d2")
|
||||||
assert.NotEqual(t, 10, len(d2.GetVerify()), "some verifications 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) {
|
func TestService_WithParent(t *testing.T) {
|
||||||
dest := &MockDest{id: 1}
|
synctest.Test(t, func(t *testing.T) {
|
||||||
dataStore := &mockStore{data: map[string]store.Comment{}}
|
dest := &MockDest{id: 1}
|
||||||
|
dataStore := &mockStore{data: map[string]store.Comment{}}
|
||||||
|
|
||||||
dataStore.data["p1"] = store.Comment{ID: "p1"}
|
dataStore.data["p1"] = store.Comment{ID: "p1"}
|
||||||
dataStore.data["p2"] = store.Comment{ID: "p2"}
|
dataStore.data["p2"] = store.Comment{ID: "p2"}
|
||||||
|
|
||||||
s := NewService(dataStore, 1, dest)
|
s := NewService(dataStore, 1, dest)
|
||||||
assert.NotNil(t, s)
|
assert.NotNil(t, s)
|
||||||
|
|
||||||
s.Submit(Request{Comment: store.Comment{ID: "c1", ParentID: "p1"}})
|
s.Submit(Request{Comment: store.Comment{ID: "c1", ParentID: "p1"}})
|
||||||
time.Sleep(time.Millisecond * 110)
|
synctest.Wait()
|
||||||
s.Submit(Request{Comment: store.Comment{ID: "c11", ParentID: "p11"}})
|
s.Submit(Request{Comment: store.Comment{ID: "c11", ParentID: "p11"}})
|
||||||
time.Sleep(time.Millisecond * 110)
|
synctest.Wait()
|
||||||
s.Close()
|
s.Close()
|
||||||
|
|
||||||
destRes := dest.Get()
|
destRes := dest.Get()
|
||||||
require.Equal(t, 2, len(destRes), "two comment notified")
|
require.Equal(t, 2, len(destRes), "two comment notified")
|
||||||
assert.Equal(t, "p1", destRes[0].Comment.ParentID)
|
assert.Equal(t, "p1", destRes[0].Comment.ParentID)
|
||||||
assert.Equal(t, "p1", destRes[0].parent.ID)
|
assert.Equal(t, "p1", destRes[0].parent.ID)
|
||||||
assert.Equal(t, "p11", destRes[1].Comment.ParentID)
|
assert.Equal(t, "p11", destRes[1].Comment.ParentID)
|
||||||
assert.Equal(t, "", destRes[1].parent.ID)
|
assert.Equal(t, "", destRes[1].parent.ID)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_EmailRetrieval(t *testing.T) {
|
func TestService_EmailRetrieval(t *testing.T) {
|
||||||
dest := &MockDest{id: 1}
|
synctest.Test(t, func(t *testing.T) {
|
||||||
dataStore := &mockStore{data: map[string]store.Comment{}, userDetails: map[string]string{}}
|
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["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["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["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.data["p4"] = store.Comment{ID: "p4", ParentID: "p3", User: store.User{ID: "u1"}}
|
||||||
dataStore.userDetails["u1"] = "u1@example.com"
|
dataStore.userDetails["u1"] = "u1@example.com"
|
||||||
|
|
||||||
s := NewService(dataStore, 1, dest)
|
s := NewService(dataStore, 1, dest)
|
||||||
assert.NotNil(t, s)
|
assert.NotNil(t, s)
|
||||||
|
|
||||||
// one comment, one notification
|
// one comment, one notification
|
||||||
s.Submit(Request{Comment: dataStore.data["p1"]})
|
s.Submit(Request{Comment: dataStore.data["p1"]})
|
||||||
time.Sleep(time.Millisecond * 110)
|
synctest.Wait()
|
||||||
|
|
||||||
destRes := dest.Get()
|
destRes := dest.Get()
|
||||||
require.Equal(t, 1, len(destRes), "one comment notified")
|
require.Equal(t, 1, len(destRes), "one comment notified")
|
||||||
assert.Equal(t, "p1", destRes[0].Comment.ID)
|
assert.Equal(t, "p1", destRes[0].Comment.ID)
|
||||||
assert.Empty(t, destRes[0].parent)
|
assert.Empty(t, destRes[0].parent)
|
||||||
assert.Empty(t, destRes[0].Emails)
|
assert.Empty(t, destRes[0].Emails)
|
||||||
|
|
||||||
// reply to the first comment, same comment as one in original comment
|
// reply to the first comment, same comment as one in original comment
|
||||||
s.Submit(Request{Comment: dataStore.data["p2"]})
|
s.Submit(Request{Comment: dataStore.data["p2"]})
|
||||||
time.Sleep(time.Millisecond * 110)
|
synctest.Wait()
|
||||||
|
|
||||||
destRes = dest.Get()
|
destRes = dest.Get()
|
||||||
require.Equal(t, 2, len(destRes), "two comment notified")
|
require.Equal(t, 2, len(destRes), "two comment notified")
|
||||||
assert.Equal(t, "p2", destRes[1].Comment.ID)
|
assert.Equal(t, "p2", destRes[1].Comment.ID)
|
||||||
assert.Equal(t, "p1", destRes[1].parent.ID)
|
assert.Equal(t, "p1", destRes[1].parent.ID)
|
||||||
assert.Equal(t, "u1", destRes[1].parent.User.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")
|
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
|
// another reply to the first comment, another user
|
||||||
s.Submit(Request{Comment: dataStore.data["p3"]})
|
s.Submit(Request{Comment: dataStore.data["p3"]})
|
||||||
time.Sleep(time.Millisecond * 110)
|
synctest.Wait()
|
||||||
|
|
||||||
destRes = dest.Get()
|
destRes = dest.Get()
|
||||||
require.Equal(t, 3, len(destRes), "three comment notified")
|
require.Equal(t, 3, len(destRes), "three comment notified")
|
||||||
assert.Equal(t, "p3", destRes[2].Comment.ID)
|
assert.Equal(t, "p3", destRes[2].Comment.ID)
|
||||||
assert.Equal(t, "p1", destRes[2].parent.ID)
|
assert.Equal(t, "p1", destRes[2].parent.ID)
|
||||||
assert.Equal(t, "u1", destRes[2].parent.User.ID)
|
assert.Equal(t, "u1", destRes[2].parent.User.ID)
|
||||||
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[2].Emails)
|
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[2].Emails)
|
||||||
|
|
||||||
// reply to the last comment by another user, should trigger email retrieval error
|
// reply to the last comment by another user, should trigger email retrieval error
|
||||||
s.Submit(Request{Comment: dataStore.data["p4"]})
|
s.Submit(Request{Comment: dataStore.data["p4"]})
|
||||||
time.Sleep(time.Millisecond * 110)
|
synctest.Wait()
|
||||||
|
|
||||||
destRes = dest.Get()
|
destRes = dest.Get()
|
||||||
require.Equal(t, 4, len(destRes), "four comment notified")
|
require.Equal(t, 4, len(destRes), "four comment notified")
|
||||||
assert.Equal(t, "p4", destRes[3].Comment.ID)
|
assert.Equal(t, "p4", destRes[3].Comment.ID)
|
||||||
assert.Equal(t, "p3", destRes[3].parent.ID)
|
assert.Equal(t, "p3", destRes[3].parent.ID)
|
||||||
assert.Equal(t, "u2", destRes[3].parent.User.ID)
|
assert.Equal(t, "u2", destRes[3].parent.User.ID)
|
||||||
assert.Empty(t, destRes[3].Emails, "no email can be retrieved for u2")
|
assert.Empty(t, destRes[3].Emails, "no email can be retrieved for u2")
|
||||||
|
|
||||||
s.Close()
|
s.Close()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Recursive(t *testing.T) {
|
func TestService_Recursive(t *testing.T) {
|
||||||
dest := &MockDest{id: 1}
|
synctest.Test(t, func(t *testing.T) {
|
||||||
dataStore := &mockStore{data: map[string]store.Comment{}, userDetails: map[string]string{}}
|
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["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["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["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["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.data["p5"] = store.Comment{ID: "p5", ParentID: "p4", User: store.User{ID: "u4"}}
|
||||||
dataStore.userDetails["u1"] = "u1@example.com"
|
dataStore.userDetails["u1"] = "u1@example.com"
|
||||||
// second comment goes without email address for notification
|
// second comment goes without email address for notification
|
||||||
dataStore.userDetails["u3"] = "u3@example.com"
|
dataStore.userDetails["u3"] = "u3@example.com"
|
||||||
|
|
||||||
s := NewService(dataStore, 1, dest)
|
s := NewService(dataStore, 1, dest)
|
||||||
assert.NotNil(t, s)
|
assert.NotNil(t, s)
|
||||||
|
|
||||||
// one comment from u1 with email set
|
// one comment from u1 with email set
|
||||||
s.Submit(Request{Comment: dataStore.data["p1"]})
|
s.Submit(Request{Comment: dataStore.data["p1"]})
|
||||||
time.Sleep(time.Millisecond * 110)
|
synctest.Wait()
|
||||||
|
|
||||||
destRes := dest.Get()
|
destRes := dest.Get()
|
||||||
require.Equal(t, 1, len(destRes), "one comment notified")
|
require.Equal(t, 1, len(destRes), "one comment notified")
|
||||||
assert.Equal(t, "p1", destRes[0].Comment.ID)
|
assert.Equal(t, "p1", destRes[0].Comment.ID)
|
||||||
assert.Empty(t, destRes[0].parent)
|
assert.Empty(t, destRes[0].parent)
|
||||||
assert.Empty(t, destRes[0].Emails)
|
assert.Empty(t, destRes[0].Emails)
|
||||||
|
|
||||||
// reply to the first comment from u2 without email set
|
// reply to the first comment from u2 without email set
|
||||||
s.Submit(Request{Comment: dataStore.data["p2"]})
|
s.Submit(Request{Comment: dataStore.data["p2"]})
|
||||||
time.Sleep(time.Millisecond * 110)
|
synctest.Wait()
|
||||||
|
|
||||||
destRes = dest.Get()
|
destRes = dest.Get()
|
||||||
require.Equal(t, 2, len(destRes), "two comment notified")
|
require.Equal(t, 2, len(destRes), "two comment notified")
|
||||||
assert.Equal(t, "p2", destRes[1].Comment.ID)
|
assert.Equal(t, "p2", destRes[1].Comment.ID)
|
||||||
assert.Equal(t, "p1", destRes[1].parent.ID)
|
assert.Equal(t, "p1", destRes[1].parent.ID)
|
||||||
assert.Equal(t, "u1", destRes[1].parent.User.ID)
|
assert.Equal(t, "u1", destRes[1].parent.User.ID)
|
||||||
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[1].Emails)
|
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[1].Emails)
|
||||||
|
|
||||||
// reply to the second comment from u3 with email set
|
// reply to the second comment from u3 with email set
|
||||||
s.Submit(Request{Comment: dataStore.data["p3"]})
|
s.Submit(Request{Comment: dataStore.data["p3"]})
|
||||||
time.Sleep(time.Millisecond * 110)
|
synctest.Wait()
|
||||||
|
|
||||||
destRes = dest.Get()
|
destRes = dest.Get()
|
||||||
require.Equal(t, 3, len(destRes), "three comment notified")
|
require.Equal(t, 3, len(destRes), "three comment notified")
|
||||||
assert.Equal(t, "p3", destRes[2].Comment.ID)
|
assert.Equal(t, "p3", destRes[2].Comment.ID)
|
||||||
assert.Equal(t, "p2", destRes[2].parent.ID)
|
assert.Equal(t, "p2", destRes[2].parent.ID)
|
||||||
assert.Equal(t, "u2", destRes[2].parent.User.ID)
|
assert.Equal(t, "u2", destRes[2].parent.User.ID)
|
||||||
assert.ElementsMatch(t, []string{"u1@example.com"}, destRes[2].Emails)
|
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
|
// reply to the third comment from u1 (author of the first comment), only u3 should be notified
|
||||||
s.Submit(Request{Comment: dataStore.data["p4"]})
|
s.Submit(Request{Comment: dataStore.data["p4"]})
|
||||||
time.Sleep(time.Millisecond * 110)
|
synctest.Wait()
|
||||||
|
|
||||||
destRes = dest.Get()
|
destRes = dest.Get()
|
||||||
require.Equal(t, 4, len(destRes), "four comment notified once each")
|
require.Equal(t, 4, len(destRes), "four comment notified once each")
|
||||||
assert.Equal(t, "p4", destRes[3].Comment.ID)
|
assert.Equal(t, "p4", destRes[3].Comment.ID)
|
||||||
assert.Equal(t, "p3", destRes[3].parent.ID)
|
assert.Equal(t, "p3", destRes[3].parent.ID)
|
||||||
assert.Equal(t, "u3", destRes[3].parent.User.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")
|
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
|
// reply to the fourth comment from u4, u1 and u3 should be notified once as a result
|
||||||
s.Submit(Request{Comment: dataStore.data["p5"]})
|
s.Submit(Request{Comment: dataStore.data["p5"]})
|
||||||
time.Sleep(time.Millisecond * 110)
|
synctest.Wait()
|
||||||
|
|
||||||
destRes = dest.Get()
|
destRes = dest.Get()
|
||||||
require.Equal(t, 5, len(destRes), "four comment notified once each")
|
require.Equal(t, 5, len(destRes), "four comment notified once each")
|
||||||
assert.Equal(t, "p5", destRes[4].Comment.ID)
|
assert.Equal(t, "p5", destRes[4].Comment.ID)
|
||||||
assert.Equal(t, "p4", destRes[4].parent.ID)
|
assert.Equal(t, "p4", destRes[4].parent.ID)
|
||||||
assert.Equal(t, "u1", destRes[4].parent.User.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")
|
assert.ElementsMatch(t, []string{"u1@example.com", "u3@example.com"}, destRes[4].Emails, "u3 and u1 notified once")
|
||||||
|
|
||||||
s.Close()
|
s.Close()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestService_Nop(t *testing.T) {
|
func TestService_Nop(t *testing.T) {
|
||||||
s := NopService
|
s := NopService
|
||||||
s.Submit(Request{Comment: store.Comment{}})
|
s.Submit(Request{Comment: store.Comment{}})
|
||||||
s.Close()
|
s.Close()
|
||||||
assert.Equal(t, uint32(1), atomic.LoadUint32(&s.closed))
|
assert.Equal(t, uint32(1), s.closed.Load())
|
||||||
}
|
}
|
||||||
|
|
||||||
type mockStore struct {
|
type mockStore struct {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/net/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pruneHTML prunes string keeping HTML closing tags.
|
||||||
|
// maxLength applies to visible text only, not HTML tags.
|
||||||
|
func pruneHTML(htmlText string, maxLength int) string {
|
||||||
|
var result strings.Builder
|
||||||
|
var endTokens []string
|
||||||
|
visibleLen := 0
|
||||||
|
|
||||||
|
suffix := "..."
|
||||||
|
suffixLen := len(suffix)
|
||||||
|
|
||||||
|
tokenizer := html.NewTokenizer(strings.NewReader(htmlText))
|
||||||
|
for {
|
||||||
|
if tokenizer.Next() == html.ErrorToken {
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
token := tokenizer.Token()
|
||||||
|
|
||||||
|
switch token.Type {
|
||||||
|
case html.CommentToken, html.DoctypeToken:
|
||||||
|
continue
|
||||||
|
|
||||||
|
case html.StartTagToken:
|
||||||
|
endTokens = append([]string{fmt.Sprintf("</%s>", token.Data)}, endTokens...)
|
||||||
|
result.WriteString(token.String())
|
||||||
|
|
||||||
|
case html.EndTagToken:
|
||||||
|
if len(endTokens) > 0 {
|
||||||
|
endTokens = endTokens[1:]
|
||||||
|
}
|
||||||
|
result.WriteString(token.String())
|
||||||
|
|
||||||
|
case html.SelfClosingTagToken:
|
||||||
|
result.WriteString(token.String())
|
||||||
|
|
||||||
|
case html.TextToken:
|
||||||
|
text := token.String()
|
||||||
|
if visibleLen+len(text)+suffixLen > maxLength {
|
||||||
|
remaining := maxLength - visibleLen - suffixLen
|
||||||
|
text = pruneStringToWord(text, remaining)
|
||||||
|
result.WriteString(text)
|
||||||
|
result.WriteString(suffix)
|
||||||
|
for _, endTag := range endTokens {
|
||||||
|
result.WriteString(endTag)
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
visibleLen += len(text)
|
||||||
|
result.WriteString(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneStringToWord prunes string to specified length respecting word boundaries
|
||||||
|
func pruneStringToWord(text string, maxLength int) string {
|
||||||
|
if maxLength <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(text) <= maxLength {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// find last space at or before maxLength to cut at word boundary
|
||||||
|
lastSpace := strings.LastIndex(text[:maxLength+1], " ")
|
||||||
|
if lastSpace <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return text[:lastSpace]
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPruneHTML(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
html string
|
||||||
|
maxLength int
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"within limit", "<p>Hello</p>", 20, "<p>Hello</p>"},
|
||||||
|
{"exceeds limit", "<p>Hello world, this is a long text</p>", 15, "<p>Hello world,...</p>"},
|
||||||
|
{"nested tags", "<div><p>Hello world</p><p>More text</p></div>", 20, "<div><p>Hello world</p><p>More...</p></div>"},
|
||||||
|
{"html comment stripped", "<!-- comment --><p>Hello</p>", 20, "<p>Hello</p>"},
|
||||||
|
{"self-closing tag", "<p>Hello<br/>World</p>", 8, "<p>Hello<br/>...</p>"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.expected, pruneHTML(tt.html, tt.maxLength))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPruneStringToWord(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
maxLength int
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"within limit", "hello world", 15, "hello world"},
|
||||||
|
{"cut at word boundary", "hello world and more", 11, "hello world"},
|
||||||
|
{"zero length", "hello", 0, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.expected, pruneStringToWord(tt.text, tt.maxLength))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,16 @@ package notify
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
log "github.com/go-pkgz/lgr"
|
log "github.com/go-pkgz/lgr"
|
||||||
ntf "github.com/go-pkgz/notify"
|
ntf "github.com/go-pkgz/notify"
|
||||||
"github.com/hashicorp/go-multierror"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const commentTextLengthLimit = 100
|
||||||
|
|
||||||
// TelegramParams contain settings for telegram notifications
|
// TelegramParams contain settings for telegram notifications
|
||||||
type TelegramParams struct {
|
type TelegramParams struct {
|
||||||
AdminChannelID string // unique identifier for the target chat or username of the target channel (in the format @channelusername)
|
AdminChannelID string // unique identifier for the target chat or username of the target channel (in the format @channelusername)
|
||||||
@@ -45,14 +47,14 @@ func NewTelegram(params TelegramParams) (*Telegram, error) {
|
|||||||
// Send to telegram recipients
|
// Send to telegram recipients
|
||||||
func (t *Telegram) Send(ctx context.Context, req Request) error {
|
func (t *Telegram) Send(ctx context.Context, req Request) error {
|
||||||
log.Printf("[DEBUG] send telegram notification for comment ID %s", req.Comment.ID)
|
log.Printf("[DEBUG] send telegram notification for comment ID %s", req.Comment.ID)
|
||||||
result := new(multierror.Error)
|
var errs []error
|
||||||
|
|
||||||
msg := t.buildMessage(req)
|
msg := t.buildMessage(req)
|
||||||
|
|
||||||
if t.AdminChannelID != "" {
|
if t.AdminChannelID != "" {
|
||||||
err := t.Telegram.Send(ctx, fmt.Sprintf("telegram:%s?parseMode=HTML", t.AdminChannelID), msg)
|
err := t.Telegram.Send(ctx, fmt.Sprintf("telegram:%s?parseMode=HTML", t.AdminChannelID), msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result = multierror.Append(result,
|
errs = append(errs,
|
||||||
fmt.Errorf("problem sending admin telegram notification about comment ID %s to %s: %w",
|
fmt.Errorf("problem sending admin telegram notification about comment ID %s to %s: %w",
|
||||||
req.Comment.ID, t.AdminChannelID, err,
|
req.Comment.ID, t.AdminChannelID, err,
|
||||||
),
|
),
|
||||||
@@ -64,7 +66,7 @@ func (t *Telegram) Send(ctx context.Context, req Request) error {
|
|||||||
for _, user := range req.Telegrams {
|
for _, user := range req.Telegrams {
|
||||||
err := t.Telegram.Send(ctx, fmt.Sprintf("telegram:%s?parseMode=HTML", user), msg)
|
err := t.Telegram.Send(ctx, fmt.Sprintf("telegram:%s?parseMode=HTML", user), msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result = multierror.Append(result,
|
errs = append(errs,
|
||||||
fmt.Errorf("problem sending user telegram notification about comment ID %s to %q: %w",
|
fmt.Errorf("problem sending user telegram notification about comment ID %s to %q: %w",
|
||||||
req.Comment.ID, user, err,
|
req.Comment.ID, user, err,
|
||||||
),
|
),
|
||||||
@@ -72,7 +74,7 @@ func (t *Telegram) Send(ctx context.Context, req Request) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result.ErrorOrNil()
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildMessage generates message for generic notification about new comment
|
// buildMessage generates message for generic notification about new comment
|
||||||
@@ -85,10 +87,10 @@ func (t *Telegram) buildMessage(req Request) string {
|
|||||||
msg += fmt.Sprintf(" -> <a href=%q>%s</a>", commentURLPrefix+req.parent.ID, ntf.EscapeTelegramText(req.parent.User.Name))
|
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))
|
msg += fmt.Sprintf("\n\n%s", pruneHTML(ntf.TelegramSupportedHTML(req.Comment.Text), commentTextLengthLimit))
|
||||||
|
|
||||||
if req.Comment.ParentID != "" {
|
if req.Comment.ParentID != "" {
|
||||||
msg += fmt.Sprintf("\n\n\"<i>%s</i>\"", ntf.TelegramSupportedHTML(req.parent.Text))
|
msg += fmt.Sprintf("\n\n\"<i>%s</i>\"", pruneHTML(ntf.TelegramSupportedHTML(req.parent.Text), commentTextLengthLimit))
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Comment.PostTitle != "" {
|
if req.Comment.PostTitle != "" {
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ func TestTelegram_Send(t *testing.T) {
|
|||||||
|
|
||||||
err := tb.Send(context.Background(), Request{Comment: c, parent: cp, Telegrams: []string{"test_user_channel"}})
|
err := tb.Send(context.Background(), Request{Comment: c, parent: cp, Telegrams: []string{"test_user_channel"}})
|
||||||
assert.Error(t, err)
|
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 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")
|
assert.Contains(t, err.Error(), "problem sending admin telegram notification about comment ID 999 to remark_test")
|
||||||
|
|
||||||
@@ -53,6 +52,15 @@ some text
|
|||||||
|
|
||||||
<b>Hello</b><i><b>World</b></i>`,
|
<b>Hello</b><i><b>World</b></i>`,
|
||||||
res)
|
res)
|
||||||
|
|
||||||
|
// prune string keeping HTML closing tags
|
||||||
|
c = store.Comment{
|
||||||
|
Text: "<b>Lorem ipsum <i>dolor sit amet</i>, consectetur adipiscing <code>elit, sed do eiusmod tempor incididunt</code> ut labore et dolore magna aliqua.</b>",
|
||||||
|
}
|
||||||
|
res = tb.buildMessage(Request{Comment: c})
|
||||||
|
assert.Equal(t, `<a href="#remark42__comment-"></a>
|
||||||
|
|
||||||
|
<b>Lorem ipsum <i>dolor sit amet</i>, consectetur adipiscing <code>elit, sed do eiusmod tempor incididunt</code> ut...</b>`, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTelegram_SendVerification(t *testing.T) {
|
func TestTelegram_SendVerification(t *testing.T) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package notify
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"text/template"
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
webhookDefaultTemplate = `{"text": "{{.Text}}"}`
|
webhookDefaultTemplate = `{"text": {{.Text | escapeJSONString}}}`
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebhookParams contain settings for webhook notifications
|
// WebhookParams contain settings for webhook notifications
|
||||||
@@ -49,7 +50,7 @@ func NewWebhook(params WebhookParams) (*Webhook, error) {
|
|||||||
params.Template = webhookDefaultTemplate
|
params.Template = webhookDefaultTemplate
|
||||||
}
|
}
|
||||||
|
|
||||||
payloadTmpl, err := template.New("webhook").Parse(params.Template)
|
payloadTmpl, err := template.New("webhook").Funcs(template.FuncMap{"escapeJSONString": escapeJSONString}).Parse(params.Template)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("unable to parse webhook template: %w", err)
|
return nil, fmt.Errorf("unable to parse webhook template: %w", err)
|
||||||
}
|
}
|
||||||
@@ -82,3 +83,12 @@ func (w *Webhook) SendVerification(_ context.Context, _ VerificationRequest) err
|
|||||||
func (w *Webhook) String() string {
|
func (w *Webhook) String() string {
|
||||||
return fmt.Sprintf("%s to %s", w.Webhook.String(), w.url)
|
return fmt.Sprintf("%s to %s", w.Webhook.String(), w.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// escapeJSONString escapes string for JSON
|
||||||
|
func escapeJSONString(s string) (string, error) {
|
||||||
|
b, err := json.Marshal(s)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package notify
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -34,6 +37,32 @@ func TestWebhook_NewWebhook(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "unable to parse webhook template")
|
assert.Contains(t, err.Error(), "unable to parse webhook template")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://github.com/umputun/remark42/issues/1791
|
||||||
|
func TestWebhook_ReceiveValidJSON(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Equal(t, r.URL.Path, "/webhook-notify")
|
||||||
|
assert.Equal(t, "POST", r.Method)
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
t.Log("received body", string(body))
|
||||||
|
assert.JSONEq(t, `{"text": "<p>testme</p>\n"}`, string(body))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
wh, err := NewWebhook(WebhookParams{
|
||||||
|
URL: ts.URL + "/webhook-notify",
|
||||||
|
Headers: []string{"Content-Type:application/json,text/plain"},
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, wh)
|
||||||
|
|
||||||
|
f := store.NewCommentFormatter()
|
||||||
|
c := store.Comment{Text: f.FormatText("testme", false), ParentID: "1", ID: "999"}
|
||||||
|
|
||||||
|
err = wh.Send(context.Background(), Request{Comment: c})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
func TestWebhook_Send(t *testing.T) {
|
func TestWebhook_Send(t *testing.T) {
|
||||||
wh, err := NewWebhook(WebhookParams{
|
wh, err := NewWebhook(WebhookParams{
|
||||||
URL: "bad-url",
|
URL: "bad-url",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type tgRequester interface {
|
type tgRequester interface {
|
||||||
Request(ctx context.Context, method string, b []byte, data interface{}) error
|
Request(ctx context.Context, method string, b []byte, data any) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// TGUpdatesReceiver used to dispatch telegram updates to multiple receivers
|
// TGUpdatesReceiver used to dispatch telegram updates to multiple receivers
|
||||||
@@ -27,8 +27,8 @@ type TGUpdatesReceiver interface {
|
|||||||
// DispatchTelegramUpdates dispatches telegram updates to provided list of receivers
|
// DispatchTelegramUpdates dispatches telegram updates to provided list of receivers
|
||||||
// Blocks caller
|
// Blocks caller
|
||||||
func DispatchTelegramUpdates(ctx context.Context, requester tgRequester, receivers []TGUpdatesReceiver, period time.Duration) {
|
func DispatchTelegramUpdates(ctx context.Context, requester tgRequester, receivers []TGUpdatesReceiver, period time.Duration) {
|
||||||
// Identifier of the first update to be requested.
|
// identifier of the first update to be requested.
|
||||||
// Should be equal to LastSeenUpdateID + 1
|
// should be equal to LastSeenUpdateID + 1
|
||||||
// See https://core.telegram.org/bots/api#getupdates
|
// See https://core.telegram.org/bots/api#getupdates
|
||||||
var updateOffset int
|
var updateOffset int
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
ntf "github.com/go-pkgz/notify"
|
ntf "github.com/go-pkgz/notify"
|
||||||
@@ -12,12 +13,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestDispatchTelegramUpdates(t *testing.T) {
|
func TestDispatchTelegramUpdates(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
synctest.Test(t, func(t *testing.T) {
|
||||||
poolPeriod := time.Millisecond * 100
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
go DispatchTelegramUpdates(ctx, &mockTGRequester{t: t}, []TGUpdatesReceiver{&mockTGUpdatesReceiver{t: t}}, poolPeriod)
|
poolPeriod := time.Millisecond * 100
|
||||||
time.Sleep(poolPeriod * 3)
|
go DispatchTelegramUpdates(ctx, &mockTGRequester{t: t}, []TGUpdatesReceiver{&mockTGUpdatesReceiver{t: t}}, poolPeriod)
|
||||||
cancel()
|
time.Sleep(poolPeriod * 3)
|
||||||
time.Sleep(poolPeriod)
|
cancel()
|
||||||
|
synctest.Wait()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUpdatesResp = `{
|
const getUpdatesResp = `{
|
||||||
@@ -39,7 +42,7 @@ type mockTGRequester struct {
|
|||||||
t *testing.T
|
t *testing.T
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockTGRequester) Request(_ context.Context, _ string, _ []byte, data interface{}) error {
|
func (m *mockTGRequester) Request(_ context.Context, _ string, _ []byte, data any) error {
|
||||||
if m.hit < 2 {
|
if m.hit < 2 {
|
||||||
m.hit++
|
m.hit++
|
||||||
assert.NoError(m.t, json.Unmarshal([]byte(getUpdatesResp), data))
|
assert.NoError(m.t, json.Unmarshal([]byte(getUpdatesResp), data))
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-pkgz/auth/v2"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-pkgz/auth/v2/avatar"
|
||||||
"github.com/go-pkgz/auth"
|
cache "github.com/go-pkgz/lcw/v2"
|
||||||
cache "github.com/go-pkgz/lcw"
|
|
||||||
log "github.com/go-pkgz/lgr"
|
log "github.com/go-pkgz/lgr"
|
||||||
R "github.com/go-pkgz/rest"
|
R "github.com/go-pkgz/rest"
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ type adminStore interface {
|
|||||||
|
|
||||||
// DELETE /comment/{id}?site=siteID&url=post-url - removes comment
|
// DELETE /comment/{id}?site=siteID&url=post-url - removes comment
|
||||||
func (a *admin) deleteCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
func (a *admin) deleteCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := r.PathValue("id")
|
||||||
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
||||||
log.Printf("[INFO] delete comment %s", id)
|
log.Printf("[INFO] delete comment %s", id)
|
||||||
|
|
||||||
@@ -54,13 +55,12 @@ func (a *admin) deleteCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.SiteID, locator.URL, lastCommentsScope))
|
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.SiteID, locator.URL, lastCommentsScope))
|
||||||
render.Status(r, http.StatusOK)
|
R.RenderJSON(w, R.JSON{"id": id, "locator": locator})
|
||||||
render.JSON(w, r, R.JSON{"id": id, "locator": locator})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /user/{userid}?site=side-id - delete all user comments for requested userid
|
// DELETE /user/{userid}?site=side-id - delete all user comments for requested userid
|
||||||
func (a *admin) deleteUserCtrl(w http.ResponseWriter, r *http.Request) {
|
func (a *admin) deleteUserCtrl(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := chi.URLParam(r, "userid")
|
userID := r.PathValue("userid")
|
||||||
siteID := r.URL.Query().Get("site")
|
siteID := r.URL.Query().Get("site")
|
||||||
log.Printf("[INFO] delete all user comments for %s, site %s", userID, siteID)
|
log.Printf("[INFO] delete all user comments for %s, site %s", userID, siteID)
|
||||||
|
|
||||||
@@ -69,13 +69,12 @@ func (a *admin) deleteUserCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.cache.Flush(cache.Flusher(siteID).Scopes(userID, siteID, lastCommentsScope))
|
a.cache.Flush(cache.Flusher(siteID).Scopes(userID, siteID, lastCommentsScope))
|
||||||
render.Status(r, http.StatusOK)
|
R.RenderJSON(w, R.JSON{"user_id": userID, "site_id": siteID})
|
||||||
render.JSON(w, r, R.JSON{"user_id": userID, "site_id": siteID})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /user/{userid}?site=side-id - get user info for requested userid
|
// GET /user/{userid}?site=side-id - get user info for requested userid
|
||||||
func (a *admin) getUserInfoCtrl(w http.ResponseWriter, r *http.Request) {
|
func (a *admin) getUserInfoCtrl(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := chi.URLParam(r, "userid")
|
userID := r.PathValue("userid")
|
||||||
siteID := r.URL.Query().Get("site")
|
siteID := r.URL.Query().Get("site")
|
||||||
log.Printf("[INFO] get user info for %s, site %s", userID, siteID)
|
log.Printf("[INFO] get user info for %s, site %s", userID, siteID)
|
||||||
|
|
||||||
@@ -84,8 +83,7 @@ func (a *admin) getUserInfoCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get user info", rest.ErrInternal)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get user info", rest.ErrInternal)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
render.Status(r, http.StatusOK)
|
R.RenderJSON(w, ucomments[0].User)
|
||||||
render.JSON(w, r, ucomments[0].User)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /deleteme?token=jwt - delete all user comments and details by user's request. Gets info about deleted used from provided token
|
// GET /deleteme?token=jwt - delete all user comments and details by user's request. Gets info about deleted used from provided token
|
||||||
@@ -107,33 +105,56 @@ func (a *admin) deleteMeRequestCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = a.dataService.DeleteUserDetail(claims.Audience, claims.User.ID, engine.AllUserDetails); err != nil {
|
// audience is a slice but we set it to a single element, and situation when there is no audience or there are more than one is unexpected
|
||||||
|
if len(claims.Audience) != 1 {
|
||||||
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("bad request"), "can't process token, claims.Audience expected to be a single element but it's not", rest.ErrActionRejected)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
audience := claims.Audience[0]
|
||||||
|
|
||||||
|
if err = a.dataService.DeleteUserDetail(audience, claims.User.ID, engine.AllUserDetails); err != nil {
|
||||||
code := parseError(err, rest.ErrInternal)
|
code := parseError(err, rest.ErrInternal)
|
||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user details for user", code)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user details for user", code)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = a.dataService.DeleteUser(claims.Audience, claims.User.ID, store.HardDelete); err != nil {
|
if err = a.dataService.DeleteUser(audience, claims.User.ID, store.HardDelete); err != nil {
|
||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user", rest.ErrNoAccess)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user", rest.ErrNoAccess)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if claims.User.Picture != "" && a.authenticator.AvatarProxy() != nil {
|
if claims.User.Picture != "" && a.authenticator.AvatarProxy() != nil {
|
||||||
avatarStore := a.authenticator.AvatarProxy().Store
|
if avatarID := avatarIDFromPicture(claims.User.Picture); avatarID != "" {
|
||||||
if err = avatarStore.Remove(path.Base(claims.User.Picture)); err != nil {
|
// an already-removed avatar is fine (a repeated request stays idempotent), but a genuine
|
||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete user's avatar", rest.ErrInternal)
|
// store failure is surfaced now that avatar.ErrNotFound lets us tell the two apart
|
||||||
return
|
if err = a.authenticator.AvatarProxy().Store.Remove(avatarID); err != nil && !errors.Is(err, avatar.ErrNotFound) {
|
||||||
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't delete user's avatar", rest.ErrInternal)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("[WARN] unexpected avatar picture %q for user %s on site %s, skipping removal", claims.User.Picture, claims.User.ID, audience)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a.cache.Flush(cache.Flusher(claims.Audience).Scopes(claims.Audience, claims.User.ID, lastCommentsScope))
|
a.cache.Flush(cache.Flusher(audience).Scopes(audience, claims.User.ID, lastCommentsScope))
|
||||||
render.Status(r, http.StatusOK)
|
R.RenderJSON(w, R.JSON{"user_id": claims.User.ID, "site_id": claims.Audience})
|
||||||
render.JSON(w, r, R.JSON{"user_id": claims.User.ID, "site_id": claims.Audience})
|
}
|
||||||
|
|
||||||
|
// avatarIDFromPicture returns the avatar-store object id for a user picture, or "" if the picture
|
||||||
|
// does not resolve to a well-formed id (the store names its objects "<hash>.image"). Guarding on the
|
||||||
|
// id shape keeps a malformed picture, e.g. a path sentinel, from making a filesystem-backed store
|
||||||
|
// target an unexpected path.
|
||||||
|
func avatarIDFromPicture(picture string) string {
|
||||||
|
if id := path.Base(picture); strings.HasSuffix(id, ".image") {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /user/{userid}?site=side-id&block=1&ttl=7d - block or unblock user
|
// PUT /user/{userid}?site=side-id&block=1&ttl=7d - block or unblock user
|
||||||
func (a *admin) setBlockCtrl(w http.ResponseWriter, r *http.Request) {
|
func (a *admin) setBlockCtrl(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := chi.URLParam(r, "userid")
|
userID := r.PathValue("userid")
|
||||||
siteID := r.URL.Query().Get("site")
|
siteID := r.URL.Query().Get("site")
|
||||||
blockStatus := r.URL.Query().Get("block") == "1"
|
blockStatus := r.URL.Query().Get("block") == "1"
|
||||||
|
|
||||||
@@ -156,7 +177,7 @@ func (a *admin) setBlockCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.cache.Flush(cache.Flusher(siteID).Scopes(userID, siteID, lastCommentsScope))
|
a.cache.Flush(cache.Flusher(siteID).Scopes(userID, siteID, lastCommentsScope))
|
||||||
render.JSON(w, r, R.JSON{"user_id": userID, "site_id": siteID, "block": blockStatus})
|
R.RenderJSON(w, R.JSON{"user_id": userID, "site_id": siteID, "block": blockStatus})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /blocked?site=siteID - list blocked users
|
// GET /blocked?site=siteID - list blocked users
|
||||||
@@ -167,7 +188,7 @@ func (a *admin) blockedUsersCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get blocked users", rest.ErrSiteNotFound)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get blocked users", rest.ErrSiteNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
render.JSON(w, r, users)
|
R.RenderJSON(w, users)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /readonly?site=siteID&url=post-url&ro=1 - set or reset read-only status for the post
|
// PUT /readonly?site=siteID&url=post-url&ro=1 - set or reset read-only status for the post
|
||||||
@@ -194,12 +215,12 @@ func (a *admin) setReadOnlyCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL, locator.SiteID))
|
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL, locator.SiteID))
|
||||||
render.JSON(w, r, R.JSON{"locator": locator, "read-only": roStatus})
|
R.RenderJSON(w, R.JSON{"locator": locator, "read-only": roStatus})
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /title/{id}?site=siteID&url=post-url - set comment PostTitle to page's title
|
// PUT /title/{id}?site=siteID&url=post-url - set comment PostTitle to page's title
|
||||||
func (a *admin) setTitleCtrl(w http.ResponseWriter, r *http.Request) {
|
func (a *admin) setTitleCtrl(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := r.PathValue("id")
|
||||||
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
||||||
|
|
||||||
c, err := a.dataService.SetTitle(locator, id)
|
c, err := a.dataService.SetTitle(locator, id)
|
||||||
@@ -210,13 +231,12 @@ func (a *admin) setTitleCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("[INFO] set comment's title %s to %q", id, c.PostTitle)
|
log.Printf("[INFO] set comment's title %s to %q", id, c.PostTitle)
|
||||||
|
|
||||||
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL, lastCommentsScope))
|
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL, lastCommentsScope))
|
||||||
render.Status(r, http.StatusOK)
|
R.RenderJSON(w, R.JSON{"id": id, "locator": locator})
|
||||||
render.JSON(w, r, R.JSON{"id": id, "locator": locator})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /verify?site=siteID&url=post-url&ro=1 - set or reset read-only status for the post
|
// PUT /verify/{userid}?site=siteID&verified=1 - set or reset verified status for the user
|
||||||
func (a *admin) setVerifyCtrl(w http.ResponseWriter, r *http.Request) {
|
func (a *admin) setVerifyCtrl(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := chi.URLParam(r, "userid")
|
userID := r.PathValue("userid")
|
||||||
siteID := r.URL.Query().Get("site")
|
siteID := r.URL.Query().Get("site")
|
||||||
verifyStatus := r.URL.Query().Get("verified") == "1"
|
verifyStatus := r.URL.Query().Get("verified") == "1"
|
||||||
|
|
||||||
@@ -225,13 +245,13 @@ func (a *admin) setVerifyCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.cache.Flush(cache.Flusher(siteID).Scopes(siteID, userID))
|
a.cache.Flush(cache.Flusher(siteID).Scopes(siteID, userID))
|
||||||
render.JSON(w, r, R.JSON{"user": userID, "verified": verifyStatus})
|
R.RenderJSON(w, R.JSON{"user": userID, "verified": verifyStatus})
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /pin/{id}?site=siteID&url=post-url&pin=1
|
// PUT /pin/{id}?site=siteID&url=post-url&pin=1
|
||||||
// mark/unmark comment as a special
|
// mark/unmark comment as a special
|
||||||
func (a *admin) setPinCtrl(w http.ResponseWriter, r *http.Request) {
|
func (a *admin) setPinCtrl(w http.ResponseWriter, r *http.Request) {
|
||||||
commentID := chi.URLParam(r, "id")
|
commentID := r.PathValue("id")
|
||||||
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
||||||
pinStatus := r.URL.Query().Get("pin") == "1"
|
pinStatus := r.URL.Query().Get("pin") == "1"
|
||||||
|
|
||||||
@@ -240,5 +260,5 @@ func (a *admin) setPinCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL))
|
a.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL))
|
||||||
render.JSON(w, r, R.JSON{"id": commentID, "locator": locator, "pin": pinStatus})
|
R.RenderJSON(w, R.JSON{"id": commentID, "locator": locator, "pin": pinStatus})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-pkgz/auth/token"
|
"github.com/go-pkgz/auth/v2/token"
|
||||||
cache "github.com/go-pkgz/lcw"
|
cache "github.com/go-pkgz/lcw/v2"
|
||||||
R "github.com/go-pkgz/rest"
|
R "github.com/go-pkgz/rest"
|
||||||
"github.com/golang-jwt/jwt"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ func TestAdmin_Delete(t *testing.T) {
|
|||||||
fmt.Sprintf("%s/api/v1/admin/comment/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1), http.NoBody)
|
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)
|
require.NoError(t, err)
|
||||||
requireAdminOnly(t, req)
|
requireAdminOnly(t, req)
|
||||||
resp, err = sendReq(t, req, adminUmputunToken)
|
resp, err = sendReq(req, adminUmputunToken)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NoError(t, resp.Body.Close())
|
assert.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -75,19 +75,27 @@ func TestAdmin_Delete(t *testing.T) {
|
|||||||
assert.Equal(t, "", cr.Text)
|
assert.Equal(t, "", cr.Text)
|
||||||
assert.True(t, cr.Deleted)
|
assert.True(t, cr.Deleted)
|
||||||
|
|
||||||
time.Sleep(250 * time.Millisecond)
|
// the last-comments list refreshes asynchronously after the delete. the polling closure runs
|
||||||
// check last comments updated
|
// off the test goroutine, so it asserts on the CollectT it is handed rather than on t, which
|
||||||
res, code = get(t, ts.URL+"/api/v1/last/2?site=remark42")
|
// also puts the real transport or decode error in the failure message
|
||||||
assert.Equal(t, http.StatusOK, code)
|
pollClient := http.Client{Timeout: waitTimeout}
|
||||||
comments = []store.Comment{}
|
defer pollClient.CloseIdleConnections()
|
||||||
err = json.Unmarshal([]byte(res), &comments)
|
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||||
assert.NoError(t, err)
|
lastResp, gErr := pollClient.Get(ts.URL + "/api/v1/last/2?site=remark42")
|
||||||
assert.Equal(t, 1, len(comments), "should have 1 comments")
|
if !assert.NoError(c, gErr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer lastResp.Body.Close()
|
||||||
|
assert.Equal(c, http.StatusOK, lastResp.StatusCode)
|
||||||
|
last := []store.Comment{}
|
||||||
|
assert.NoError(c, json.NewDecoder(lastResp.Body).Decode(&last))
|
||||||
|
assert.Len(c, last, 1, "should have 1 comments")
|
||||||
|
}, waitTimeout, httpPoll)
|
||||||
|
|
||||||
// check count updated
|
// check count updated
|
||||||
res, code = get(t, ts.URL+"/api/v1/count?site=remark42&url=https://radio-t.com/blah")
|
res, code = get(t, ts.URL+"/api/v1/count?site=remark42&url=https://radio-t.com/blah")
|
||||||
assert.Equal(t, http.StatusOK, code)
|
assert.Equal(t, http.StatusOK, code)
|
||||||
b := map[string]interface{}{}
|
b := map[string]any{}
|
||||||
err = json.Unmarshal([]byte(res), &b)
|
err = json.Unmarshal([]byte(res), &b)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
t.Logf("%#v", b)
|
t.Logf("%#v", b)
|
||||||
@@ -111,7 +119,7 @@ func TestAdmin_Title(t *testing.T) {
|
|||||||
ts, srv, teardown := startupT(t)
|
ts, srv, teardown := startupT(t)
|
||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
srv.DataService.TitleExtractor = service.NewTitleExtractor(http.Client{Timeout: time.Second})
|
srv.DataService.TitleExtractor = service.NewTitleExtractor(http.Client{Timeout: time.Second}, []string{"127.0.0.1"})
|
||||||
tss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
tss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.String() == "/post1" {
|
if r.URL.String() == "/post1" {
|
||||||
_, err := w.Write([]byte("<html><title>post1 blah 123</title><body> 2222</body></html>"))
|
_, err := w.Write([]byte("<html><title>post1 blah 123</title><body> 2222</body></html>"))
|
||||||
@@ -139,7 +147,7 @@ func TestAdmin_Title(t *testing.T) {
|
|||||||
fmt.Sprintf("%s/api/v1/admin/title/%s?site=remark42&url=%s/post1", ts.URL, id1, tss.URL), http.NoBody)
|
fmt.Sprintf("%s/api/v1/admin/title/%s?site=remark42&url=%s/post1", ts.URL, id1, tss.URL), http.NoBody)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
requireAdminOnly(t, req)
|
requireAdminOnly(t, req)
|
||||||
resp, err := sendReq(t, req, adminUmputunToken)
|
resp, err := sendReq(req, adminUmputunToken)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -174,7 +182,7 @@ func TestAdmin_DeleteUser(t *testing.T) {
|
|||||||
req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42", ts.URL, "id2"), http.NoBody)
|
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)
|
assert.NoError(t, err)
|
||||||
requireAdminOnly(t, req)
|
requireAdminOnly(t, req)
|
||||||
resp, err := sendReq(t, req, adminUmputunToken)
|
resp, err := sendReq(req, adminUmputunToken)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -275,7 +283,7 @@ func TestAdmin_Block(t *testing.T) {
|
|||||||
req, err := http.NewRequest(http.MethodPut, url, http.NoBody)
|
req, err := http.NewRequest(http.MethodPut, url, http.NoBody)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
requireAdminOnly(t, req)
|
requireAdminOnly(t, req)
|
||||||
resp, err := sendReq(t, req, adminUmputunToken)
|
resp, err := sendReq(req, adminUmputunToken)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
body, err = io.ReadAll(resp.Body)
|
body, err = io.ReadAll(resp.Body)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -333,10 +341,12 @@ func TestAdmin_Block(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, false, j["block"])
|
assert.Equal(t, false, j["block"])
|
||||||
|
|
||||||
// block with ttl
|
// block with ttl, checked in place rather than through another admin request, which would
|
||||||
|
// push this test over the 10 req/s limit on that route
|
||||||
makeTwoComments()
|
makeTwoComments()
|
||||||
code, _ = block(1, "50ms")
|
code, _ = block(1, "500ms")
|
||||||
require.Equal(t, http.StatusOK, code)
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
require.True(t, srv.adminRest.dataService.IsBlocked("remark42", "user1"), "user1 blocked with ttl")
|
||||||
|
|
||||||
// get as regular user
|
// get as regular user
|
||||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
|
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
|
||||||
@@ -348,9 +358,15 @@ func TestAdmin_Block(t *testing.T) {
|
|||||||
assert.Equal(t, "test test #1", comments.Comments[2].Text, "comment not removed and not cleared")
|
assert.Equal(t, "test test #1", comments.Comments[2].Text, "comment not removed and not cleared")
|
||||||
assert.False(t, comments.Comments[2].Deleted, "not deleted")
|
assert.False(t, comments.Comments[2].Deleted, "not deleted")
|
||||||
|
|
||||||
srv.pubRest.cache = cache.NewScache(cache.NewNopCache()) // TODO: with lru cache it won't be refreshed and invalidated for long
|
srv.pubRest.cache = cache.NewScache[[]byte](cache.NewNopCache[[]byte]()) // TODO: with lru cache it won't be refreshed and invalidated for long
|
||||||
// time
|
// time
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
// the ttl above is wide enough that the checks in between cannot outlast it, so reaching
|
||||||
|
// here still inside the block, and the wait below observes it lapse
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return !srv.adminRest.dataService.IsBlocked("remark42", "user1")
|
||||||
|
}, waitTimeout, pollInterval, "block with ttl did not expire")
|
||||||
|
|
||||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
|
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&sort=+time")
|
||||||
assert.Equal(t, http.StatusOK, code)
|
assert.Equal(t, http.StatusOK, code)
|
||||||
comments = commentsWithInfo{}
|
comments = commentsWithInfo{}
|
||||||
@@ -383,23 +399,23 @@ func TestAdmin_BlockedList(t *testing.T) {
|
|||||||
req, err := http.NewRequest(http.MethodPut,
|
req, err := http.NewRequest(http.MethodPut,
|
||||||
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d", ts.URL, "user1", 1), http.NoBody)
|
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d", ts.URL, "user1", 1), http.NoBody)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
res, err := sendReq(t, req, adminUmputunToken)
|
res, err := sendReq(req, adminUmputunToken)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, res.Body.Close())
|
require.NoError(t, res.Body.Close())
|
||||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||||
|
|
||||||
// block user2
|
// block user2 for long enough that the "two users blocked" check below cannot race the ttl
|
||||||
req, err = http.NewRequest(http.MethodPut,
|
req, err = http.NewRequest(http.MethodPut,
|
||||||
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d&ttl=150ms", ts.URL, "user2", 1), http.NoBody)
|
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d&ttl=1h", ts.URL, "user2", 1), http.NoBody)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
res, err = sendReq(t, req, adminUmputunToken)
|
res, err = sendReq(req, adminUmputunToken)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, res.Body.Close())
|
require.NoError(t, res.Body.Close())
|
||||||
assert.Equal(t, http.StatusOK, res.StatusCode)
|
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||||
|
|
||||||
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", http.NoBody)
|
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", http.NoBody)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
res, err = sendReq(t, req, adminUmputunToken)
|
res, err = sendReq(req, adminUmputunToken)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||||
users := []store.BlockedUser{}
|
users := []store.BlockedUser{}
|
||||||
@@ -412,18 +428,33 @@ func TestAdmin_BlockedList(t *testing.T) {
|
|||||||
assert.Equal(t, "user2", users[1].ID)
|
assert.Equal(t, "user2", users[1].ID)
|
||||||
assert.Equal(t, "user2 name", users[1].Name)
|
assert.Equal(t, "user2 name", users[1].Name)
|
||||||
t.Logf("%+v", users)
|
t.Logf("%+v", users)
|
||||||
time.Sleep(150 * time.Millisecond)
|
|
||||||
|
|
||||||
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", http.NoBody)
|
// re-block user2 with a short ttl and wait for it to lapse, so the lapse is observed
|
||||||
|
// independently of the check above
|
||||||
|
req, err = http.NewRequest(http.MethodPut,
|
||||||
|
fmt.Sprintf("%s/api/v1/admin/user/%s?site=remark42&block=%d&ttl=150ms", ts.URL, "user2", 1), http.NoBody)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
res, err = sendReq(t, req, adminUmputunToken)
|
res, err = sendReq(req, adminUmputunToken)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
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.NoError(t, res.Body.Close())
|
||||||
assert.Equal(t, 1, len(users), "one user left blocked")
|
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||||
|
|
||||||
|
// the closure runs off the test goroutine and asserts on the CollectT it is handed, never on t
|
||||||
|
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||||
|
blockedReq, reqErr := http.NewRequest("GET", ts.URL+"/api/v1/admin/blocked?site=remark42", http.NoBody)
|
||||||
|
if !assert.NoError(c, reqErr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
blockedResp, sendErr := sendReq(blockedReq, adminUmputunToken)
|
||||||
|
if !assert.NoError(c, sendErr) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer blockedResp.Body.Close()
|
||||||
|
assert.Equal(c, http.StatusOK, blockedResp.StatusCode)
|
||||||
|
blocked := []store.BlockedUser{}
|
||||||
|
assert.NoError(c, json.NewDecoder(blockedResp.Body).Decode(&blocked))
|
||||||
|
assert.Len(c, blocked, 1, "one user left blocked")
|
||||||
|
}, waitTimeout, httpPoll)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdmin_ReadOnly(t *testing.T) {
|
func TestAdmin_ReadOnly(t *testing.T) {
|
||||||
@@ -448,11 +479,11 @@ func TestAdmin_ReadOnly(t *testing.T) {
|
|||||||
req, err := http.NewRequest(http.MethodPut,
|
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), http.NoBody)
|
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)
|
assert.NoError(t, err)
|
||||||
resp, err := sendReq(t, req, "") // non-admin user
|
resp, err := sendReq(req, "") // non-admin user
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
resp, err = sendReq(t, req, adminUmputunToken)
|
resp, err = sendReq(req, adminUmputunToken)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -465,9 +496,9 @@ func TestAdmin_ReadOnly(t *testing.T) {
|
|||||||
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}}
|
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}}
|
||||||
b, err := json.Marshal(c)
|
b, err := json.Marshal(c)
|
||||||
assert.NoError(t, err, "can't marshal comment %+v", c)
|
assert.NoError(t, err, "can't marshal comment %+v", c)
|
||||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", bytes.NewBuffer(b))
|
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", bytes.NewBuffer(b))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp, err = sendReq(t, req, adminUmputunToken)
|
resp, err = sendReq(req, adminUmputunToken)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
@@ -476,7 +507,7 @@ func TestAdmin_ReadOnly(t *testing.T) {
|
|||||||
req, err = http.NewRequest(http.MethodPut,
|
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), http.NoBody)
|
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)
|
assert.NoError(t, err)
|
||||||
resp, err = sendReq(t, req, adminUmputunToken)
|
resp, err = sendReq(req, adminUmputunToken)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -489,9 +520,9 @@ func TestAdmin_ReadOnly(t *testing.T) {
|
|||||||
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}}
|
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}}
|
||||||
b, err = json.Marshal(c)
|
b, err = json.Marshal(c)
|
||||||
assert.NoError(t, err, "can't marshal comment %+v", c)
|
assert.NoError(t, err, "can't marshal comment %+v", c)
|
||||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", bytes.NewBuffer(b))
|
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site="+c.Locator.SiteID, bytes.NewBuffer(b))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp, err = sendReq(t, req, adminUmputunToken)
|
resp, err = sendReq(req, adminUmputunToken)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
@@ -506,13 +537,14 @@ func TestAdmin_ReadOnlyNoComments(t *testing.T) {
|
|||||||
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), http.NoBody)
|
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)
|
assert.NoError(t, err)
|
||||||
requireAdminOnly(t, req)
|
requireAdminOnly(t, req)
|
||||||
resp, err := sendReq(t, req, adminUmputunToken)
|
resp, err := sendReq(req, adminUmputunToken)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
_, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
|
_, err = srv.DataService.Info(store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah"}, 0)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
// test format "tree"
|
||||||
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&format=tree")
|
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah&format=tree")
|
||||||
assert.Equal(t, http.StatusOK, code)
|
assert.Equal(t, http.StatusOK, code)
|
||||||
comments := commentsWithInfo{}
|
comments := commentsWithInfo{}
|
||||||
@@ -521,6 +553,16 @@ func TestAdmin_ReadOnlyNoComments(t *testing.T) {
|
|||||||
assert.Equal(t, 0, len(comments.Comments), "should have 0 comments")
|
assert.Equal(t, 0, len(comments.Comments), "should have 0 comments")
|
||||||
assert.True(t, comments.Info.ReadOnly)
|
assert.True(t, comments.Info.ReadOnly)
|
||||||
t.Logf("%+v", comments)
|
t.Logf("%+v", comments)
|
||||||
|
|
||||||
|
// test format "plain"
|
||||||
|
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah")
|
||||||
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
comments = commentsWithInfo{}
|
||||||
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, len(comments.Comments), "should have 0 comments")
|
||||||
|
assert.True(t, comments.Info.ReadOnly)
|
||||||
|
t.Logf("%+v", comments)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdmin_ReadOnlyWithAge(t *testing.T) {
|
func TestAdmin_ReadOnlyWithAge(t *testing.T) {
|
||||||
@@ -542,7 +584,7 @@ func TestAdmin_ReadOnlyWithAge(t *testing.T) {
|
|||||||
fmt.Sprintf("%s/api/v1/admin/readonly?site=remark42&url=https://radio-t.com/blah&ro=1", ts.URL), http.NoBody)
|
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)
|
assert.NoError(t, err)
|
||||||
requireAdminOnly(t, req)
|
requireAdminOnly(t, req)
|
||||||
resp, err := sendReq(t, req, adminUmputunToken)
|
resp, err := sendReq(req, adminUmputunToken)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -554,7 +596,7 @@ func TestAdmin_ReadOnlyWithAge(t *testing.T) {
|
|||||||
req, err = http.NewRequest(http.MethodPut,
|
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), http.NoBody)
|
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)
|
assert.NoError(t, err)
|
||||||
resp, err = sendReq(t, req, adminUmputunToken)
|
resp, err = sendReq(req, adminUmputunToken)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
@@ -583,7 +625,7 @@ func TestAdmin_Verify(t *testing.T) {
|
|||||||
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=1", ts.URL), http.NoBody)
|
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=1", ts.URL), http.NoBody)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
requireAdminOnly(t, req)
|
requireAdminOnly(t, req)
|
||||||
resp, err := sendReq(t, req, adminUmputunToken)
|
resp, err := sendReq(req, adminUmputunToken)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -602,7 +644,7 @@ func TestAdmin_Verify(t *testing.T) {
|
|||||||
req, err = http.NewRequest(http.MethodPut,
|
req, err = http.NewRequest(http.MethodPut,
|
||||||
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=0", ts.URL), http.NoBody)
|
fmt.Sprintf("%s/api/v1/admin/verify/user1?site=remark42&verified=0", ts.URL), http.NoBody)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
resp, err = sendReq(t, req, adminUmputunToken)
|
resp, err = sendReq(req, adminUmputunToken)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -653,7 +695,7 @@ func TestAdmin_ExportFile(t *testing.T) {
|
|||||||
req, err := http.NewRequest("GET", ts.URL+"/api/v1/admin/export?site=remark42&mode=file", http.NoBody)
|
req, err := http.NewRequest("GET", ts.URL+"/api/v1/admin/export?site=remark42&mode=file", http.NoBody)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
requireAdminOnly(t, req)
|
requireAdminOnly(t, req)
|
||||||
resp, err := sendReq(t, req, adminUmputunToken)
|
resp, err := sendReq(req, adminUmputunToken)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -697,17 +739,17 @@ func TestAdmin_DeleteMeRequest(t *testing.T) {
|
|||||||
|
|
||||||
claims := token.Claims{
|
claims := token.Claims{
|
||||||
SessionOnly: true,
|
SessionOnly: true,
|
||||||
StandardClaims: jwt.StandardClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Audience: "remark42",
|
Audience: jwt.ClaimStrings{"remark42"},
|
||||||
Id: "1234567",
|
ID: "1234567",
|
||||||
Issuer: "remark42",
|
Issuer: "remark42",
|
||||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||||
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
|
||||||
},
|
},
|
||||||
User: &token.User{
|
User: &token.User{
|
||||||
ID: "user1",
|
ID: "user1",
|
||||||
Picture: "pic.image",
|
Picture: "https://demo.remark42.com/api/v1/avatar/pic.image", // production-shaped URL: removal must path.Base it to the avatar id
|
||||||
Attributes: map[string]interface{}{
|
Attributes: map[string]any{
|
||||||
"delete_me": true,
|
"delete_me": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -736,6 +778,124 @@ func TestAdmin_DeleteMeRequest(t *testing.T) {
|
|||||||
email, err = srv.DataService.GetUserEmail("remark42", "user1")
|
email, err = srv.DataService.GetUserEmail("remark42", "user1")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Empty(t, email, "user1 email was deleted")
|
assert.Empty(t, email, "user1 email was deleted")
|
||||||
|
|
||||||
|
assert.NoFileExists(t, os.TempDir()+"/ava-remark42/42/pic.image", "user's avatar should be removed on deleteme")
|
||||||
|
}
|
||||||
|
|
||||||
|
// a delete_me request whose token carries a picture must still succeed when the avatar is
|
||||||
|
// already gone from the store: the user data is deleted and a missing avatar is tolerated
|
||||||
|
func TestAdmin_DeleteMeRequestMissingAvatar(t *testing.T) {
|
||||||
|
ts, srv, teardown := startupT(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: "user3 name", ID: "user3"}}
|
||||||
|
_, err := srv.DataService.Create(c1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
claims := token.Claims{
|
||||||
|
SessionOnly: true,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Audience: jwt.ClaimStrings{"remark42"},
|
||||||
|
ID: "2345678",
|
||||||
|
Issuer: "remark42",
|
||||||
|
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
|
||||||
|
},
|
||||||
|
User: &token.User{
|
||||||
|
ID: "user3",
|
||||||
|
Picture: "missing.image", // no avatar file exists for this picture in the store
|
||||||
|
Attributes: map[string]any{
|
||||||
|
"delete_me": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tkn, err := srv.Authenticator.TokenService().Token(claims)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
client := http.Client{}
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.SetBasicAuth("admin", "password")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode, "a missing avatar must not fail the deletion")
|
||||||
|
|
||||||
|
_, err = srv.DataService.User("remark42", "user3", 0, 0, store.User{})
|
||||||
|
assert.EqualError(t, err, "no comments for user user3 in store", "user3 comments should be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// a genuine (non not-found) avatar-store failure must now surface, not be silently swallowed:
|
||||||
|
// avatar.ErrNotFound lets deleteMeRequestCtrl tell an already-gone avatar from a real error
|
||||||
|
func TestAdmin_DeleteMeRequestAvatarRemoveError(t *testing.T) {
|
||||||
|
ts, srv, teardown := startupT(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: "user5 name", ID: "user5"}}
|
||||||
|
_, err := srv.DataService.Create(c1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// put a non-empty directory where the avatar file is expected, so Store.Remove fails with a real
|
||||||
|
// error (directory not empty), not os.ErrNotExist - "pic" hashes to partition 42
|
||||||
|
require.NoError(t, os.MkdirAll(os.TempDir()+"/ava-remark42/42/pic.image", 0o700))
|
||||||
|
require.NoError(t, os.WriteFile(os.TempDir()+"/ava-remark42/42/pic.image/child", []byte("x"), 0o600))
|
||||||
|
|
||||||
|
claims := token.Claims{
|
||||||
|
SessionOnly: true,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Audience: jwt.ClaimStrings{"remark42"},
|
||||||
|
ID: "4567890",
|
||||||
|
Issuer: "remark42",
|
||||||
|
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
|
||||||
|
},
|
||||||
|
User: &token.User{
|
||||||
|
ID: "user5",
|
||||||
|
Picture: "https://demo.remark42.com/api/v1/avatar/pic.image",
|
||||||
|
Attributes: map[string]any{
|
||||||
|
"delete_me": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tkn, err := srv.Authenticator.TokenService().Token(claims)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
client := http.Client{}
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.SetBasicAuth("admin", "password")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
assert.Equal(t, http.StatusInternalServerError, resp.StatusCode, "a real avatar-store failure must surface, not be swallowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAvatarIDFromPicture(t *testing.T) {
|
||||||
|
tbl := []struct {
|
||||||
|
name string
|
||||||
|
picture string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"local avatar url", "https://demo.remark42.com/api/v1/avatar/cb42ff493ade696d88a3a590f136ae9e34de7c1b.image", "cb42ff493ade696d88a3a590f136ae9e34de7c1b.image"},
|
||||||
|
{"bare avatar id", "pic.image", "pic.image"},
|
||||||
|
{"parent sentinel", "https://demo.remark42.com/api/v1/avatar/..", ""},
|
||||||
|
{"trailing slash", "https://demo.remark42.com/api/v1/avatar/", ""},
|
||||||
|
{"root", "/", ""},
|
||||||
|
{"dotdot", "..", ""},
|
||||||
|
{"empty", "", ""},
|
||||||
|
{"provider url without image suffix", "https://example.com/pic.png", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range tbl {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tc.want, avatarIDFromPicture(tc.picture))
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
|
func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
|
||||||
@@ -743,9 +903,9 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
|
|||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
c1 := store.Comment{Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
|
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",
|
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)
|
_, err := srv.DataService.Create(c1)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -766,16 +926,16 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
|
|||||||
// try with bad auth
|
// try with bad auth
|
||||||
claims := token.Claims{
|
claims := token.Claims{
|
||||||
SessionOnly: true,
|
SessionOnly: true,
|
||||||
StandardClaims: jwt.StandardClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Audience: "remark42",
|
Audience: jwt.ClaimStrings{"remark42"},
|
||||||
Id: "1234567",
|
ID: "provider1_1234567",
|
||||||
Issuer: "remark42",
|
Issuer: "remark42",
|
||||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||||
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
|
||||||
},
|
},
|
||||||
User: &token.User{
|
User: &token.User{
|
||||||
ID: "user1",
|
ID: "provider1_user1",
|
||||||
Attributes: map[string]interface{}{
|
Attributes: map[string]any{
|
||||||
"delete_me": true,
|
"delete_me": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -791,10 +951,11 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
|
|||||||
assert.NoError(t, resp.Body.Close())
|
assert.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
|
||||||
// try bad user
|
// unknown user: deletion is idempotent, so a valid (signed) delete_me token for a user with
|
||||||
badClaims := claims
|
// no stored data is a no-op success rather than an error
|
||||||
badClaims.User.ID = "no-such-id"
|
badClaimsUser := claims
|
||||||
tkn, err = srv.Authenticator.TokenService().Token(badClaims)
|
badClaimsUser.User.ID = "no-such-id"
|
||||||
|
tkn, err = srv.Authenticator.TokenService().Token(badClaimsUser)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
|
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -802,12 +963,13 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
|
|||||||
resp, err = client.Do(req)
|
resp, err = client.Do(req)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NoError(t, resp.Body.Close())
|
assert.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, resp.Status)
|
assert.Equal(t, http.StatusOK, resp.StatusCode, resp.Status)
|
||||||
|
badClaimsUser.User.ID = "provider1_user1"
|
||||||
|
|
||||||
// try without deleteme flag
|
// try without deleteme flag
|
||||||
badClaims2 := claims
|
badClaimsWithoutDeleteMe := claims
|
||||||
badClaims2.User.SetBoolAttr("delete_me", false)
|
badClaimsWithoutDeleteMe.User.SetBoolAttr("delete_me", false)
|
||||||
tkn, err = srv.Authenticator.TokenService().Token(badClaims2)
|
tkn, err = srv.Authenticator.TokenService().Token(badClaimsWithoutDeleteMe)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
|
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/admin/deleteme?token=%s", ts.URL, tkn), http.NoBody)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -818,7 +980,25 @@ func TestAdmin_DeleteMeRequestFailed(t *testing.T) {
|
|||||||
b, err := io.ReadAll(resp.Body)
|
b, err := io.ReadAll(resp.Body)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NoError(t, resp.Body.Close())
|
assert.NoError(t, resp.Body.Close())
|
||||||
assert.True(t, strings.Contains(string(b), "can't use provided token"))
|
assert.Contains(t, string(b), "can't use provided token")
|
||||||
|
badClaimsWithoutDeleteMe.User.SetBoolAttr("delete_me", true)
|
||||||
|
|
||||||
|
// try with wrong audience
|
||||||
|
badClaimsMultipleAudience := claims
|
||||||
|
badClaimsMultipleAudience.Audience = jwt.ClaimStrings{"remark42", "something else"}
|
||||||
|
tkn, err = srv.Authenticator.TokenService().Token(badClaimsMultipleAudience)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
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, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
b, err = io.ReadAll(resp.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NoError(t, resp.Body.Close())
|
||||||
|
assert.Contains(t, string(b), "can't process token, claims.Audience expected to be a single element but it's not")
|
||||||
|
badClaimsMultipleAudience.Audience = jwt.ClaimStrings{"remark42"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdmin_GetUserInfo(t *testing.T) {
|
func TestAdmin_GetUserInfo(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
// Package api middleware: request-scoped HTTP middlewares used by the REST router.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/didip/tollbooth/v8"
|
||||||
|
"github.com/didip/tollbooth/v8/limiter"
|
||||||
|
log "github.com/go-pkgz/lgr"
|
||||||
|
R "github.com/go-pkgz/rest"
|
||||||
|
"github.com/umputun/remark42/backend/app/rest"
|
||||||
|
"github.com/umputun/remark42/backend/app/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ipForwardingHeaders are the request headers R.RealIP derives the client IP from.
|
||||||
|
var ipForwardingHeaders = []string{"X-Real-IP", "X-Forwarded-For", "CF-Connecting-IP"}
|
||||||
|
|
||||||
|
// realIPMiddleware derives the client IP from forwarding headers (X-Real-IP / X-Forwarded-For /
|
||||||
|
// CF-Connecting-IP) via R.RealIP, but honors those headers only for requests whose direct peer
|
||||||
|
// is one of the trusted proxies. For any other peer it drops those headers and pins RemoteAddr to
|
||||||
|
// the real socket IP, so an untrusted client can't spoof the IP that per-IP controls (rate limiting,
|
||||||
|
// vote dedup, comment IP, anonymous id) and the request log key on.
|
||||||
|
//
|
||||||
|
// With no trusted proxies configured it falls back to trusting the headers from any client (the
|
||||||
|
// historical behavior). That is spoofable by design, so operators running behind a reverse proxy
|
||||||
|
// should set --trusted-proxy to the proxy's network — see the "trusted proxy" docs.
|
||||||
|
func realIPMiddleware(trustedProxies []*net.IPNet) func(http.Handler) http.Handler {
|
||||||
|
if len(trustedProxies) == 0 {
|
||||||
|
return R.RealIP
|
||||||
|
}
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
fromTrusted := R.RealIP(next) // rewrites RemoteAddr from the forwarding headers
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
peer := directPeerIP(r.RemoteAddr)
|
||||||
|
if peer != nil && cidrsContain(trustedProxies, peer) {
|
||||||
|
fromTrusted.ServeHTTP(w, r) // trusted proxy: honor the forwarding headers
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// untrusted peer: drop the forwarding headers and pin RemoteAddr to the real socket IP,
|
||||||
|
// so nothing downstream can be fooled by a spoofed header (R.RealIP normalizes
|
||||||
|
// RemoteAddr to a bare IP for trusted peers; do the same here for consistency)
|
||||||
|
for _, h := range ipForwardingHeaders {
|
||||||
|
r.Header.Del(h)
|
||||||
|
}
|
||||||
|
if peer != nil {
|
||||||
|
r.RemoteAddr = peer.String()
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// directPeerIP extracts the IP from a "host:port" (or bare host) RemoteAddr, or nil if unparseable.
|
||||||
|
func directPeerIP(remoteAddr string) net.IP {
|
||||||
|
host, _, err := net.SplitHostPort(remoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
host = remoteAddr // may already be a bare IP with no port
|
||||||
|
}
|
||||||
|
return net.ParseIP(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrustsAnyPeer reports whether the trusted-proxy list contains a catch-all (0.0.0.0/0 or ::/0),
|
||||||
|
// which trusts forwarding headers from every client and re-opens the IP-spoofing bypass.
|
||||||
|
func TrustsAnyPeer(cidrs []*net.IPNet) bool {
|
||||||
|
for _, c := range cidrs {
|
||||||
|
if ones, _ := c.Mask.Size(); ones == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// cidrsContain reports whether ip falls within any of the CIDRs.
|
||||||
|
func cidrsContain(cidrs []*net.IPNet, ip net.IP) bool {
|
||||||
|
for _, c := range cidrs {
|
||||||
|
if c.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTrustedProxies parses a list of trusted-proxy entries into CIDRs. Each entry may be a CIDR
|
||||||
|
// (e.g. 172.16.0.0/12) or a bare IP (treated as a single host). Blank entries are skipped; a
|
||||||
|
// malformed entry is a hard error so a typo can't silently disable proxy trust.
|
||||||
|
func ParseTrustedProxies(entries []string) ([]*net.IPNet, error) {
|
||||||
|
var out []*net.IPNet
|
||||||
|
for _, e := range entries {
|
||||||
|
e = strings.TrimSpace(e)
|
||||||
|
if e == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(e, "/") { // bare IP -> single-host CIDR
|
||||||
|
ip := net.ParseIP(e)
|
||||||
|
if ip == nil {
|
||||||
|
return nil, fmt.Errorf("invalid trusted proxy %q", e)
|
||||||
|
}
|
||||||
|
// build the network from the normalized IP so a v4-mapped IPv6 (e.g. ::ffff:10.0.0.1)
|
||||||
|
// yields the intended /32 host, not a huge ::/32 range
|
||||||
|
bits := 128
|
||||||
|
if v4 := ip.To4(); v4 != nil {
|
||||||
|
ip, bits = v4, 32
|
||||||
|
}
|
||||||
|
out = append(out, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, network, err := net.ParseCIDR(e)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid trusted proxy CIDR %q: %w", e, err)
|
||||||
|
}
|
||||||
|
out = append(out, network)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// corsMiddleware builds the CORS middleware for the public API. With AllowedOrigins
|
||||||
|
// "*" and credentials enabled, rest.CORS reflects the request Origin into
|
||||||
|
// Access-Control-Allow-Origin (rather than a literal "*"), which browsers require
|
||||||
|
// for credentialed cross-origin requests.
|
||||||
|
//
|
||||||
|
// That combination is refused by default upstream, so it has to be asked for by name with
|
||||||
|
// CorsUnsafeAnyOriginWithCredentials. The wildcard stays because the comment widget is embedded on
|
||||||
|
// arbitrary third-party sites, which makes the set of origins unknowable. The consequence it carries
|
||||||
|
// is that any site a signed-in user visits can read authenticated responses, so state-changing
|
||||||
|
// requests have to keep being protected by something other than the origin, X-XSRF-Token today.
|
||||||
|
func corsMiddleware() func(http.Handler) http.Handler {
|
||||||
|
return R.CORS(
|
||||||
|
R.CorsAllowedOrigins("*"),
|
||||||
|
R.CorsAllowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS"),
|
||||||
|
R.CorsAllowedHeaders("Accept", "Authorization", "Content-Type", "X-XSRF-Token", "X-JWT"),
|
||||||
|
R.CorsExposedHeaders("Authorization"),
|
||||||
|
R.CorsAllowCredentials(true),
|
||||||
|
R.CorsUnsafeAnyOriginWithCredentials(true),
|
||||||
|
R.CorsMaxAge(300),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rejectHead rejects HEAD requests with 405, advertising the given allowed methods in
|
||||||
|
// the Allow header. net/http.ServeMux routes HEAD to a "GET ..." handler, but per RFC
|
||||||
|
// 9110 GET/HEAD are safe methods; this guard is applied to the few GET routes whose
|
||||||
|
// handlers mutate state so they cannot be triggered by a (nominally side-effect-free)
|
||||||
|
// HEAD, preserving the pre-routegroup behavior. allow lists every method the resource
|
||||||
|
// supports (e.g. "GET" or "GET, POST") so the 405 Allow header is accurate.
|
||||||
|
func rejectHead(allow string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
w.Header().Set("Allow", allow)
|
||||||
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rejectAnonUser is a middleware rejecting anonymous users
|
||||||
|
func rejectAnonUser(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, err := rest.GetUserInfo(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(user.ID, "anonymous_") {
|
||||||
|
http.Error(w, "Access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchSiteID is a middleware rejecting users with mismatch between site param and and User.SiteID
|
||||||
|
func matchSiteID(next http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, err := rest.GetUserInfo(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip for basic auth user
|
||||||
|
if user.Name == "admin" && user.ID == "admin" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
siteID := r.URL.Query().Get("site")
|
||||||
|
// require an explicit site so the user.SiteID check below cannot be bypassed
|
||||||
|
// by simply omitting the query parameter
|
||||||
|
if siteID == "" || user.SiteID != siteID {
|
||||||
|
http.Error(w, "Access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheControl is a middleware setting cache expiration. Using url+version as etag
|
||||||
|
func cacheControl(expiration time.Duration, version string) func(http.Handler) http.Handler {
|
||||||
|
etag := func(r *http.Request, version string) string {
|
||||||
|
s := version + ":" + r.URL.String()
|
||||||
|
return store.EncodeID(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(h http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
e := `"` + etag(r, version) + `"`
|
||||||
|
w.Header().Set("Etag", e)
|
||||||
|
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d, no-cache", int(expiration.Seconds())))
|
||||||
|
|
||||||
|
if match := r.Header.Get("If-None-Match"); match != "" {
|
||||||
|
if strings.Contains(match, e) {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiCSPMiddleware overrides the global Content-Security-Policy on /api/v1 routes
|
||||||
|
// with a strict, default-deny policy. The global CSP (securityHeadersMiddleware) keeps
|
||||||
|
// 'self' 'unsafe-inline' for script-src/style-src because the widget HTML pages
|
||||||
|
// (/web/*.html) need inline bootstrap blocks. API responses serve JSON, XML/RSS, or
|
||||||
|
// images — none of those should ever execute scripts when rendered, so they get the
|
||||||
|
// strictest policy available as defense-in-depth against future trust-boundary bugs.
|
||||||
|
//
|
||||||
|
// Image-serving handlers (/api/v1/img, /api/v1/picture/{user}/{id}) re-apply the same
|
||||||
|
// rest.StrictImageCSP value at the handler level and additionally set Content-Disposition:
|
||||||
|
// inline; filename="image" (framing the response as a file rather than a renderable
|
||||||
|
// document) and X-Content-Type-Options: nosniff. The CSP re-apply is intentional belt-and-
|
||||||
|
// braces: if a future route refactor bypasses this middleware, the image handlers still
|
||||||
|
// emit the policy.
|
||||||
|
func apiCSPMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Security-Policy", rest.StrictImageCSP)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// securityHeadersMiddleware sets security-related headers:
|
||||||
|
// - Content-Security-Policy: controls which resources the browser is allowed to load
|
||||||
|
// - Permissions-Policy: disables browser features (camera, mic, etc.) not needed by a comment widget
|
||||||
|
// - X-Content-Type-Options: prevents browsers from MIME-sniffing responses away from the declared type,
|
||||||
|
// stopping e.g. a user-uploaded image from being reinterpreted as executable HTML/JS
|
||||||
|
// - Referrer-Policy: controls how much URL information leaks in the Referer header on cross-origin
|
||||||
|
// requests; "strict-origin-when-cross-origin" sends only the origin (no path) to other domains
|
||||||
|
// and nothing at all on HTTPS→HTTP downgrades
|
||||||
|
func securityHeadersMiddleware(imageProxyEnabled bool, allowedAncestors []string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
imgSrc := "*"
|
||||||
|
if imageProxyEnabled {
|
||||||
|
imgSrc = "'self'"
|
||||||
|
}
|
||||||
|
frameAncestors := "*"
|
||||||
|
if len(allowedAncestors) > 0 {
|
||||||
|
frameAncestors = strings.Join(allowedAncestors, " ")
|
||||||
|
}
|
||||||
|
// font-src is set to 'none' (no @font-face / no base64 fonts in the bundle).
|
||||||
|
w.Header().Set("Content-Security-Policy", fmt.Sprintf("default-src 'none'; base-uri 'none'; form-action 'none'; connect-src 'self'; frame-src 'self' mailto:; img-src %s; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'none'; object-src 'none'; frame-ancestors %s;", imgSrc, frameAncestors))
|
||||||
|
w.Header().Set("Permissions-Policy", "accelerometer=(), autoplay=(), camera=(), cross-origin-isolated=(), display-capture=(), encrypted-media=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), xr-spatial-tracking=(), clipboard-read=(), clipboard-write=(), gamepad=(), hid=(), idle-detection=(), interest-cohort=(), serial=(), unload=(), window-management=()")
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribersOnly is a middleware rejecting non-paid_sub users
|
||||||
|
func subscribersOnly(enable bool) func(http.Handler) http.Handler {
|
||||||
|
return func(h http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if enable {
|
||||||
|
user, err := rest.GetUserInfo(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !user.PaidSub {
|
||||||
|
http.Error(w, "Access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validEmailAuth is a middleware for auth endpoints for email method.
|
||||||
|
// it rejects login request if user, site or email are suspicious
|
||||||
|
func validEmailAuth() func(http.Handler) http.Handler {
|
||||||
|
|
||||||
|
reUser := regexp.MustCompile(`^[\p{L}\d\s_]{4,64}$`) // matches ui side validation, adding min/max limitation
|
||||||
|
reSite := regexp.MustCompile(`^[a-zA-Z\d\s_.-]{1,64}$`)
|
||||||
|
|
||||||
|
return func(h http.Handler) http.Handler {
|
||||||
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
if r.URL.Path != "/auth/email/login" {
|
||||||
|
// not email login, skip the check
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if u := r.URL.Query().Get("user"); u != "" {
|
||||||
|
if !reUser.MatchString(u) {
|
||||||
|
log.Printf("[WARN] suspicious user rejected: %s", u)
|
||||||
|
http.Error(w, "Access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if a := r.URL.Query().Get("address"); a != "" {
|
||||||
|
if _, err := mail.ParseAddress(a); err != nil {
|
||||||
|
log.Printf("[WARN] suspicious address rejected: %s", a)
|
||||||
|
http.Error(w, "Access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s := r.URL.Query().Get("site"); s != "" {
|
||||||
|
if !reSite.MatchString(s) {
|
||||||
|
log.Printf("[WARN] suspicious site rejected: %s", s)
|
||||||
|
http.Error(w, "Access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
return http.HandlerFunc(fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rateLimiter creates a rate limiting middleware with proper IP lookup configuration.
|
||||||
|
// tollbooth v8 requires explicit IP lookup method to be set.
|
||||||
|
// keys on RemoteAddr, which realIPMiddleware sets to the client IP (from the forwarding
|
||||||
|
// headers for trusted proxies, otherwise the real socket IP).
|
||||||
|
func rateLimiter(maxReq float64) func(http.Handler) http.Handler {
|
||||||
|
lmt := tollbooth.NewLimiter(maxReq, nil)
|
||||||
|
lmt.SetIPLookup(limiter.IPLookup{
|
||||||
|
Name: "RemoteAddr",
|
||||||
|
IndexFromRight: 0,
|
||||||
|
})
|
||||||
|
return tollbooth.HTTPMiddleware(lmt)
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-pkgz/auth/v2/token"
|
||||||
|
R "github.com/go-pkgz/rest"
|
||||||
|
"github.com/go-pkgz/routegroup"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/umputun/remark42/backend/app/rest"
|
||||||
|
"github.com/umputun/remark42/backend/app/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// routes() wraps bounded routes with the enforcing rest.Timeout and deliberately leaves the
|
||||||
|
// streaming/long-polling routes (GET /export, /userdata, /wait) without it. This checks that
|
||||||
|
// contract holds against the vendored middleware: a slow handler under R.Timeout is aborted with
|
||||||
|
// 504 at the deadline, while a route left without it runs to completion.
|
||||||
|
func TestRouteTimeout(t *testing.T) {
|
||||||
|
slow := func(d time.Duration) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done(): // return promptly once the enforcing timeout cancels the context
|
||||||
|
case <-time.After(d):
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router := routegroup.New(http.NewServeMux())
|
||||||
|
router.With(R.Timeout(20*time.Millisecond)).HandleFunc("GET /bounded", slow(time.Second))
|
||||||
|
router.HandleFunc("GET /streaming", slow(30*time.Millisecond)) // no timeout, like /export and /wait
|
||||||
|
ts := httptest.NewServer(router)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(ts.URL + "/bounded")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
assert.Equal(t, http.StatusGatewayTimeout, resp.StatusCode, "route under R.Timeout is aborted at the deadline")
|
||||||
|
|
||||||
|
resp, err = http.Get(ts.URL + "/streaming")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode, "route without R.Timeout runs to completion")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRateLimiter covers the middleware guarding every route group: a burst past the per-second
|
||||||
|
// allowance is refused with 429, and a client under the allowance is not. The limiter keys on
|
||||||
|
// RemoteAddr, so the two cases use different ones rather than waiting for a bucket to refill.
|
||||||
|
func TestRateLimiter(t *testing.T) {
|
||||||
|
router := routegroup.New(http.NewServeMux())
|
||||||
|
router.With(rateLimiter(1)).HandleFunc("GET /limited", func(http.ResponseWriter, *http.Request) {})
|
||||||
|
ts := httptest.NewServer(router)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
call := func(remoteAddr string) int {
|
||||||
|
req := httptest.NewRequest("GET", "http://example.com/limited", http.NoBody)
|
||||||
|
req.RemoteAddr = remoteAddr
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
resp := w.Result()
|
||||||
|
assert.NoError(t, resp.Body.Close())
|
||||||
|
return resp.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// one request a second is allowed, so the first of a burst passes and the rest are refused
|
||||||
|
assert.Equal(t, http.StatusOK, call("1.2.3.4:1000"), "first request within the allowance")
|
||||||
|
refused := 0
|
||||||
|
for range 5 {
|
||||||
|
if call("1.2.3.4:1000") == http.StatusTooManyRequests {
|
||||||
|
refused++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, 5, refused, "burst past the allowance is refused")
|
||||||
|
|
||||||
|
// a different client has its own bucket and is unaffected
|
||||||
|
assert.Equal(t, http.StatusOK, call("5.6.7.8:1000"), "limit is per client, not global")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealIPMiddleware(t *testing.T) {
|
||||||
|
// call runs mw with the given peer and (optional) X-Real-IP header and returns what the
|
||||||
|
// downstream handler observes; state is per-call, so subtests don't share closure locals.
|
||||||
|
call := func(mw func(http.Handler) http.Handler, remoteAddr, xRealIP string) (addr, hdr string) {
|
||||||
|
next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
addr, hdr = r.RemoteAddr, r.Header.Get("X-Real-IP")
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
|
||||||
|
req.RemoteAddr = remoteAddr
|
||||||
|
if xRealIP != "" {
|
||||||
|
req.Header.Set("X-Real-IP", xRealIP)
|
||||||
|
}
|
||||||
|
mw(next).ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
return addr, hdr
|
||||||
|
}
|
||||||
|
|
||||||
|
trusted, err := ParseTrustedProxies([]string{"172.16.0.0/12", "2001:db8::/32"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Run("no trusted proxies trusts the header from anyone (legacy)", func(t *testing.T) {
|
||||||
|
addr, _ := call(realIPMiddleware(nil), "203.0.113.9:1234", "8.8.8.8")
|
||||||
|
assert.Equal(t, "8.8.8.8", addr)
|
||||||
|
})
|
||||||
|
t.Run("trusted v4 peer: forwarding header sets the client IP", func(t *testing.T) {
|
||||||
|
addr, _ := call(realIPMiddleware(trusted), "172.18.0.5:5555", "8.8.8.8")
|
||||||
|
assert.Equal(t, "8.8.8.8", addr)
|
||||||
|
})
|
||||||
|
t.Run("trusted v6 peer: forwarding header honored", func(t *testing.T) {
|
||||||
|
addr, _ := call(realIPMiddleware(trusted), "[2001:db8::5]:5555", "8.8.8.8")
|
||||||
|
assert.Equal(t, "8.8.8.8", addr)
|
||||||
|
})
|
||||||
|
t.Run("trusted peer without a forwarding header falls back to the socket IP", func(t *testing.T) {
|
||||||
|
addr, _ := call(realIPMiddleware(trusted), "172.18.0.5:5555", "")
|
||||||
|
assert.Equal(t, "172.18.0.5", addr, "no header to honor, so the bare socket IP is used")
|
||||||
|
})
|
||||||
|
t.Run("untrusted peer: header stripped, RemoteAddr pinned to bare socket IP", func(t *testing.T) {
|
||||||
|
addr, hdr := call(realIPMiddleware(trusted), "203.0.113.9:1234", "8.8.8.8")
|
||||||
|
assert.Equal(t, "203.0.113.9", addr, "real socket IP with the port stripped")
|
||||||
|
assert.Empty(t, hdr, "spoofed forwarding header removed so nothing downstream can read it")
|
||||||
|
})
|
||||||
|
t.Run("unparseable RemoteAddr is treated as untrusted, header stripped", func(t *testing.T) {
|
||||||
|
addr, hdr := call(realIPMiddleware(trusted), "garbage", "8.8.8.8")
|
||||||
|
assert.Equal(t, "garbage", addr, "unparseable peer left as-is, not overwritten")
|
||||||
|
assert.Empty(t, hdr, "forwarding header still stripped for a non-trusted peer")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTrustedProxies(t *testing.T) {
|
||||||
|
t.Run("cidr, bare v4, bare v6, blanks", func(t *testing.T) {
|
||||||
|
got, err := ParseTrustedProxies([]string{"172.16.0.0/12", " 10.0.0.1 ", "", "2001:db8::/32"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 3)
|
||||||
|
assert.True(t, got[0].Contains(net.ParseIP("172.18.0.5")))
|
||||||
|
assert.True(t, got[1].Contains(net.ParseIP("10.0.0.1")))
|
||||||
|
assert.False(t, got[1].Contains(net.ParseIP("10.0.0.2")), "a bare IP is a single host")
|
||||||
|
assert.True(t, got[2].Contains(net.ParseIP("2001:db8::1")))
|
||||||
|
})
|
||||||
|
t.Run("v4-mapped IPv6 bare entry resolves to the v4 host", func(t *testing.T) {
|
||||||
|
got, err := ParseTrustedProxies([]string{"::ffff:10.0.0.1"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
assert.True(t, got[0].Contains(net.ParseIP("10.0.0.1")), "the intended /32 host")
|
||||||
|
assert.False(t, got[0].Contains(net.ParseIP("10.0.0.2")), "not a wider range")
|
||||||
|
})
|
||||||
|
t.Run("malformed entry is a hard error", func(t *testing.T) {
|
||||||
|
_, err := ParseTrustedProxies([]string{"172.16.0.0/12", "nonsense"})
|
||||||
|
require.Error(t, err)
|
||||||
|
_, err = ParseTrustedProxies([]string{"10.0.0.0/999"})
|
||||||
|
require.Error(t, err)
|
||||||
|
})
|
||||||
|
t.Run("all blank yields nil", func(t *testing.T) {
|
||||||
|
got, err := ParseTrustedProxies([]string{"", " "})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrustsAnyPeer(t *testing.T) {
|
||||||
|
catchAll := func(entries ...string) bool {
|
||||||
|
cidrs, err := ParseTrustedProxies(entries)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return TrustsAnyPeer(cidrs)
|
||||||
|
}
|
||||||
|
assert.True(t, catchAll("10.0.0.0/8", "0.0.0.0/0"), "v4 catch-all")
|
||||||
|
assert.True(t, catchAll("::/0"), "v6 catch-all")
|
||||||
|
assert.False(t, catchAll("172.16.0.0/12", "10.0.0.5"), "scoped ranges are not catch-all")
|
||||||
|
assert.False(t, catchAll(), "empty is not catch-all")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRest_rejectAnonUser(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(fakeAuth(rejectAnonUser(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
fmt.Fprintln(w, "Hello")
|
||||||
|
}))))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(ts.URL)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "use not logged in")
|
||||||
|
|
||||||
|
resp, err = http.Get(ts.URL + "?fake_id=anonymous_user123&fake_name=test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode, "anon rejected")
|
||||||
|
|
||||||
|
resp, err = http.Get(ts.URL + "?fake_id=real_user123&fake_name=test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode, "real user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRest_cacheControl(t *testing.T) {
|
||||||
|
tbl := []struct {
|
||||||
|
url string
|
||||||
|
version string
|
||||||
|
exp time.Duration
|
||||||
|
etag string
|
||||||
|
maxAge int
|
||||||
|
}{
|
||||||
|
{"http://example.com/foo", "v1", time.Hour, "b433be1ea19edaee9dc92ca4b895b6bdf3c058cb", 3600},
|
||||||
|
{"http://example.com/foo2", "v1", 10 * time.Hour, "6d8466aef3246c1057452561acddf7ad9d0d99e0", 36000},
|
||||||
|
{"http://example.com/foo", "v2", time.Hour, "481700c52aab0dfbca99f3ffc2a4fbb27884c114", 3600},
|
||||||
|
{"https://example.com/foo", "v2", time.Hour, "bebd4f1b87f474792c4e75e5affe31fbf67f5778", 3600},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tt := range tbl {
|
||||||
|
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", tt.url, http.NoBody)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h := cacheControl(tt.exp, tt.version)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
resp := w.Result()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.NoError(t, resp.Body.Close())
|
||||||
|
t.Logf("%+v", resp.Header)
|
||||||
|
assert.Equal(t, `"`+tt.etag+`"`, resp.Header.Get("Etag"))
|
||||||
|
assert.Equal(t, `max-age=`+strconv.Itoa(int(tt.exp.Seconds()))+", no-cache", resp.Header.Get("Cache-Control"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRest_apiCSP locks in that /api/v1/* responses get a strict default-src 'none'
|
||||||
|
// override regardless of what the global CSP allows. The widget HTML pages
|
||||||
|
// (/web/*.html) still get the global CSP (with 'unsafe-inline' for bootstrap),
|
||||||
|
// so the test asserts the two policies diverge across origins.
|
||||||
|
func TestRest_apiCSP(t *testing.T) {
|
||||||
|
ts, _, teardown := startupT(t)
|
||||||
|
defer teardown()
|
||||||
|
client := http.Client{}
|
||||||
|
|
||||||
|
// JSON API endpoint — must carry the strict policy
|
||||||
|
resp, err := client.Get(ts.URL + "/api/v1/config")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
csp := resp.Header.Get("Content-Security-Policy")
|
||||||
|
assert.Contains(t, csp, "default-src 'none'",
|
||||||
|
"API responses must override the global CSP with default-src 'none'; got %q", csp)
|
||||||
|
assert.Contains(t, csp, "sandbox", "API CSP must include sandbox; got %q", csp)
|
||||||
|
assert.NotContains(t, csp, "'unsafe-inline'",
|
||||||
|
"API CSP must not allow inline scripts/styles; got %q", csp)
|
||||||
|
|
||||||
|
// RSS/XML endpoint — same strict policy, and the XML response itself must still be served
|
||||||
|
respRSS, err := client.Get(ts.URL + "/api/v1/rss/site?site=remark42")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer respRSS.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusOK, respRSS.StatusCode, "RSS must still respond OK under strict CSP")
|
||||||
|
cspRSS := respRSS.Header.Get("Content-Security-Policy")
|
||||||
|
assert.Contains(t, cspRSS, "default-src 'none'", "RSS responses must carry the strict API CSP")
|
||||||
|
assert.Contains(t, cspRSS, "sandbox", "RSS CSP must include sandbox")
|
||||||
|
|
||||||
|
// widget HTML — must keep the global CSP (unchanged, lax to support inline bootstrap)
|
||||||
|
resp2, err := client.Get(ts.URL + "/web/index.html")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp2.Body.Close()
|
||||||
|
csp2 := resp2.Header.Get("Content-Security-Policy")
|
||||||
|
assert.Contains(t, csp2, "'unsafe-inline'",
|
||||||
|
"widget HTML CSP must keep unsafe-inline for bootstrap; got %q", csp2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// check CSP, img-src should be 'self' with proxy enabled and * without it
|
||||||
|
func TestRest_securityHeaders(t *testing.T) {
|
||||||
|
ts, _, teardown := startupT(t)
|
||||||
|
|
||||||
|
// with proxy disabled
|
||||||
|
client := http.Client{}
|
||||||
|
resp, err := client.Get(ts.URL + "/web/index.html")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "img-src *;")
|
||||||
|
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
|
||||||
|
assert.Equal(t, "strict-origin-when-cross-origin", resp.Header.Get("Referrer-Policy"))
|
||||||
|
// httptest.Server.Close waits on connections still in use, and a deferred close does not run
|
||||||
|
// until the test ends, so the body has to be released before the server is torn down here
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
client.CloseIdleConnections()
|
||||||
|
teardown()
|
||||||
|
|
||||||
|
// check CSP with proxy enabled
|
||||||
|
ts, _, teardown = startupT(t, func(srv *Rest) {
|
||||||
|
srv.ExternalImageProxy = true
|
||||||
|
})
|
||||||
|
defer teardown()
|
||||||
|
resp, err = client.Get(ts.URL + "/web/index.html")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "img-src 'self';")
|
||||||
|
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
|
||||||
|
assert.Equal(t, "strict-origin-when-cross-origin", resp.Header.Get("Referrer-Policy"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRest_subscribersOnly(t *testing.T) {
|
||||||
|
paidSubUser := &token.User{}
|
||||||
|
paidSubUser.SetPaidSub(true)
|
||||||
|
|
||||||
|
tbl := []struct {
|
||||||
|
subsOnly bool
|
||||||
|
user token.User
|
||||||
|
setUser bool
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{true, token.User{}, false, http.StatusUnauthorized},
|
||||||
|
{true, token.User{}, true, http.StatusForbidden},
|
||||||
|
{false, token.User{}, false, http.StatusOK},
|
||||||
|
{false, token.User{}, true, http.StatusOK},
|
||||||
|
{true, *paidSubUser, true, http.StatusOK},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tt := range tbl {
|
||||||
|
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "http://example.com", http.NoBody)
|
||||||
|
if tt.setUser {
|
||||||
|
req = token.SetUserInfo(req, tt.user)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h := subscribersOnly(tt.subsOnly)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
resp := w.Result()
|
||||||
|
assert.Equal(t, tt.status, resp.StatusCode)
|
||||||
|
assert.NoError(t, resp.Body.Close())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_validEmailAuth(t *testing.T) {
|
||||||
|
tbl := []struct {
|
||||||
|
req string
|
||||||
|
status int
|
||||||
|
}{
|
||||||
|
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someone", http.StatusOK},
|
||||||
|
{"/auth/email/login?site=site-with-dash_and_underscore-and.dot&address=umputun%example.com&user=someone", http.StatusOK},
|
||||||
|
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someone+blah", http.StatusOK},
|
||||||
|
{"/auth/email/login?site=remark42&address=umputun%example.com&user=Евгений+Умпутун", http.StatusOK},
|
||||||
|
{"/auth/email/login?site=remark42&address=umputun%example.com&user=12", http.StatusForbidden},
|
||||||
|
{"/auth/email/login?site=remark42&address=umputun%example.com&user=..blah+blah", http.StatusForbidden},
|
||||||
|
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someonelooong+loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong", http.StatusForbidden},
|
||||||
|
{"/auth/twitter/login?site=remark42&address=umputun%example.com&user=..blah+blah", http.StatusOK},
|
||||||
|
{"/auth/email/login?site=remark42&address=umputun%example.com", http.StatusOK},
|
||||||
|
{"/auth/email/login?site=remark42&address=umputun+example.com&user=someone", http.StatusForbidden},
|
||||||
|
{"/auth/email/login?site=bad!site&address=umputun%example.com&user=someone", http.StatusForbidden},
|
||||||
|
{"/auth/email/login?site=loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooongsite&address=umputun%example.com&user=someone", http.StatusForbidden},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tt := range tbl {
|
||||||
|
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "http://example.com"+tt.req, http.NoBody)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h := validEmailAuth()(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
resp := w.Result()
|
||||||
|
assert.Equal(t, tt.status, resp.StatusCode)
|
||||||
|
assert.NoError(t, resp.Body.Close())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRest_matchSiteID reproduces the multi-tenant isolation gap in the matchSiteID
|
||||||
|
// middleware. Before the fix, the check `if siteID != "" && user.SiteID != siteID`
|
||||||
|
// silently allowed any authenticated request that omitted the ?site= query param.
|
||||||
|
// On admin and user-mutation routes this meant the cross-site check was bypassable
|
||||||
|
// just by dropping the parameter. The fix requires ?site= to be present and to match
|
||||||
|
// the user's bound site.
|
||||||
|
func TestRest_matchSiteID(t *testing.T) {
|
||||||
|
wrapped := matchSiteID(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
}))
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
userSite string
|
||||||
|
query string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{name: "matching site allowed", userSite: "site-a", query: "?site=site-a", want: http.StatusOK},
|
||||||
|
{name: "mismatched site forbidden", userSite: "site-a", query: "?site=site-b", want: http.StatusForbidden},
|
||||||
|
{name: "missing site param rejected", userSite: "site-a", query: "", want: http.StatusForbidden},
|
||||||
|
{name: "empty site param rejected", userSite: "site-a", query: "?site=", want: http.StatusForbidden},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
r = rest.SetUserInfo(r, store.User{ID: "u", Name: "u", SiteID: c.userSite})
|
||||||
|
wrapped.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
ts := httptest.NewServer(h)
|
||||||
|
defer ts.Close()
|
||||||
|
resp, err := http.Get(ts.URL + c.query)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
assert.Equal(t, c.want, resp.StatusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCorsMiddleware(t *testing.T) {
|
||||||
|
h := corsMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
t.Run("credentialed cross-origin reflects the request origin", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
|
||||||
|
req.Header.Set("Origin", "https://example.com")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
// AllowedOrigins "*" with credentials must reflect the origin, never a literal "*"
|
||||||
|
assert.Equal(t, "https://example.com", rec.Header().Get("Access-Control-Allow-Origin"))
|
||||||
|
assert.Equal(t, "true", rec.Header().Get("Access-Control-Allow-Credentials"))
|
||||||
|
assert.Equal(t, "Authorization", rec.Header().Get("Access-Control-Expose-Headers"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("preflight advertises configured methods, headers and max-age", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/", http.NoBody)
|
||||||
|
req.Header.Set("Origin", "https://example.com")
|
||||||
|
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
assert.Equal(t, http.StatusNoContent, rec.Code)
|
||||||
|
assert.Contains(t, rec.Header().Get("Access-Control-Allow-Methods"), "POST")
|
||||||
|
assert.Contains(t, rec.Header().Get("Access-Control-Allow-Headers"), "X-JWT")
|
||||||
|
assert.Equal(t, "300", rec.Header().Get("Access-Control-Max-Age"))
|
||||||
|
// preflight responses must vary on origin and the request method/headers so caches
|
||||||
|
// don't reuse one preflight across different requests
|
||||||
|
vary := rec.Header().Values("Vary")
|
||||||
|
assert.Contains(t, vary, "Origin")
|
||||||
|
assert.Contains(t, vary, "Access-Control-Request-Method")
|
||||||
|
assert.Contains(t, vary, "Access-Control-Request-Headers")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("same-origin request (no Origin) gets no CORS headers", func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", http.NoBody))
|
||||||
|
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,22 +1,26 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/render"
|
cache "github.com/go-pkgz/lcw/v2"
|
||||||
cache "github.com/go-pkgz/lcw"
|
|
||||||
log "github.com/go-pkgz/lgr"
|
log "github.com/go-pkgz/lgr"
|
||||||
R "github.com/go-pkgz/rest"
|
R "github.com/go-pkgz/rest"
|
||||||
|
|
||||||
"github.com/umputun/remark42/backend/app/migrator"
|
"github.com/umputun/remark42/backend/app/migrator"
|
||||||
"github.com/umputun/remark42/backend/app/rest"
|
"github.com/umputun/remark42/backend/app/rest"
|
||||||
|
"github.com/umputun/remark42/backend/app/store/engine"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Migrator rest with import and export controllers
|
// Migrator rest with import and export controllers
|
||||||
@@ -58,8 +62,7 @@ func (m *Migrator) importCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
go m.runImport(siteID, r.URL.Query().Get("provider"), tmpfile) // import runs in background and sets busy flag for site
|
go m.runImport(siteID, r.URL.Query().Get("provider"), tmpfile) // import runs in background and sets busy flag for site
|
||||||
|
|
||||||
render.Status(r, http.StatusAccepted)
|
_ = R.EncodeJSON(w, http.StatusAccepted, R.JSON{"status": "import request accepted"})
|
||||||
render.JSON(w, r, R.JSON{"status": "import request accepted"})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /import/form?secret=key&site=site-id&provider=disqus|remark|wordpress
|
// POST /import/form?secret=key&site=site-id&provider=disqus|remark|wordpress
|
||||||
@@ -73,28 +76,47 @@ func (m *Migrator) importFormCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := r.ParseMultipartForm(20 * 1024 * 1024); err != nil { // 20M max memory, if bigger will make a file
|
r.Body = http.MaxBytesReader(w, r.Body, 256*1024*1024) // hard cap on upload to prevent memory exhaustion
|
||||||
|
reader, err := r.MultipartReader()
|
||||||
|
if err != nil {
|
||||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't parse multipart form", rest.ErrDecode)
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't parse multipart form", rest.ErrDecode)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
file, _, err := r.FormFile("file")
|
tmpfile := ""
|
||||||
if err != nil {
|
for {
|
||||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't get import file from the request", rest.ErrInternal)
|
part, err := reader.NextPart()
|
||||||
return
|
if err == io.EOF {
|
||||||
}
|
break
|
||||||
defer func() { _ = file.Close() }()
|
}
|
||||||
|
if err != nil {
|
||||||
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't parse multipart form", rest.ErrDecode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if part.FormName() != "file" {
|
||||||
|
_ = part.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
tmpfile, err := m.saveTemp(file)
|
tmpfile, err = m.saveTemp(part)
|
||||||
if err != nil {
|
if closeErr := part.Close(); err == nil && closeErr != nil {
|
||||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't save request to temp file", rest.ErrInternal)
|
err = closeErr
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't save request to temp file", rest.ErrInternal)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if tmpfile == "" {
|
||||||
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, fmt.Errorf("file field missing"),
|
||||||
|
"can't get import file from the request", rest.ErrInternal)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
go m.runImport(siteID, r.URL.Query().Get("provider"), tmpfile) // import runs in background and sets busy flag for site
|
go m.runImport(siteID, r.URL.Query().Get("provider"), tmpfile) // import runs in background and sets busy flag for site
|
||||||
|
|
||||||
render.Status(r, http.StatusAccepted)
|
_ = R.EncodeJSON(w, http.StatusAccepted, R.JSON{"status": "import request accepted"})
|
||||||
render.JSON(w, r, R.JSON{"status": "import request accepted"})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /wait?site=site-id
|
// GET /wait?site=site-id
|
||||||
@@ -110,20 +132,16 @@ func (m *Migrator) waitCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeOut)
|
ctx, cancel := context.WithTimeout(context.Background(), timeOut)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
for {
|
for m.isBusy(siteID) {
|
||||||
if !m.isBusy(siteID) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
render.Status(r, http.StatusGatewayTimeout)
|
_ = R.EncodeJSON(w, http.StatusGatewayTimeout, R.JSON{"status": "timeout expired", "site_id": siteID})
|
||||||
render.JSON(w, r, R.JSON{"status": "timeout expired", "site_id": siteID})
|
|
||||||
return
|
return
|
||||||
case <-time.After(100 * time.Millisecond):
|
case <-time.After(100 * time.Millisecond):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
render.Status(r, http.StatusOK)
|
R.RenderJSON(w, R.JSON{"status": "completed", "site_id": siteID})
|
||||||
render.JSON(w, r, R.JSON{"status": "completed", "site_id": siteID})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /export?site=site-id&secret=12345&?mode=file|stream
|
// GET /export?site=site-id&secret=12345&?mode=file|stream
|
||||||
@@ -131,25 +149,47 @@ func (m *Migrator) waitCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (m *Migrator) exportCtrl(w http.ResponseWriter, r *http.Request) {
|
func (m *Migrator) exportCtrl(w http.ResponseWriter, r *http.Request) {
|
||||||
siteID := r.URL.Query().Get("site")
|
siteID := r.URL.Query().Get("site")
|
||||||
|
|
||||||
var writer io.Writer = w
|
|
||||||
if r.URL.Query().Get("mode") == "file" {
|
if r.URL.Query().Get("mode") == "file" {
|
||||||
|
// buffer to memory to handle errors before committing to response
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gzWriter := gzip.NewWriter(&buf)
|
||||||
|
if _, err := m.NativeExporter.Export(gzWriter, siteID); err != nil {
|
||||||
|
code, errCode := exportErrStatus(err)
|
||||||
|
rest.SendErrorJSON(w, r, code, err, "export failed", errCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := gzWriter.Close(); err != nil {
|
||||||
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "export failed", rest.ErrInternal)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
exportFile := fmt.Sprintf("%s-%s.json.gz", siteID, time.Now().Format("20060102"))
|
exportFile := fmt.Sprintf("%s-%s.json.gz", siteID, time.Now().Format("20060102"))
|
||||||
w.Header().Set("Content-Type", "application/gzip")
|
w.Header().Set("Content-Type", "application/gzip")
|
||||||
w.Header().Set("Content-Disposition", "attachment;filename="+exportFile)
|
w.Header().Set("Content-Disposition", "attachment;filename="+exportFile)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
|
||||||
gzWriter := gzip.NewWriter(w)
|
if _, err := io.Copy(w, &buf); err != nil {
|
||||||
defer func() {
|
log.Printf("[WARN] failed to write export response: %v", err)
|
||||||
if e := gzWriter.Close(); e != nil {
|
}
|
||||||
log.Printf("[WARN] can't close gzip writer, %s", e)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
writer = gzWriter
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := m.NativeExporter.Export(writer, siteID); err != nil {
|
|
||||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "export failed", rest.ErrInternal)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stream mode - write directly to response
|
||||||
|
if _, err := m.NativeExporter.Export(w, siteID); err != nil {
|
||||||
|
code, errCode := exportErrStatus(err)
|
||||||
|
rest.SendErrorJSON(w, r, code, err, "export failed", errCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportErrStatus maps an export failure to an HTTP status and error code: an unknown
|
||||||
|
// site is a client error (400), anything else is treated as internal (500).
|
||||||
|
// The bolt store returns the engine.ErrSiteNotFound sentinel; the rpc store loses typed
|
||||||
|
// errors over jrpc, so the "not found" message is matched as a fallback (export only ever
|
||||||
|
// hits a site-level lookup, so a "not found" here can only mean the site).
|
||||||
|
func exportErrStatus(err error) (status, errCode int) {
|
||||||
|
if errors.Is(err, engine.ErrSiteNotFound) || strings.Contains(err.Error(), "not found") {
|
||||||
|
return http.StatusBadRequest, rest.ErrSiteNotFound
|
||||||
|
}
|
||||||
|
return http.StatusInternalServerError, rest.ErrInternal
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /remap?site=site-id
|
// POST /remap?site=site-id
|
||||||
@@ -163,7 +203,7 @@ func (m *Migrator) remapCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "remap failed, bad given rules", rest.ErrDecode)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "remap failed, bad given rules", rest.ErrDecode)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer r.Body.Close()
|
defer r.Body.Close() //nolint gosec // we don't care about response body
|
||||||
|
|
||||||
// start remap procedure with mapper
|
// start remap procedure with mapper
|
||||||
go func() {
|
go func() {
|
||||||
@@ -177,7 +217,7 @@ func (m *Migrator) remapCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if e = os.Remove(fh.Name()); e != nil {
|
if e = os.Remove(fh.Name()); e != nil { //nolint:gosec // fh.Name() is from os.CreateTemp, server-controlled
|
||||||
log.Printf("[WARN] failed to remove temp file %+v", e)
|
log.Printf("[WARN] failed to remove temp file %+v", e)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -204,8 +244,7 @@ func (m *Migrator) remapCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("[DEBUG] convert request completed. site=%s, comments=%d", siteID, size)
|
log.Printf("[DEBUG] convert request completed. site=%s, comments=%d", siteID, size)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
render.Status(r, http.StatusAccepted)
|
_ = R.EncodeJSON(w, http.StatusAccepted, R.JSON{"status": "convert request accepted"})
|
||||||
render.JSON(w, r, R.JSON{"status": "convert request accepted"})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// runImport reads from tmpfile and import for given siteID and provider
|
// runImport reads from tmpfile and import for given siteID and provider
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -33,7 +34,7 @@ func TestMigrator_Import(t *testing.T) {
|
|||||||
"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah2"},"score":0,
|
"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah2"},"score":0,
|
||||||
"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
|
"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 1 * time.Second}
|
client := &http.Client{Timeout: waitTimeout}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r)
|
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -49,6 +50,22 @@ func TestMigrator_Import(t *testing.T) {
|
|||||||
assert.NoError(t, resp.Body.Close())
|
assert.NoError(t, resp.Body.Close())
|
||||||
|
|
||||||
waitForMigrationCompletion(t, ts)
|
waitForMigrationCompletion(t, ts)
|
||||||
|
|
||||||
|
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
comments := commentsWithInfo{}
|
||||||
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, comments.Info.Count)
|
||||||
|
require.Equal(t, 1, len(comments.Comments))
|
||||||
|
|
||||||
|
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://radio-t.com/blah1")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
comments = commentsWithInfo{}
|
||||||
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, comments.Info.Count)
|
||||||
|
require.Equal(t, 1, len(comments.Comments))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMigrator_ImportForm(t *testing.T) {
|
func TestMigrator_ImportForm(t *testing.T) {
|
||||||
@@ -84,15 +101,31 @@ func TestMigrator_ImportForm(t *testing.T) {
|
|||||||
assert.NoError(t, resp.Body.Close())
|
assert.NoError(t, resp.Body.Close())
|
||||||
|
|
||||||
waitForMigrationCompletion(t, ts)
|
waitForMigrationCompletion(t, ts)
|
||||||
|
|
||||||
|
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
comments := commentsWithInfo{}
|
||||||
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, comments.Info.Count)
|
||||||
|
require.Equal(t, 1, len(comments.Comments))
|
||||||
|
|
||||||
|
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://radio-t.com/blah1")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
comments = commentsWithInfo{}
|
||||||
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, comments.Info.Count)
|
||||||
|
require.Equal(t, 1, len(comments.Comments))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMigrator_ImportFromWP(t *testing.T) {
|
func TestMigrator_ImportFromWP(t *testing.T) {
|
||||||
ts, _, teardown := startupT(t)
|
ts, _, teardown := startupT(t)
|
||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
r := strings.NewReader(strings.Replace(xmlTestWP, "'", "`", -1))
|
r := strings.NewReader(strings.ReplaceAll(xmlTestWP, "'", "`"))
|
||||||
|
|
||||||
client := &http.Client{Timeout: 1 * time.Second}
|
client := &http.Client{Timeout: waitTimeout}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=wordpress", r)
|
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=wordpress", r)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -108,6 +141,22 @@ func TestMigrator_ImportFromWP(t *testing.T) {
|
|||||||
assert.NoError(t, resp.Body.Close())
|
assert.NoError(t, resp.Body.Close())
|
||||||
|
|
||||||
waitForMigrationCompletion(t, ts)
|
waitForMigrationCompletion(t, ts)
|
||||||
|
|
||||||
|
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://realmenweardress.es/2010/07/do-you-rp/")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
comments := commentsWithInfo{}
|
||||||
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 3, comments.Info.Count)
|
||||||
|
require.Equal(t, 3, len(comments.Comments))
|
||||||
|
|
||||||
|
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://realmenweardress.es/2010/07/do-you-rp/")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
comments = commentsWithInfo{}
|
||||||
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 3, comments.Info.Count)
|
||||||
|
require.Equal(t, 2, len(comments.Comments), "2 comments with 1 reply")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMigrator_ImportFromCommento(t *testing.T) {
|
func TestMigrator_ImportFromCommento(t *testing.T) {
|
||||||
@@ -115,13 +164,13 @@ func TestMigrator_ImportFromCommento(t *testing.T) {
|
|||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
r := strings.NewReader(`{"version":1,"comments":[{"commentHex":"7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854",
|
r := strings.NewReader(`{"version":1,"comments":[{"commentHex":"7d77e39fcd813241d6281478cc8f21ab5f807d043c750bc1a936bc23b34fb854",
|
||||||
"domain":"example.com","url":"https://example.com/blog/post/1","commenterHex":"a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
|
"domain":"example.com","url":"/blog/post/1","commenterHex":"a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
|
||||||
"markdown":"Example content","html":"","parentHex":"root","score":0,"state":"approved","creationDate":"2021-03-17T12:09:47.722181Z",
|
"markdown":"Example content","html":"","parentHex":"root","score":0,"state":"approved","creationDate":"2021-03-17T12:09:47.722181Z",
|
||||||
"direction":0,"deleted":false}],"commenters":[{"commenterHex":"a1ac58ed1146bd7fe3feff6a7276f73955c3bfd23cacee00e2e0a7a89b1a8c10",
|
"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",
|
"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}]}`)
|
"provider":"sso:example.com","joinDate":"2021-03-19T19:27:25.954285Z","isModerator":false}]}`)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 1 * time.Second}
|
client := &http.Client{Timeout: waitTimeout}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=commento", r)
|
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=commento", r)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -137,6 +186,63 @@ func TestMigrator_ImportFromCommento(t *testing.T) {
|
|||||||
assert.NoError(t, resp.Body.Close())
|
assert.NoError(t, resp.Body.Close())
|
||||||
|
|
||||||
waitForMigrationCompletion(t, ts)
|
waitForMigrationCompletion(t, ts)
|
||||||
|
|
||||||
|
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://example.com/blog/post/1")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
comments := commentsWithInfo{}
|
||||||
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, comments.Info.Count)
|
||||||
|
require.Equal(t, 1, len(comments.Comments))
|
||||||
|
|
||||||
|
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://example.com/blog/post/1")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
comments = commentsWithInfo{}
|
||||||
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, comments.Info.Count)
|
||||||
|
require.Equal(t, 1, len(comments.Comments))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrator_ImportFromCommentoJSON(t *testing.T) {
|
||||||
|
ts, _, teardown := startupT(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
r, err := os.Open("testdata/commento.json")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: waitTimeout}
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=commento", r)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
req.Header.Add("Content-Type", "application/json; charset=utf-8")
|
||||||
|
req.SetBasicAuth("admin", "password")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||||
|
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "{\"status\":\"import request accepted\"}\n", string(b))
|
||||||
|
assert.NoError(t, resp.Body.Close())
|
||||||
|
|
||||||
|
waitForMigrationCompletion(t, ts)
|
||||||
|
|
||||||
|
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://example.com/example")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
comments := commentsWithInfo{}
|
||||||
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 7, comments.Info.Count)
|
||||||
|
require.Equal(t, 7, len(comments.Comments))
|
||||||
|
|
||||||
|
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://example.com/example")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
comments = commentsWithInfo{}
|
||||||
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 7, comments.Info.Count)
|
||||||
|
require.Equal(t, 5, len(comments.Comments), "five comments with two replies")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMigrator_ImportRejected(t *testing.T) {
|
func TestMigrator_ImportRejected(t *testing.T) {
|
||||||
@@ -152,7 +258,7 @@ func TestMigrator_ImportRejected(t *testing.T) {
|
|||||||
"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah2"},"score":0,
|
"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah2"},"score":0,
|
||||||
"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
|
"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 1 * time.Second}
|
client := &http.Client{Timeout: waitTimeout}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native&secret=XYZ", r)
|
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native&secret=XYZ", r)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -170,14 +276,18 @@ func TestMigrator_ImportDouble(t *testing.T) {
|
|||||||
"picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,
|
"picture":"/api/v1/avatar/remark.image","profile":"https://remark42.com","admin":true,
|
||||||
"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah1"},"score":0,
|
"ip":"ae12fe3b5f129b5cc4cdd2b136b7b7947c4d2741"},"locator":{"site":"remark42","url":"https://radio-t.com/blah1"},"score":0,
|
||||||
"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}`
|
"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}`
|
||||||
recs := []string{}
|
recs := make([]string, 0, 50)
|
||||||
for i := 0; i < 50; i++ {
|
for i := range 50 {
|
||||||
recs = append(recs, fmt.Sprintf(tmpl, i))
|
recs = append(recs, fmt.Sprintf(tmpl, i))
|
||||||
}
|
}
|
||||||
r := strings.NewReader(`{"version":1}` + strings.Join(recs, "\n")) // reader with 10k records
|
// each request needs its own reader. client.Do returns once the response headers are in, which
|
||||||
client := &http.Client{Timeout: 1 * time.Second}
|
// for an accepted import is before the transport's writeLoop has finished copying the body, so
|
||||||
|
// handing the same strings.Reader to the second NewRequest races that copy: NewRequest reads
|
||||||
|
// Len() to set ContentLength while WriteTo is still advancing it
|
||||||
|
body := `{"version":1}` + strings.Join(recs, "\n")
|
||||||
|
client := &http.Client{Timeout: waitTimeout}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r)
|
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", strings.NewReader(body))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.SetBasicAuth("admin", "password")
|
req.SetBasicAuth("admin", "password")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -188,7 +298,7 @@ func TestMigrator_ImportDouble(t *testing.T) {
|
|||||||
|
|
||||||
client = &http.Client{Timeout: 5 * time.Second}
|
client = &http.Client{Timeout: 5 * time.Second}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r)
|
req, err = http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", strings.NewReader(body))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.SetBasicAuth("admin", "password")
|
req.SetBasicAuth("admin", "password")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -197,6 +307,20 @@ func TestMigrator_ImportDouble(t *testing.T) {
|
|||||||
assert.NoError(t, resp.Body.Close())
|
assert.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
assert.Equal(t, http.StatusConflict, resp.StatusCode)
|
||||||
waitForMigrationCompletion(t, ts)
|
waitForMigrationCompletion(t, ts)
|
||||||
|
|
||||||
|
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
comments := commentsWithInfo{}
|
||||||
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 50, comments.Info.Count)
|
||||||
|
|
||||||
|
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
comments = commentsWithInfo{}
|
||||||
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 50, comments.Info.Count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMigrator_ImportWaitExpired(t *testing.T) {
|
func TestMigrator_ImportWaitExpired(t *testing.T) {
|
||||||
@@ -209,7 +333,7 @@ func TestMigrator_ImportWaitExpired(t *testing.T) {
|
|||||||
"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}`
|
"votes":{},"time":"2018-04-30T01:37:00.849053725-05:00"}`
|
||||||
nRecs := 50
|
nRecs := 50
|
||||||
recs := make([]string, 0, nRecs)
|
recs := make([]string, 0, nRecs)
|
||||||
for i := 0; i < nRecs; i++ {
|
for i := range nRecs {
|
||||||
recs = append(recs, fmt.Sprintf(tmpl, i))
|
recs = append(recs, fmt.Sprintf(tmpl, i))
|
||||||
}
|
}
|
||||||
r := strings.NewReader(`{"version":1}` + strings.Join(recs, "\n")) // reader with `nRecs` records
|
r := strings.NewReader(`{"version":1}` + strings.Join(recs, "\n")) // reader with `nRecs` records
|
||||||
@@ -236,6 +360,14 @@ func TestMigrator_ImportWaitExpired(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusGatewayTimeout, resp.StatusCode)
|
assert.Equal(t, http.StatusGatewayTimeout, resp.StatusCode)
|
||||||
|
|
||||||
waitForMigrationCompletion(t, ts)
|
waitForMigrationCompletion(t, ts)
|
||||||
|
|
||||||
|
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://example.com/example")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
comments := commentsWithInfo{}
|
||||||
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 0, comments.Info.Count)
|
||||||
|
require.Equal(t, 0, len(comments.Comments))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMigrator_Export(t *testing.T) {
|
func TestMigrator_Export(t *testing.T) {
|
||||||
@@ -252,7 +384,7 @@ func TestMigrator_Export(t *testing.T) {
|
|||||||
"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
|
"votes":{},"time":"2018-04-30T01:37:00.861387771-05:00"}`)
|
||||||
|
|
||||||
// import comments first
|
// import comments first
|
||||||
client := &http.Client{Timeout: 1 * time.Second}
|
client := &http.Client{Timeout: waitTimeout}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r)
|
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/import?site=remark42&provider=native", r)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -263,6 +395,32 @@ func TestMigrator_Export(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusAccepted, resp.StatusCode)
|
require.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||||
waitForMigrationCompletion(t, ts)
|
waitForMigrationCompletion(t, ts)
|
||||||
|
|
||||||
|
// export unknown site is a client error, not internal
|
||||||
|
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=file&site=test", http.NoBody)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.SetBasicAuth("admin", "password")
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
errBody, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
require.Equal(t, "application/json", resp.Header.Get("Content-Type"))
|
||||||
|
assert.Contains(t, string(errBody), `"code":6`) // rest.ErrSiteNotFound, not ErrInternal
|
||||||
|
assert.Contains(t, string(errBody), `not found`) // error detail names the missing site
|
||||||
|
|
||||||
|
// unknown site in stream mode is also a client error
|
||||||
|
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=stream&site=test", http.NoBody)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.SetBasicAuth("admin", "password")
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
errBody, err = io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
assert.Contains(t, string(errBody), `"code":6`)
|
||||||
|
|
||||||
// check file mode
|
// check file mode
|
||||||
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=file&site=remark42", http.NoBody)
|
req, err = http.NewRequest("GET", ts.URL+"/api/v1/admin/export?mode=file&site=remark42", http.NoBody)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -339,6 +497,7 @@ func TestMigrator_Remap(t *testing.T) {
|
|||||||
err = json.Unmarshal([]byte(res), &comments)
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, 2, comments.Info.Count)
|
require.Equal(t, 2, comments.Info.Count)
|
||||||
|
require.Equal(t, 2, len(comments.Comments))
|
||||||
require.False(t, comments.Info.ReadOnly)
|
require.False(t, comments.Info.ReadOnly)
|
||||||
|
|
||||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://remark42.com/demo-another/")
|
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://remark42.com/demo-another/")
|
||||||
@@ -347,6 +506,7 @@ func TestMigrator_Remap(t *testing.T) {
|
|||||||
err = json.Unmarshal([]byte(res), &comments)
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, 1, comments.Info.Count)
|
require.Equal(t, 1, comments.Info.Count)
|
||||||
|
require.Equal(t, 1, len(comments.Comments))
|
||||||
require.True(t, comments.Info.ReadOnly)
|
require.True(t, comments.Info.ReadOnly)
|
||||||
|
|
||||||
// we want remap urls to another domain - www.remark42.com
|
// we want remap urls to another domain - www.remark42.com
|
||||||
@@ -364,6 +524,16 @@ func TestMigrator_Remap(t *testing.T) {
|
|||||||
err = json.Unmarshal([]byte(res), &comments)
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, 2, comments.Info.Count)
|
require.Equal(t, 2, comments.Info.Count)
|
||||||
|
require.Equal(t, 2, len(comments.Comments))
|
||||||
|
require.False(t, comments.Info.ReadOnly)
|
||||||
|
|
||||||
|
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://www.remark42.com/demo/")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
comments = commentsWithInfo{}
|
||||||
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 2, comments.Info.Count)
|
||||||
|
require.Equal(t, 2, len(comments.Comments))
|
||||||
require.False(t, comments.Info.ReadOnly)
|
require.False(t, comments.Info.ReadOnly)
|
||||||
|
|
||||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://www.remark42.com/demo-another/")
|
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://www.remark42.com/demo-another/")
|
||||||
@@ -372,6 +542,16 @@ func TestMigrator_Remap(t *testing.T) {
|
|||||||
err = json.Unmarshal([]byte(res), &comments)
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, 1, comments.Info.Count)
|
require.Equal(t, 1, comments.Info.Count)
|
||||||
|
require.Equal(t, 1, len(comments.Comments))
|
||||||
|
require.True(t, comments.Info.ReadOnly)
|
||||||
|
|
||||||
|
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&format=tree&url=https://www.remark42.com/demo-another/")
|
||||||
|
require.Equal(t, http.StatusOK, code)
|
||||||
|
comments = commentsWithInfo{}
|
||||||
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, comments.Info.Count)
|
||||||
|
require.Equal(t, 1, len(comments.Comments))
|
||||||
require.True(t, comments.Info.ReadOnly)
|
require.True(t, comments.Info.ReadOnly)
|
||||||
|
|
||||||
// should find nothing from previous url
|
// should find nothing from previous url
|
||||||
@@ -381,6 +561,7 @@ func TestMigrator_Remap(t *testing.T) {
|
|||||||
err = json.Unmarshal([]byte(res), &comments)
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, 0, comments.Info.Count)
|
require.Equal(t, 0, comments.Info.Count)
|
||||||
|
require.Equal(t, 0, len(comments.Comments))
|
||||||
|
|
||||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://remark42.com/demo-another/")
|
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://remark42.com/demo-another/")
|
||||||
require.Equal(t, http.StatusOK, code)
|
require.Equal(t, http.StatusOK, code)
|
||||||
@@ -388,6 +569,7 @@ func TestMigrator_Remap(t *testing.T) {
|
|||||||
err = json.Unmarshal([]byte(res), &comments)
|
err = json.Unmarshal([]byte(res), &comments)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, 0, comments.Info.Count)
|
require.Equal(t, 0, comments.Info.Count)
|
||||||
|
require.Equal(t, 0, len(comments.Comments))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMigrator_RemapReject(t *testing.T) {
|
func TestMigrator_RemapReject(t *testing.T) {
|
||||||
@@ -395,7 +577,7 @@ func TestMigrator_RemapReject(t *testing.T) {
|
|||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
// without admin credentials
|
// without admin credentials
|
||||||
client := &http.Client{Timeout: 1 * time.Second}
|
client := &http.Client{Timeout: waitTimeout}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
rules := strings.NewReader(`https://remark42.com/* https://www.remark42.com/*`)
|
rules := strings.NewReader(`https://remark42.com/* https://www.remark42.com/*`)
|
||||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/remap?site=remark42", rules)
|
req, err := http.NewRequest("POST", ts.URL+"/api/v1/admin/remap?site=remark42", rules)
|
||||||
|
|||||||
+275
-375
@@ -3,29 +3,22 @@ package api
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"embed"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/mail"
|
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/didip/tollbooth/v7"
|
"github.com/go-pkgz/auth/v2"
|
||||||
"github.com/didip/tollbooth_chi"
|
"github.com/go-pkgz/lcw/v2"
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
|
||||||
"github.com/go-chi/cors"
|
|
||||||
"github.com/go-chi/render"
|
|
||||||
"github.com/go-pkgz/auth"
|
|
||||||
"github.com/go-pkgz/lcw"
|
|
||||||
log "github.com/go-pkgz/lgr"
|
log "github.com/go-pkgz/lgr"
|
||||||
R "github.com/go-pkgz/rest"
|
R "github.com/go-pkgz/rest"
|
||||||
"github.com/go-pkgz/rest/logger"
|
"github.com/go-pkgz/rest/logger"
|
||||||
|
"github.com/go-pkgz/routegroup"
|
||||||
|
|
||||||
"github.com/umputun/remark42/backend/app/notify"
|
"github.com/umputun/remark42/backend/app/notify"
|
||||||
"github.com/umputun/remark42/backend/app/rest"
|
"github.com/umputun/remark42/backend/app/rest"
|
||||||
@@ -33,6 +26,7 @@ import (
|
|||||||
"github.com/umputun/remark42/backend/app/store"
|
"github.com/umputun/remark42/backend/app/store"
|
||||||
"github.com/umputun/remark42/backend/app/store/image"
|
"github.com/umputun/remark42/backend/app/store/image"
|
||||||
"github.com/umputun/remark42/backend/app/store/service"
|
"github.com/umputun/remark42/backend/app/store/service"
|
||||||
|
"github.com/umputun/remark42/backend/app/webassets"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Rest is a rest access server
|
// Rest is a rest access server
|
||||||
@@ -51,34 +45,39 @@ type Rest struct {
|
|||||||
|
|
||||||
AnonVote bool
|
AnonVote bool
|
||||||
WebRoot string
|
WebRoot string
|
||||||
WebFS embed.FS
|
WebFS fs.FS
|
||||||
RemarkURL string
|
RemarkURL string
|
||||||
ReadOnlyAge int
|
ReadOnlyAge int
|
||||||
SharedSecret string
|
SharedSecret string
|
||||||
|
TrustedProxies []*net.IPNet // reverse-proxy networks whose forwarding headers (X-Real-IP, X-Forwarded-For, ...) are trusted
|
||||||
ScoreThresholds struct {
|
ScoreThresholds struct {
|
||||||
Low int
|
Low int
|
||||||
Critical int
|
Critical int
|
||||||
}
|
}
|
||||||
UpdateLimiter float64
|
UpdateLimiter float64
|
||||||
EmailNotifications bool
|
EmailNotifications bool
|
||||||
TelegramBotUsername string
|
TelegramNotifications bool
|
||||||
EmojiEnabled bool
|
EmojiEnabled bool
|
||||||
SimpleView bool
|
SimpleView bool
|
||||||
ProxyCORS bool
|
ProxyCORS bool
|
||||||
SendJWTHeader bool
|
SendJWTHeader bool
|
||||||
AllowedAncestors []string // sets Content-Security-Policy "frame-ancestors ..."
|
AllowedAncestors []string // sets Content-Security-Policy "frame-ancestors ..."
|
||||||
SubscribersOnly bool
|
SubscribersOnly bool
|
||||||
DisableSignature bool // prevent signature from being added to headers
|
DisableSignature bool // prevent signature from being added to headers
|
||||||
|
DisableFancyTextFormatting bool // disables SmartyPants in the comment text rendering of the posted comments
|
||||||
|
ExternalImageProxy bool
|
||||||
|
|
||||||
SSLConfig SSLConfig
|
SSLConfig SSLConfig
|
||||||
httpsServer *http.Server
|
httpsServer *http.Server
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
lock sync.Mutex
|
shutdownRequested bool
|
||||||
|
lock sync.Mutex
|
||||||
|
|
||||||
pubRest public
|
pubRest public
|
||||||
privRest private
|
privRest private
|
||||||
adminRest admin
|
adminRest admin
|
||||||
rssRest rss
|
rssRest rss
|
||||||
|
openRouteLimiter float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadingCache defines interface for caching
|
// LoadingCache defines interface for caching
|
||||||
@@ -89,12 +88,17 @@ type LoadingCache interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hardBodyLimit = 1024 * 64 // limit size of body
|
const hardBodyLimit = 1024 * 64 // limit size of body
|
||||||
|
const openRouteLimiter = 10 // limit for open routes
|
||||||
const lastCommentsScope = "last"
|
const lastCommentsScope = "last"
|
||||||
|
|
||||||
type commentsWithInfo struct {
|
type commentsWithInfo struct {
|
||||||
Comments []store.Comment `json:"comments"`
|
Comments []store.Comment `json:"comments"`
|
||||||
Info store.PostInfo `json:"info,omitempty"`
|
Info store.PostInfo `json:"info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type treeWithInfo struct {
|
||||||
|
*service.Tree
|
||||||
|
Info store.PostInfo `json:"info"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the lister and request's router, activate rest server
|
// Run the lister and request's router, activate rest server
|
||||||
@@ -110,6 +114,11 @@ func (s *Rest) Run(address string, port int) {
|
|||||||
s.lock.Lock()
|
s.lock.Lock()
|
||||||
s.httpServer = s.makeHTTPServer(address, port, s.routes())
|
s.httpServer = s.makeHTTPServer(address, port, s.routes())
|
||||||
s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
|
s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
|
||||||
|
if s.shutdownRequested {
|
||||||
|
s.lock.Unlock()
|
||||||
|
log.Print("[WARN] rest server start canceled")
|
||||||
|
return
|
||||||
|
}
|
||||||
s.lock.Unlock()
|
s.lock.Unlock()
|
||||||
|
|
||||||
err := s.httpServer.ListenAndServe()
|
err := s.httpServer.ListenAndServe()
|
||||||
@@ -123,6 +132,11 @@ func (s *Rest) Run(address string, port int) {
|
|||||||
|
|
||||||
s.httpServer = s.makeHTTPServer(address, port, s.httpToHTTPSRouter())
|
s.httpServer = s.makeHTTPServer(address, port, s.httpToHTTPSRouter())
|
||||||
s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
|
s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
|
||||||
|
if s.shutdownRequested {
|
||||||
|
s.lock.Unlock()
|
||||||
|
log.Print("[WARN] rest server start canceled")
|
||||||
|
return
|
||||||
|
}
|
||||||
s.lock.Unlock()
|
s.lock.Unlock()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -143,6 +157,11 @@ func (s *Rest) Run(address string, port int) {
|
|||||||
|
|
||||||
s.httpServer = s.makeHTTPServer(address, port, s.httpChallengeRouter(m))
|
s.httpServer = s.makeHTTPServer(address, port, s.httpChallengeRouter(m))
|
||||||
s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
|
s.httpServer.ErrorLog = log.ToStdLogger(log.Default(), "WARN")
|
||||||
|
if s.shutdownRequested {
|
||||||
|
s.lock.Unlock()
|
||||||
|
log.Print("[WARN] rest server start canceled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
s.lock.Unlock()
|
s.lock.Unlock()
|
||||||
|
|
||||||
@@ -164,6 +183,7 @@ func (s *Rest) Shutdown() {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
s.lock.Lock()
|
s.lock.Lock()
|
||||||
|
s.shutdownRequested = true
|
||||||
if s.httpServer != nil {
|
if s.httpServer != nil {
|
||||||
if err := s.httpServer.Shutdown(ctx); err != nil {
|
if err := s.httpServer.Shutdown(ctx); err != nil {
|
||||||
log.Printf("[DEBUG] http shutdown error, %s", err)
|
log.Printf("[DEBUG] http shutdown error, %s", err)
|
||||||
@@ -191,9 +211,14 @@ func (s *Rest) makeHTTPServer(address string, port int, router http.Handler) *ht
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Rest) routes() chi.Router {
|
func (s *Rest) routes() http.Handler {
|
||||||
router := chi.NewRouter()
|
if s.openRouteLimiter == 0 {
|
||||||
router.Use(middleware.Throttle(1000), middleware.RealIP, R.Recoverer(log.Default()))
|
// set the default open route limiter. Just a safety measure as it should be set by Run method anyway
|
||||||
|
s.openRouteLimiter = openRouteLimiter
|
||||||
|
}
|
||||||
|
router := routegroup.New(http.NewServeMux())
|
||||||
|
router.Use(R.Throttle(1000), realIPMiddleware(s.TrustedProxies), R.Recoverer(log.Default()))
|
||||||
|
router.Use(securityHeadersMiddleware(s.ExternalImageProxy, s.AllowedAncestors))
|
||||||
if !s.DisableSignature {
|
if !s.DisableSignature {
|
||||||
router.Use(R.AppInfo("remark42", "umputun", s.Version))
|
router.Use(R.AppInfo("remark42", "umputun", s.Version))
|
||||||
}
|
}
|
||||||
@@ -204,20 +229,7 @@ func (s *Rest) routes() chi.Router {
|
|||||||
if s.ProxyCORS {
|
if s.ProxyCORS {
|
||||||
log.Printf("[WARN] internal CORS disabled")
|
log.Printf("[WARN] internal CORS disabled")
|
||||||
} else {
|
} else {
|
||||||
corsMiddleware := cors.New(cors.Options{
|
router.Use(corsMiddleware())
|
||||||
AllowedOrigins: []string{"*"},
|
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
|
||||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-XSRF-Token", "X-JWT"},
|
|
||||||
ExposedHeaders: []string{"Authorization"},
|
|
||||||
AllowCredentials: true,
|
|
||||||
MaxAge: 300,
|
|
||||||
})
|
|
||||||
router.Use(corsMiddleware.Handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(s.AllowedAncestors) > 0 {
|
|
||||||
log.Printf("[INFO] allowed from %+v only", s.AllowedAncestors)
|
|
||||||
router.Use(frameAncestors(s.AllowedAncestors))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ipFn := func(ip string) string { return store.HashValue(ip, s.SharedSecret)[:12] } // logger uses it for anonymization
|
ipFn := func(ip string) string { return store.HashValue(ip, s.SharedSecret)[:12] } // logger uses it for anonymization
|
||||||
@@ -225,137 +237,164 @@ func (s *Rest) routes() chi.Router {
|
|||||||
|
|
||||||
authHandler, avatarHandler := s.Authenticator.Handlers()
|
authHandler, avatarHandler := s.Authenticator.Handlers()
|
||||||
|
|
||||||
router.Group(func(r chi.Router) {
|
router.Route(func(r *routegroup.Bundle) {
|
||||||
r.Use(middleware.Timeout(5 * time.Second))
|
r.Use(R.Timeout(5 * time.Second))
|
||||||
r.Use(logInfoWithBody, tollbooth_chi.LimitHandler(tollbooth.NewLimiter(2, nil)), middleware.NoCache)
|
r.Use(logInfoWithBody, rateLimiter(2), R.NoCache)
|
||||||
r.Use(validEmailAuth()) // reject suspicious email logins
|
r.Use(validEmailAuth()) // reject suspicious email logins
|
||||||
r.Mount("/auth", authHandler)
|
r.Handle("/auth/", authHandler)
|
||||||
})
|
})
|
||||||
|
|
||||||
router.Group(func(r chi.Router) {
|
router.Route(func(r *routegroup.Bundle) {
|
||||||
r.Use(middleware.Timeout(5 * time.Second))
|
r.Use(R.Timeout(5 * time.Second))
|
||||||
r.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(100, nil)))
|
r.Use(rateLimiter(100))
|
||||||
r.Mount("/avatar", avatarHandler)
|
r.Handle("/avatar/", avatarHandler)
|
||||||
})
|
})
|
||||||
|
|
||||||
authMiddleware := s.Authenticator.Middleware()
|
authMiddleware := s.Authenticator.Middleware()
|
||||||
|
|
||||||
// api routes
|
// api routes
|
||||||
router.Route("/api/v1", func(rapi chi.Router) {
|
rapi := router.Mount("/api/v1")
|
||||||
rapi.Group(func(rava chi.Router) {
|
rapi.Use(apiCSPMiddleware)
|
||||||
rava.Use(middleware.Timeout(5 * time.Second))
|
|
||||||
rava.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(100, nil)))
|
rapi.Group().Route(func(rava *routegroup.Bundle) {
|
||||||
rava.Mount("/avatar", avatarHandler)
|
rava.Use(R.Timeout(5 * time.Second))
|
||||||
|
rava.Use(rateLimiter(100))
|
||||||
|
rava.Handle("/avatar/", avatarHandler)
|
||||||
|
})
|
||||||
|
|
||||||
|
// open routes
|
||||||
|
rapi.Group().Route(func(ropen *routegroup.Bundle) {
|
||||||
|
ropen.Use(R.Timeout(30 * time.Second))
|
||||||
|
ropen.Use(rateLimiter(s.openRouteLimiter))
|
||||||
|
ropen.Use(authMiddleware.Trace, R.NoCache, logInfoWithBody)
|
||||||
|
ropen.HandleFunc("GET /config", s.configCtrl)
|
||||||
|
ropen.HandleFunc("GET /find", s.pubRest.findCommentsCtrl)
|
||||||
|
ropen.HandleFunc("GET /id/{id}", s.pubRest.commentByIDCtrl)
|
||||||
|
ropen.HandleFunc("GET /comments", s.pubRest.findUserCommentsCtrl)
|
||||||
|
ropen.HandleFunc("GET /last/{limit}", s.pubRest.lastCommentsCtrl)
|
||||||
|
ropen.HandleFunc("GET /count", s.pubRest.countCtrl)
|
||||||
|
ropen.HandleFunc("POST /counts", s.pubRest.countMultiCtrl)
|
||||||
|
ropen.HandleFunc("GET /list", s.pubRest.listCtrl)
|
||||||
|
ropen.HandleFunc("GET /info", s.pubRest.infoCtrl)
|
||||||
|
|
||||||
|
ropen.Mount("/rss").Route(func(rrss *routegroup.Bundle) {
|
||||||
|
rrss.HandleFunc("GET /post", s.rssRest.postCommentsCtrl)
|
||||||
|
rrss.HandleFunc("GET /site", s.rssRest.siteCommentsCtrl)
|
||||||
|
rrss.HandleFunc("GET /reply", s.rssRest.repliesCtrl)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// open routes, cached. /img lives here (not in the NoCache group above) because
|
||||||
|
// R.NoCache strips If-None-Match from incoming requests, which would
|
||||||
|
// defeat the proxy handler's 304 short-circuit. The handler sets a 30-day
|
||||||
|
// max-age on validated success responses (with a versioned etag for cache
|
||||||
|
// invalidation on revalidation); error responses get Cache-Control: no-store
|
||||||
|
// so transient failures aren't pinned in the cache.
|
||||||
|
rapi.Group().Route(func(ropen *routegroup.Bundle) {
|
||||||
|
ropen.Use(R.Timeout(30 * time.Second))
|
||||||
|
ropen.Use(rateLimiter(10))
|
||||||
|
ropen.Use(authMiddleware.Trace, logInfoWithBody)
|
||||||
|
ropen.HandleFunc("GET /img", s.ImageProxy.Handler)
|
||||||
|
ropen.HandleFunc("GET /picture/{user}/{id}", s.pubRest.loadPictureCtrl)
|
||||||
|
ropen.HandleFunc("GET /qr/telegram", s.pubRest.telegramQrCtrl)
|
||||||
|
})
|
||||||
|
|
||||||
|
// protected routes, require auth
|
||||||
|
rapi.Group().Route(func(rauth *routegroup.Bundle) {
|
||||||
|
rauth.Use(rateLimiter(10))
|
||||||
|
rauth.Use(authMiddleware.Auth, matchSiteID, R.NoCache, logInfoWithBody)
|
||||||
|
|
||||||
|
// GET /userdata streams a gzipped export of the user's data straight to the client, so it
|
||||||
|
// deliberately runs without R.Timeout: that middleware buffers the whole response in memory
|
||||||
|
// before sending and aborts at the deadline, which would hold a full export in RAM and truncate it.
|
||||||
|
rauth.HandleFunc("GET /userdata", s.privRest.userAllDataCtrl)
|
||||||
|
|
||||||
|
rauth.Group().Route(func(r *routegroup.Bundle) {
|
||||||
|
r.Use(R.Timeout(30 * time.Second))
|
||||||
|
r.HandleFunc("GET /user", s.privRest.userInfoCtrl)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// admin routes, require auth and admin users only
|
||||||
|
rapi.Mount("/admin").Route(func(radmin *routegroup.Bundle) {
|
||||||
|
radmin.Use(rateLimiter(10))
|
||||||
|
radmin.Use(authMiddleware.Auth, authMiddleware.AdminOnly, matchSiteID)
|
||||||
|
radmin.Use(R.NoCache, logInfoWithBody)
|
||||||
|
|
||||||
|
// bounded admin operations return small responses and get the enforcing request timeout
|
||||||
|
radmin.Group().Route(func(r *routegroup.Bundle) {
|
||||||
|
r.Use(R.Timeout(30 * time.Second))
|
||||||
|
r.HandleFunc("DELETE /comment/{id}", s.adminRest.deleteCommentCtrl)
|
||||||
|
r.HandleFunc("PUT /user/{userid}", s.adminRest.setBlockCtrl)
|
||||||
|
r.HandleFunc("DELETE /user/{userid}", s.adminRest.deleteUserCtrl)
|
||||||
|
r.HandleFunc("GET /user/{userid}", s.adminRest.getUserInfoCtrl)
|
||||||
|
r.With(rejectHead("GET")).HandleFunc("GET /deleteme", s.adminRest.deleteMeRequestCtrl)
|
||||||
|
r.HandleFunc("PUT /verify/{userid}", s.adminRest.setVerifyCtrl)
|
||||||
|
r.HandleFunc("PUT /pin/{id}", s.adminRest.setPinCtrl)
|
||||||
|
r.HandleFunc("GET /blocked", s.adminRest.blockedUsersCtrl)
|
||||||
|
r.HandleFunc("PUT /readonly", s.adminRest.setReadOnlyCtrl)
|
||||||
|
r.HandleFunc("PUT /title/{id}", s.adminRest.setTitleCtrl)
|
||||||
})
|
})
|
||||||
|
|
||||||
// open routes
|
// migrator routes deliberately run without R.Timeout: GET /export streams a full-site
|
||||||
rapi.Group(func(ropen chi.Router) {
|
// backup, GET /wait long-polls for up to 15m, and import/remap ingest large uploads. The
|
||||||
ropen.Use(middleware.Timeout(30 * time.Second))
|
// enforcing timeout buffers the whole response and aborts at the deadline, which would
|
||||||
ropen.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
|
// truncate backups, break waiting, and reject large imports.
|
||||||
ropen.Use(authMiddleware.Trace, middleware.NoCache, logInfoWithBody)
|
radmin.HandleFunc("GET /export", s.adminRest.migrator.exportCtrl)
|
||||||
ropen.Get("/config", s.configCtrl)
|
radmin.HandleFunc("POST /import", s.adminRest.migrator.importCtrl)
|
||||||
ropen.Get("/find", s.pubRest.findCommentsCtrl)
|
radmin.HandleFunc("POST /import/form", s.adminRest.migrator.importFormCtrl)
|
||||||
ropen.Get("/id/{id}", s.pubRest.commentByIDCtrl)
|
radmin.HandleFunc("POST /remap", s.adminRest.migrator.remapCtrl)
|
||||||
ropen.Get("/comments", s.pubRest.findUserCommentsCtrl)
|
radmin.HandleFunc("GET /wait", s.adminRest.migrator.waitCtrl)
|
||||||
ropen.Get("/last/{limit}", s.pubRest.lastCommentsCtrl)
|
})
|
||||||
ropen.Get("/count", s.pubRest.countCtrl)
|
|
||||||
ropen.Post("/counts", s.pubRest.countMultiCtrl)
|
|
||||||
ropen.Get("/list", s.pubRest.listCtrl)
|
|
||||||
ropen.Get("/info", s.pubRest.infoCtrl)
|
|
||||||
ropen.Get("/img", s.ImageProxy.Handler)
|
|
||||||
|
|
||||||
ropen.Route("/rss", func(rrss chi.Router) {
|
// protected routes, throttled to 10/s by default, controlled by external UpdateLimiter param
|
||||||
rrss.Get("/post", s.rssRest.postCommentsCtrl)
|
rapi.Group().Route(func(rauth *routegroup.Bundle) {
|
||||||
rrss.Get("/site", s.rssRest.siteCommentsCtrl)
|
rauth.Use(R.Timeout(10 * time.Second))
|
||||||
rrss.Get("/reply", s.rssRest.repliesCtrl)
|
rauth.Use(rateLimiter(s.updateLimiter()))
|
||||||
})
|
rauth.Use(authMiddleware.Auth, matchSiteID, subscribersOnly(s.SubscribersOnly))
|
||||||
})
|
rauth.Use(R.NoCache, logInfoWithBody)
|
||||||
|
|
||||||
// open routes, cached
|
rauth.HandleFunc("PUT /comment/{id}", s.privRest.updateCommentCtrl)
|
||||||
rapi.Group(func(ropen chi.Router) {
|
rauth.HandleFunc("POST /preview", s.privRest.previewCommentCtrl)
|
||||||
ropen.Use(middleware.Timeout(30 * time.Second))
|
rauth.HandleFunc("POST /comment", s.privRest.createCommentCtrl)
|
||||||
ropen.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
|
rauth.HandleFunc("PUT /vote/{id}", s.privRest.voteCtrl)
|
||||||
ropen.Use(authMiddleware.Trace, logInfoWithBody)
|
rauth.With(rejectAnonUser).HandleFunc("POST /deleteme", s.privRest.deleteMeCtrl)
|
||||||
ropen.Get("/picture/{user}/{id}", s.pubRest.loadPictureCtrl)
|
rauth.With(rejectAnonUser).HandleFunc("GET /email", s.privRest.getEmailCtrl)
|
||||||
ropen.Get("/qr/telegram", s.pubRest.telegramQrCtrl)
|
rauth.With(rejectAnonUser).HandleFunc("POST /email/subscribe", s.privRest.sendEmailConfirmationCtrl)
|
||||||
})
|
rauth.With(rejectAnonUser).HandleFunc("POST /email/confirm", s.privRest.setConfirmedEmailCtrl)
|
||||||
|
rauth.With(rejectAnonUser).HandleFunc("DELETE /email", s.privRest.deleteEmailCtrl)
|
||||||
|
rauth.With(rejectAnonUser, rejectHead("GET")).HandleFunc("GET /telegram/subscribe", s.privRest.telegramSubscribeCtrl)
|
||||||
|
rauth.With(rejectAnonUser).HandleFunc("DELETE /telegram", s.privRest.deleteTelegramCtrl)
|
||||||
|
})
|
||||||
|
|
||||||
// protected routes, require auth
|
// protected routes, anonymous rejected
|
||||||
rapi.Group(func(rauth chi.Router) {
|
rapi.Group().Route(func(rauth *routegroup.Bundle) {
|
||||||
rauth.Use(middleware.Timeout(30 * time.Second))
|
rauth.Use(R.Timeout(10 * time.Second))
|
||||||
rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
|
rauth.Use(rateLimiter(s.updateLimiter()))
|
||||||
rauth.Use(authMiddleware.Auth, matchSiteID, middleware.NoCache, logInfoWithBody)
|
rauth.Use(authMiddleware.Auth, rejectAnonUser, matchSiteID)
|
||||||
rauth.Get("/user", s.privRest.userInfoCtrl)
|
rauth.Use(logger.New(logger.Log(log.Default()), logger.Prefix("[DEBUG]"), logger.IPfn(ipFn)).Handler)
|
||||||
rauth.Get("/userdata", s.privRest.userAllDataCtrl)
|
rauth.HandleFunc("POST /picture", s.privRest.savePictureCtrl)
|
||||||
})
|
|
||||||
|
|
||||||
// admin routes, require auth and admin users only
|
|
||||||
rapi.Route("/admin", func(radmin chi.Router) {
|
|
||||||
radmin.Use(middleware.Timeout(30 * time.Second))
|
|
||||||
radmin.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(10, nil)))
|
|
||||||
radmin.Use(authMiddleware.Auth, authMiddleware.AdminOnly, matchSiteID)
|
|
||||||
radmin.Use(middleware.NoCache, logInfoWithBody)
|
|
||||||
|
|
||||||
radmin.Delete("/comment/{id}", s.adminRest.deleteCommentCtrl)
|
|
||||||
radmin.Put("/user/{userid}", s.adminRest.setBlockCtrl)
|
|
||||||
radmin.Delete("/user/{userid}", s.adminRest.deleteUserCtrl)
|
|
||||||
radmin.Get("/user/{userid}", s.adminRest.getUserInfoCtrl)
|
|
||||||
radmin.Get("/deleteme", s.adminRest.deleteMeRequestCtrl)
|
|
||||||
radmin.Put("/verify/{userid}", s.adminRest.setVerifyCtrl)
|
|
||||||
radmin.Put("/pin/{id}", s.adminRest.setPinCtrl)
|
|
||||||
radmin.Get("/blocked", s.adminRest.blockedUsersCtrl)
|
|
||||||
radmin.Put("/readonly", s.adminRest.setReadOnlyCtrl)
|
|
||||||
radmin.Put("/title/{id}", s.adminRest.setTitleCtrl)
|
|
||||||
|
|
||||||
// migrator
|
|
||||||
radmin.Get("/export", s.adminRest.migrator.exportCtrl)
|
|
||||||
radmin.Post("/import", s.adminRest.migrator.importCtrl)
|
|
||||||
radmin.Post("/import/form", s.adminRest.migrator.importFormCtrl)
|
|
||||||
radmin.Post("/remap", s.adminRest.migrator.remapCtrl)
|
|
||||||
radmin.Get("/wait", s.adminRest.migrator.waitCtrl)
|
|
||||||
})
|
|
||||||
|
|
||||||
// protected routes, throttled to 10/s by default, controlled by external UpdateLimiter param
|
|
||||||
rapi.Group(func(rauth chi.Router) {
|
|
||||||
rauth.Use(middleware.Timeout(10 * time.Second))
|
|
||||||
rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(s.updateLimiter(), nil)))
|
|
||||||
rauth.Use(authMiddleware.Auth, matchSiteID, subscribersOnly(s.SubscribersOnly))
|
|
||||||
rauth.Use(middleware.NoCache, logInfoWithBody)
|
|
||||||
|
|
||||||
rauth.Put("/comment/{id}", s.privRest.updateCommentCtrl)
|
|
||||||
rauth.Post("/preview", s.privRest.previewCommentCtrl)
|
|
||||||
rauth.Post("/comment", s.privRest.createCommentCtrl)
|
|
||||||
rauth.Put("/vote/{id}", s.privRest.voteCtrl)
|
|
||||||
rauth.With(rejectAnonUser).Post("/deleteme", s.privRest.deleteMeCtrl)
|
|
||||||
rauth.With(rejectAnonUser).Get("/email", s.privRest.getEmailCtrl)
|
|
||||||
rauth.With(rejectAnonUser).Post("/email/subscribe", s.privRest.sendEmailConfirmationCtrl)
|
|
||||||
rauth.With(rejectAnonUser).Post("/email/confirm", s.privRest.setConfirmedEmailCtrl)
|
|
||||||
rauth.With(rejectAnonUser).Delete("/email", s.privRest.deleteEmailCtrl)
|
|
||||||
rauth.With(rejectAnonUser).Get("/telegram/subscribe", s.privRest.telegramSubscribeCtrl)
|
|
||||||
rauth.With(rejectAnonUser).Delete("/telegram", s.privRest.deleteTelegramCtrl)
|
|
||||||
})
|
|
||||||
|
|
||||||
// protected routes, anonymous rejected
|
|
||||||
rapi.Group(func(rauth chi.Router) {
|
|
||||||
rauth.Use(middleware.Timeout(10 * time.Second))
|
|
||||||
rauth.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(s.updateLimiter(), nil)))
|
|
||||||
rauth.Use(authMiddleware.Auth, rejectAnonUser, matchSiteID)
|
|
||||||
rauth.Use(logger.New(logger.Log(log.Default()), logger.Prefix("[DEBUG]"), logger.IPfn(ipFn)).Handler)
|
|
||||||
rauth.Post("/picture", s.privRest.savePictureCtrl)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// open routes on root level
|
// open routes on root level
|
||||||
router.Group(func(rroot chi.Router) {
|
router.Route(func(rroot *routegroup.Bundle) {
|
||||||
rroot.Use(middleware.Timeout(10 * time.Second))
|
rroot.Use(R.Timeout(10 * time.Second))
|
||||||
rroot.Use(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(50, nil)))
|
rroot.Use(rateLimiter(50))
|
||||||
rroot.Get("/robots.txt", s.pubRest.robotsCtrl)
|
rroot.HandleFunc("GET /robots.txt", s.pubRest.robotsCtrl)
|
||||||
rroot.Get("/email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl)
|
rroot.With(rejectHead("GET, POST")).HandleFunc("GET /email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl)
|
||||||
rroot.Post("/email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl)
|
rroot.HandleFunc("POST /email/unsubscribe.html", s.privRest.emailUnsubscribeCtrl)
|
||||||
})
|
})
|
||||||
|
|
||||||
// file server for static content from s.WebRoot on path /web
|
// file server for /web: the frontend build first, then the assets embedded in the binary.
|
||||||
addFileServer(router, s.WebFS, s.WebRoot, s.Version)
|
// the build is embedded under web/ by app/cmd, so that prefix is stripped here. fs.Sub only
|
||||||
|
// fails for an fs.SubFS that refuses, and a nil result would panic on the first request, so
|
||||||
|
// serve nothing from the frontend rather than serving it at the wrong paths
|
||||||
|
embeddedFrontend, err := fs.Sub(s.WebFS, "web")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] no embedded frontend, serving built-in assets only: %v", err)
|
||||||
|
embeddedFrontend = emptyFS{}
|
||||||
|
}
|
||||||
|
addFileServer(router, embeddedFrontend, s.WebRoot, s.Version, s.RemarkURL)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,16 +408,17 @@ func (s *Rest) controllerGroups() (public, private, admin, rss) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
privGrp := private{
|
privGrp := private{
|
||||||
dataService: s.DataService,
|
dataService: s.DataService,
|
||||||
cache: s.Cache,
|
cache: s.Cache,
|
||||||
imageService: s.ImageService,
|
imageService: s.ImageService,
|
||||||
commentFormatter: s.CommentFormatter,
|
commentFormatter: s.CommentFormatter,
|
||||||
readOnlyAge: s.ReadOnlyAge,
|
readOnlyAge: s.ReadOnlyAge,
|
||||||
authenticator: s.Authenticator,
|
authenticator: s.Authenticator,
|
||||||
notifyService: s.NotifyService,
|
notifyService: s.NotifyService,
|
||||||
telegramService: s.TelegramService,
|
telegramService: s.TelegramService,
|
||||||
remarkURL: s.RemarkURL,
|
remarkURL: s.RemarkURL,
|
||||||
anonVote: s.AnonVote,
|
anonVote: s.AnonVote,
|
||||||
|
disableFancyTextFormatting: s.DisableFancyTextFormatting,
|
||||||
}
|
}
|
||||||
|
|
||||||
admGrp := admin{
|
admGrp := admin{
|
||||||
@@ -414,44 +454,46 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
emails, _ := s.DataService.AdminStore.Email(siteID)
|
emails, _ := s.DataService.AdminStore.Email(siteID)
|
||||||
|
|
||||||
cnf := struct {
|
cnf := struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
EditDuration int `json:"edit_duration"`
|
EditDuration int `json:"edit_duration"`
|
||||||
AdminEdit bool `json:"admin_edit"`
|
AdminEdit bool `json:"admin_edit"`
|
||||||
MaxCommentSize int `json:"max_comment_size"`
|
MinCommentSize int `json:"min_comment_size"`
|
||||||
Admins []string `json:"admins"`
|
MaxCommentSize int `json:"max_comment_size"`
|
||||||
AdminEmail string `json:"admin_email"`
|
Admins []string `json:"admins"`
|
||||||
Auth []string `json:"auth_providers"`
|
AdminEmail string `json:"admin_email"`
|
||||||
AnonVote bool `json:"anon_vote"`
|
Auth []string `json:"auth_providers"`
|
||||||
LowScore int `json:"low_score"`
|
AnonVote bool `json:"anon_vote"`
|
||||||
CriticalScore int `json:"critical_score"`
|
LowScore int `json:"low_score"`
|
||||||
PositiveScore bool `json:"positive_score"`
|
CriticalScore int `json:"critical_score"`
|
||||||
ReadOnlyAge int `json:"readonly_age"`
|
PositiveScore bool `json:"positive_score"`
|
||||||
MaxImageSize int `json:"max_image_size"`
|
ReadOnlyAge int `json:"readonly_age"`
|
||||||
EmailNotifications bool `json:"email_notifications"`
|
MaxImageSize int `json:"max_image_size"`
|
||||||
TelegramBotUsername string `json:"telegram_bot_username"`
|
EmailNotifications bool `json:"email_notifications"`
|
||||||
EmojiEnabled bool `json:"emoji_enabled"`
|
TelegramNotifications bool `json:"telegram_notifications"`
|
||||||
SimpleView bool `json:"simple_view"`
|
EmojiEnabled bool `json:"emoji_enabled"`
|
||||||
SendJWTHeader bool `json:"send_jwt_header"`
|
SimpleView bool `json:"simple_view"`
|
||||||
SubscribersOnly bool `json:"subscribers_only"`
|
SendJWTHeader bool `json:"send_jwt_header"`
|
||||||
|
SubscribersOnly bool `json:"subscribers_only"`
|
||||||
}{
|
}{
|
||||||
Version: s.Version,
|
Version: s.Version,
|
||||||
EditDuration: int(s.DataService.EditDuration.Seconds()),
|
EditDuration: int(s.DataService.EditDuration.Seconds()),
|
||||||
AdminEdit: s.DataService.AdminEdits,
|
AdminEdit: s.DataService.AdminEdits,
|
||||||
MaxCommentSize: s.DataService.MaxCommentSize,
|
MinCommentSize: s.DataService.MinCommentSize,
|
||||||
Admins: admins,
|
MaxCommentSize: s.DataService.MaxCommentSize,
|
||||||
AdminEmail: emails,
|
Admins: admins,
|
||||||
LowScore: s.ScoreThresholds.Low,
|
AdminEmail: emails,
|
||||||
CriticalScore: s.ScoreThresholds.Critical,
|
LowScore: s.ScoreThresholds.Low,
|
||||||
PositiveScore: s.DataService.PositiveScore,
|
CriticalScore: s.ScoreThresholds.Critical,
|
||||||
ReadOnlyAge: s.ReadOnlyAge,
|
PositiveScore: s.DataService.PositiveScore,
|
||||||
MaxImageSize: s.ImageService.MaxSize,
|
ReadOnlyAge: s.ReadOnlyAge,
|
||||||
EmailNotifications: s.EmailNotifications,
|
MaxImageSize: s.ImageService.MaxSize,
|
||||||
TelegramBotUsername: s.TelegramBotUsername,
|
EmailNotifications: s.EmailNotifications,
|
||||||
EmojiEnabled: s.EmojiEnabled,
|
TelegramNotifications: s.TelegramNotifications,
|
||||||
AnonVote: s.AnonVote,
|
EmojiEnabled: s.EmojiEnabled,
|
||||||
SimpleView: s.SimpleView,
|
AnonVote: s.AnonVote,
|
||||||
SendJWTHeader: s.SendJWTHeader,
|
SimpleView: s.SimpleView,
|
||||||
SubscribersOnly: s.SubscribersOnly,
|
SendJWTHeader: s.SendJWTHeader,
|
||||||
|
SubscribersOnly: s.SubscribersOnly,
|
||||||
}
|
}
|
||||||
|
|
||||||
cnf.Auth = []string{}
|
cnf.Auth = []string{}
|
||||||
@@ -462,30 +504,39 @@ func (s *Rest) configCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
if cnf.Admins == nil { // prevent json serialization to nil
|
if cnf.Admins == nil { // prevent json serialization to nil
|
||||||
cnf.Admins = []string{}
|
cnf.Admins = []string{}
|
||||||
}
|
}
|
||||||
render.Status(r, http.StatusOK)
|
R.RenderJSON(w, cnf)
|
||||||
render.JSON(w, r, cnf)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// serves static files from the webRoot directory or files embedded into the compiled binary if that directory is absent
|
// serves /web from the frontend build, falling back to the assets embedded in the binary for
|
||||||
func addFileServer(r chi.Router, embedFS embed.FS, webRoot, version string) {
|
// names the build does not produce. the frontend build is read from webRoot on disk, or from the
|
||||||
var webFS http.Handler
|
// copy embedded at app/cmd/web when that directory is absent.
|
||||||
|
func addFileServer(r *routegroup.Bundle, embeddedFrontend fs.FS, webRoot, version, remarkURL string) {
|
||||||
|
frontendFS := embeddedFrontend
|
||||||
|
|
||||||
if _, err := os.Stat(webRoot); err == nil {
|
if _, err := os.Stat(webRoot); err == nil {
|
||||||
log.Printf("[INFO] run file server from %s from the disk", webRoot)
|
log.Printf("[INFO] run file server from %s from the disk", webRoot)
|
||||||
webFS = http.FileServer(http.Dir(webRoot))
|
frontendFS = os.DirFS(webRoot)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("[INFO] run file server, embedded")
|
log.Printf("[INFO] run file server, embedded")
|
||||||
var contentFS, _ = fs.Sub(embedFS, "web")
|
|
||||||
webFS = http.FileServer(http.FS(contentFS))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
webFS = http.StripPrefix("/web", webFS)
|
// wrapped rather than substituted once at startup: the disk root can change under a running
|
||||||
r.Get("/web", http.RedirectHandler("/web/", http.StatusMovedPermanently).ServeHTTP)
|
// server, and the docker image has already substituted its copy, where this is a no-op
|
||||||
|
sources := templatedFS{
|
||||||
|
fs: webFiles{frontend: frontendFS, embedded: webassets.FS},
|
||||||
|
remarkURL: remarkURL,
|
||||||
|
}
|
||||||
|
webFS := http.StripPrefix("/web", http.FileServer(http.FS(sources)))
|
||||||
|
r.HandleFunc("GET /web", http.RedirectHandler("/web/", http.StatusMovedPermanently).ServeHTTP)
|
||||||
|
|
||||||
r.With(tollbooth_chi.LimitHandler(tollbooth.NewLimiter(20, nil)),
|
r.With(rateLimiter(20),
|
||||||
middleware.Timeout(10*time.Second),
|
R.Timeout(10*time.Second),
|
||||||
cacheControl(time.Hour, version),
|
// the served body now depends on remarkURL, so it has to be part of the validator. Without
|
||||||
).Get("/web/*", func(w http.ResponseWriter, r *http.Request) {
|
// it an operator who corrects a wrong REMARK_URL and restarts the same binary keeps getting
|
||||||
|
// 304 on revalidation, and the client keeps a bundle addressed to the old host for good,
|
||||||
|
// since no-cache means it revalidates rather than aging out
|
||||||
|
cacheControl(time.Hour, version+":"+remarkURL),
|
||||||
|
).HandleFunc("GET /web/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
// don't show dirs, just serve files
|
// don't show dirs, just serve files
|
||||||
if strings.HasSuffix(r.URL.Path, "/") && len(r.URL.Path) > 1 && r.URL.Path != ("/web/") {
|
if strings.HasSuffix(r.URL.Path, "/") && len(r.URL.Path) > 1 && r.URL.Path != ("/web/") {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
@@ -495,7 +546,7 @@ func addFileServer(r chi.Router, embedFS embed.FS, webRoot, version string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func encodeJSONWithHTML(v interface{}) ([]byte, error) {
|
func encodeJSONWithHTML(v any) ([]byte, error) {
|
||||||
buf := &bytes.Buffer{}
|
buf := &bytes.Buffer{}
|
||||||
enc := json.NewEncoder(buf)
|
enc := json.NewEncoder(buf)
|
||||||
enc.SetEscapeHTML(false)
|
enc.SetEscapeHTML(false)
|
||||||
@@ -541,157 +592,6 @@ func URLKeyWithUser(r *http.Request) string {
|
|||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
// rejectAnonUser is a middleware rejecting anonymous users
|
|
||||||
func rejectAnonUser(next http.Handler) http.Handler {
|
|
||||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user, err := rest.GetUserInfo(r)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(user.ID, "anonymous_") {
|
|
||||||
http.Error(w, "Access denied", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
return http.HandlerFunc(fn)
|
|
||||||
}
|
|
||||||
|
|
||||||
// matchSiteID is a middleware rejecting users with mismatch between site param and and User.SiteID
|
|
||||||
func matchSiteID(next http.Handler) http.Handler {
|
|
||||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
user, err := rest.GetUserInfo(r)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// skip for basic auth user
|
|
||||||
if user.Name == "admin" && user.ID == "admin" {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
siteID := r.URL.Query().Get("site")
|
|
||||||
if siteID != "" && user.SiteID != siteID {
|
|
||||||
http.Error(w, "Access denied", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
return http.HandlerFunc(fn)
|
|
||||||
}
|
|
||||||
|
|
||||||
// cacheControl is a middleware setting cache expiration. Using url+version as etag
|
|
||||||
func cacheControl(expiration time.Duration, version string) func(http.Handler) http.Handler {
|
|
||||||
etag := func(r *http.Request, version string) string {
|
|
||||||
s := version + ":" + r.URL.String()
|
|
||||||
return store.EncodeID(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
return func(h http.Handler) http.Handler {
|
|
||||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
e := `"` + etag(r, version) + `"`
|
|
||||||
w.Header().Set("Etag", e)
|
|
||||||
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%d, no-cache", int(expiration.Seconds())))
|
|
||||||
|
|
||||||
if match := r.Header.Get("If-None-Match"); match != "" {
|
|
||||||
if strings.Contains(match, e) {
|
|
||||||
w.WriteHeader(http.StatusNotModified)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
return http.HandlerFunc(fn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// frameAncestors is a middleware setting Content-Security-Policy "frame-ancestors host1 host2 ..."
|
|
||||||
// prevents loading of comments widgets from any other origins. In case if the list of allowed empty, ignored.
|
|
||||||
func frameAncestors(hosts []string) func(http.Handler) http.Handler {
|
|
||||||
return func(h http.Handler) http.Handler {
|
|
||||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if len(hosts) == 0 {
|
|
||||||
h.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Security-Policy", "frame-ancestors "+strings.Join(hosts, " ")+";")
|
|
||||||
h.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
return http.HandlerFunc(fn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// subscribersOnly is a middleware rejecting non-paid_sub users
|
|
||||||
func subscribersOnly(enable bool) func(http.Handler) http.Handler {
|
|
||||||
return func(h http.Handler) http.Handler {
|
|
||||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if enable {
|
|
||||||
user, err := rest.GetUserInfo(r)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !user.PaidSub {
|
|
||||||
http.Error(w, "Access denied", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
return http.HandlerFunc(fn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// validEmailAuth is a middleware for auth endpoints for email method.
|
|
||||||
// it rejects login request if user, site or email are suspicious
|
|
||||||
func validEmailAuth() func(http.Handler) http.Handler {
|
|
||||||
|
|
||||||
reUser := regexp.MustCompile(`^[\p{L}\d\s_]{4,64}$`) // matches ui side validation, adding min/max limitation
|
|
||||||
reSite := regexp.MustCompile(`^[a-zA-Z\d\s_.-]{1,64}$`)
|
|
||||||
|
|
||||||
return func(h http.Handler) http.Handler {
|
|
||||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
|
||||||
if r.URL.Path != "/auth/email/login" {
|
|
||||||
// not email login, skip the check
|
|
||||||
h.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if u := r.URL.Query().Get("user"); u != "" {
|
|
||||||
if !reUser.MatchString(u) {
|
|
||||||
log.Printf("[WARN] suspicious user rejected: %s", u)
|
|
||||||
http.Error(w, "Access denied", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if a := r.URL.Query().Get("address"); a != "" {
|
|
||||||
if _, err := mail.ParseAddress(a); err != nil {
|
|
||||||
log.Printf("[WARN] suspicious address rejected: %s", a)
|
|
||||||
http.Error(w, "Access denied", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if s := r.URL.Query().Get("site"); s != "" {
|
|
||||||
if !reSite.MatchString(s) {
|
|
||||||
log.Printf("[WARN] suspicious site rejected: %s", s)
|
|
||||||
http.Error(w, "Access denied", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
return http.HandlerFunc(fn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseError(err error, defaultCode int) (code int) {
|
func parseError(err error, defaultCode int) (code int) {
|
||||||
code = defaultCode
|
code = defaultCode
|
||||||
|
|
||||||
|
|||||||
@@ -6,22 +6,21 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha1" //nolint:gosec //not used for security
|
"crypto/sha1" //nolint:gosec //not used for security
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-pkgz/auth/v2"
|
||||||
"github.com/go-chi/render"
|
"github.com/go-pkgz/auth/v2/token"
|
||||||
"github.com/go-pkgz/auth"
|
cache "github.com/go-pkgz/lcw/v2"
|
||||||
"github.com/go-pkgz/auth/token"
|
|
||||||
cache "github.com/go-pkgz/lcw"
|
|
||||||
log "github.com/go-pkgz/lgr"
|
log "github.com/go-pkgz/lgr"
|
||||||
R "github.com/go-pkgz/rest"
|
R "github.com/go-pkgz/rest"
|
||||||
"github.com/golang-jwt/jwt"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/hashicorp/go-multierror"
|
|
||||||
|
|
||||||
"github.com/umputun/remark42/backend/app/notify"
|
"github.com/umputun/remark42/backend/app/notify"
|
||||||
"github.com/umputun/remark42/backend/app/rest"
|
"github.com/umputun/remark42/backend/app/rest"
|
||||||
@@ -33,16 +32,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type private struct {
|
type private struct {
|
||||||
dataService privStore
|
dataService privStore
|
||||||
cache LoadingCache
|
cache LoadingCache
|
||||||
readOnlyAge int
|
readOnlyAge int
|
||||||
commentFormatter *store.CommentFormatter
|
commentFormatter *store.CommentFormatter
|
||||||
imageService *image.Service
|
imageService *image.Service
|
||||||
notifyService *notify.Service
|
notifyService *notify.Service
|
||||||
authenticator *auth.Service
|
authenticator *auth.Service
|
||||||
telegramService telegramService
|
telegramService telegramService
|
||||||
remarkURL string
|
remarkURL string
|
||||||
anonVote bool
|
anonVote bool
|
||||||
|
disableFancyTextFormatting bool // disables SmartyPants in the comment text rendering of the posted comments
|
||||||
}
|
}
|
||||||
|
|
||||||
// telegramService is a subset of Telegram service used for setting up user telegram notifications
|
// telegramService is a subset of Telegram service used for setting up user telegram notifications
|
||||||
@@ -75,7 +75,7 @@ func (s *private) previewCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
user := rest.MustGetUserInfo(r)
|
user := rest.MustGetUserInfo(r)
|
||||||
|
|
||||||
comment := store.Comment{}
|
comment := store.Comment{}
|
||||||
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &comment); err != nil {
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, hardBodyLimit)).Decode(&comment); err != nil {
|
||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't bind comment", rest.ErrDecode)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't bind comment", rest.ErrDecode)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -87,25 +87,24 @@ func (s *private) previewCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
comment = s.commentFormatter.Format(comment)
|
comment = s.commentFormatter.Format(comment, s.disableFancyTextFormatting)
|
||||||
comment.Sanitize()
|
comment.Sanitize()
|
||||||
|
|
||||||
// check if images are valid
|
// check if images are valid, omit proxied images as they are lazy-loaded
|
||||||
for _, id := range s.imageService.ExtractPictures(comment.Text) {
|
for _, id := range s.imageService.ExtractNonProxiedPictures(comment.Text) {
|
||||||
err := s.imageService.ResetCleanupTimer(id)
|
err := s.imageService.ResetCleanupTimer(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't renew staged picture cleanup timer", rest.ErrImgNotFound)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't load picture from the comment", rest.ErrImgNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
rest.HTMLResponse(w, http.StatusOK, comment.Text)
|
||||||
render.HTML(w, r, comment.Text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /comment - adds comment, resets all immutable fields
|
// POST /comment - adds comment, resets all immutable fields
|
||||||
func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
||||||
comment := store.Comment{}
|
comment := store.Comment{}
|
||||||
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &comment); err != nil {
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, hardBodyLimit)).Decode(&comment); err != nil {
|
||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't bind comment", rest.ErrDecode)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't bind comment", rest.ErrDecode)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -120,17 +119,17 @@ func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
comment.PrepareUntrusted() // clean all fields user not supposed to set
|
comment.PrepareUntrusted() // clean all fields user not supposed to set
|
||||||
comment.User = user
|
comment.User = user
|
||||||
comment.User.IP = strings.Split(r.RemoteAddr, ":")[0]
|
comment.User.IP = extractIP(r.RemoteAddr)
|
||||||
|
|
||||||
comment.Orig = comment.Text // original comment text, prior to md render
|
comment.Orig = comment.Text // original comment text, prior to md render
|
||||||
if err := s.dataService.ValidateComment(&comment); err != nil {
|
if err := s.dataService.ValidateComment(&comment); err != nil {
|
||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
comment = s.commentFormatter.Format(comment)
|
comment = s.commentFormatter.Format(comment, s.disableFancyTextFormatting)
|
||||||
|
|
||||||
// check if images are valid
|
// check if images are valid, omit proxied images as they are lazy-loaded
|
||||||
for _, id := range s.imageService.ExtractPictures(comment.Text) {
|
for _, id := range s.imageService.ExtractNonProxiedPictures(comment.Text) {
|
||||||
_, err := s.imageService.Load(id)
|
_, err := s.imageService.Load(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't load picture from the comment", rest.ErrImgNotFound)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't load picture from the comment", rest.ErrImgNotFound)
|
||||||
@@ -150,7 +149,7 @@ func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
id, err := s.dataService.Create(comment)
|
id, err := s.dataService.Create(comment)
|
||||||
if err == service.ErrRestrictedWordsFound {
|
if errors.Is(err, service.ErrRestrictedWordsFound) {
|
||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentRestrictWords)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentRestrictWords)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -174,8 +173,7 @@ func (s *private) createCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
log.Printf("[DEBUG] created comment %+v", finalComment)
|
log.Printf("[DEBUG] created comment %+v", finalComment)
|
||||||
|
|
||||||
render.Status(r, http.StatusCreated)
|
_ = R.EncodeJSON(w, http.StatusCreated, &finalComment)
|
||||||
render.JSON(w, r, &finalComment)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /comment/{id}?site=siteID&url=post-url - update comment
|
// PUT /comment/{id}?site=siteID&url=post-url - update comment
|
||||||
@@ -186,14 +184,14 @@ func (s *private) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
Delete bool
|
Delete bool
|
||||||
}{}
|
}{}
|
||||||
|
|
||||||
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, hardBodyLimit), &edit); err != nil {
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, hardBodyLimit)).Decode(&edit); err != nil {
|
||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't bind comment", rest.ErrDecode)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't read comment details from body", rest.ErrDecode)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := rest.MustGetUserInfo(r)
|
user := rest.MustGetUserInfo(r)
|
||||||
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
||||||
id := chi.URLParam(r, "id")
|
id := r.PathValue("id")
|
||||||
|
|
||||||
log.Printf("[DEBUG] update comment %s", id)
|
log.Printf("[DEBUG] update comment %s", id)
|
||||||
|
|
||||||
@@ -211,7 +209,7 @@ func (s *private) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
editReq := service.EditRequest{
|
editReq := service.EditRequest{
|
||||||
Text: s.commentFormatter.FormatText(edit.Text),
|
Text: s.commentFormatter.FormatText(edit.Text, s.disableFancyTextFormatting),
|
||||||
Orig: edit.Text,
|
Orig: edit.Text,
|
||||||
Summary: edit.Summary,
|
Summary: edit.Summary,
|
||||||
Delete: edit.Delete,
|
Delete: edit.Delete,
|
||||||
@@ -219,7 +217,7 @@ func (s *private) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res, err := s.dataService.EditComment(locator, id, editReq)
|
res, err := s.dataService.EditComment(locator, id, editReq)
|
||||||
if err == service.ErrRestrictedWordsFound {
|
if errors.Is(err, service.ErrRestrictedWordsFound) {
|
||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "invalid comment", rest.ErrCommentValidation)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -231,7 +229,7 @@ func (s *private) updateCommentCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.SiteID, locator.URL, lastCommentsScope, user.ID))
|
s.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.SiteID, locator.URL, lastCommentsScope, user.ID))
|
||||||
render.JSON(w, r, res)
|
R.RenderJSON(w, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /user?site=siteID - returns user info
|
// GET /user?site=siteID - returns user info
|
||||||
@@ -244,12 +242,12 @@ func (s *private) userInfoCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[WARN] can't read email for %s, %v", user.ID, err)
|
log.Printf("[WARN] can't read email for %s, %v", user.ID, err)
|
||||||
}
|
}
|
||||||
if len(email) > 0 {
|
if email != "" {
|
||||||
user.EmailSubscription = true
|
user.EmailSubscription = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, user)
|
R.RenderJSON(w, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /vote/{id}?site=siteID&url=post-url&vote=1 - vote for/against comment
|
// PUT /vote/{id}?site=siteID&url=post-url&vote=1 - vote for/against comment
|
||||||
@@ -260,7 +258,7 @@ func (s *private) voteCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
||||||
id := chi.URLParam(r, "id")
|
id := r.PathValue("id")
|
||||||
log.Printf("[DEBUG] vote for comment %s", id)
|
log.Printf("[DEBUG] vote for comment %s", id)
|
||||||
|
|
||||||
vote := r.URL.Query().Get("vote") == "1"
|
vote := r.URL.Query().Get("vote") == "1"
|
||||||
@@ -280,7 +278,7 @@ func (s *private) voteCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
Locator: locator,
|
Locator: locator,
|
||||||
CommentID: id,
|
CommentID: id,
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
UserIP: strings.Split(r.RemoteAddr, ":")[0],
|
UserIP: extractIP(r.RemoteAddr),
|
||||||
Val: vote,
|
Val: vote,
|
||||||
}
|
}
|
||||||
comment, err := s.dataService.Vote(req)
|
comment, err := s.dataService.Vote(req)
|
||||||
@@ -290,7 +288,7 @@ func (s *private) voteCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL, comment.User.ID))
|
s.cache.Flush(cache.Flusher(locator.SiteID).Scopes(locator.URL, comment.User.ID))
|
||||||
render.JSON(w, r, R.JSON{"id": comment.ID, "score": comment.Score})
|
R.RenderJSON(w, R.JSON{"id": comment.ID, "score": comment.Score})
|
||||||
}
|
}
|
||||||
|
|
||||||
// getEmailCtrl gets email address for authenticated user.
|
// getEmailCtrl gets email address for authenticated user.
|
||||||
@@ -303,36 +301,67 @@ func (s *private) getEmailCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Printf("[WARN] can't read email for %s, %v", user.ID, err)
|
log.Printf("[WARN] can't read email for %s, %v", user.ID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, R.JSON{"user": user, "address": address})
|
R.RenderJSON(w, R.JSON{"user": user, "address": address})
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendEmailConfirmationCtrl gets address and siteID from query, makes confirmation token and sends it to user.
|
// sendEmailConfirmationCtrl gets address and siteID from query, makes confirmation token and sends it to user.
|
||||||
// GET /email/subscribe?site=siteID&address=someone@example.com
|
// In case user is logged in with the same email, and auto_confirm is true, confirm it right away.
|
||||||
|
// In case of quick confirmation, "updated" is set to true, otherwise - to false.
|
||||||
|
// POST /email/subscribe with site and address in json body
|
||||||
|
//
|
||||||
//nolint:dupl // too hard to deduplicate that logic, as then it's tricky to use SendErrorJSON
|
//nolint:dupl // too hard to deduplicate that logic, as then it's tricky to use SendErrorJSON
|
||||||
func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Request) {
|
func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Request) {
|
||||||
user := rest.MustGetUserInfo(r)
|
user := rest.MustGetUserInfo(r)
|
||||||
address := r.URL.Query().Get("address")
|
|
||||||
siteID := r.URL.Query().Get("site")
|
subscribe := struct {
|
||||||
if address == "" {
|
Site string
|
||||||
|
Address string
|
||||||
|
autoConfirm bool
|
||||||
|
}{autoConfirm: true}
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, hardBodyLimit)).Decode(&subscribe); err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't parse request body", rest.ErrDecode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// old behavior fallback, reading from the query params. Auto confirm is false in this case.
|
||||||
|
subscribe.Address = r.URL.Query().Get("address")
|
||||||
|
subscribe.Site = r.URL.Query().Get("site")
|
||||||
|
subscribe.autoConfirm = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if subscribe.Address == "" {
|
||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest,
|
rest.SendErrorJSON(w, r, http.StatusBadRequest,
|
||||||
fmt.Errorf("missing parameter"), "address parameter is required", rest.ErrInternal)
|
fmt.Errorf("missing parameter"), "address parameter is required", rest.ErrInternal)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
existingAddress, err := s.dataService.GetUserEmail(siteID, user.ID)
|
existingAddress, getErr := s.dataService.GetUserEmail(subscribe.Site, user.ID)
|
||||||
if err != nil {
|
if getErr != nil {
|
||||||
log.Printf("[WARN] can't read email for %s, %v", user.ID, err)
|
log.Printf("[WARN] can't read email for %s, %v", user.ID, getErr)
|
||||||
}
|
}
|
||||||
if address == existingAddress {
|
if subscribe.Address == existingAddress {
|
||||||
rest.SendErrorJSON(w, r, http.StatusConflict,
|
rest.SendErrorJSON(w, r, http.StatusConflict,
|
||||||
fmt.Errorf("already verified"), "email address is already verified for this user", rest.ErrInternal)
|
fmt.Errorf("already verified"), "email address is already verified for this user", rest.ErrInternal)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// in case the user logged in with the same email as they try to subscribe with, confirm it right away
|
||||||
|
// this behavior is different from the previous one and is hidden behind the autoConfirm flag,
|
||||||
|
// which is true for the new API, and false for the old one
|
||||||
|
//
|
||||||
|
// nolint:gosec // this is not used for security purposes
|
||||||
|
if subscribe.autoConfirm &&
|
||||||
|
strings.HasPrefix(user.ID, "email_") &&
|
||||||
|
strings.TrimPrefix(user.ID, "email_") == token.HashID(sha1.New(), subscribe.Address) {
|
||||||
|
s.setEmail(w, r, user.ID, subscribe.Site, subscribe.Address)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
claims := token.Claims{
|
claims := token.Claims{
|
||||||
Handshake: &token.Handshake{ID: user.ID + "::" + address},
|
Handshake: &token.Handshake{ID: user.ID + "::" + subscribe.Address},
|
||||||
StandardClaims: jwt.StandardClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Audience: r.URL.Query().Get("site"),
|
Audience: jwt.ClaimStrings{r.URL.Query().Get("site")},
|
||||||
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)),
|
||||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||||
Issuer: "remark42",
|
Issuer: "remark42",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -345,14 +374,14 @@ func (s *private) sendEmailConfirmationCtrl(w http.ResponseWriter, r *http.Reque
|
|||||||
|
|
||||||
s.notifyService.SubmitVerification(
|
s.notifyService.SubmitVerification(
|
||||||
notify.VerificationRequest{
|
notify.VerificationRequest{
|
||||||
SiteID: siteID,
|
SiteID: subscribe.Site,
|
||||||
User: user.Name,
|
User: user.Name,
|
||||||
Email: address,
|
Email: subscribe.Address,
|
||||||
Token: tkn,
|
Token: tkn,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
render.JSON(w, r, R.JSON{"user": user, "address": address})
|
R.RenderJSON(w, R.JSON{"user": user, "address": subscribe.Address, "updated": false})
|
||||||
}
|
}
|
||||||
|
|
||||||
// telegramSubscribeCtrl generates and verifies telegram notification request
|
// telegramSubscribeCtrl generates and verifies telegram notification request
|
||||||
@@ -380,7 +409,7 @@ func (s *private) telegramSubscribeCtrl(w http.ResponseWriter, r *http.Request)
|
|||||||
fmt.Errorf("already subscribed"), "telegram subscription is already set for this user, delete if first to re-subscribe", rest.ErrActionRejected)
|
fmt.Errorf("already subscribed"), "telegram subscription is already set for this user, delete if first to re-subscribe", rest.ErrActionRejected)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Generate and send token
|
// generate and send token
|
||||||
tkn, err := randToken()
|
tkn, err := randToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to generate verification token", rest.ErrInternal)
|
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to generate verification token", rest.ErrInternal)
|
||||||
@@ -390,7 +419,7 @@ func (s *private) telegramSubscribeCtrl(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
s.telegramService.AddToken(tkn, user.ID, siteID, expires)
|
s.telegramService.AddToken(tkn, user.ID, siteID, expires)
|
||||||
|
|
||||||
render.JSON(w, r, R.JSON{"token": tkn, "bot": s.telegramService.GetBotUsername()})
|
R.RenderJSON(w, R.JSON{"token": tkn, "bot": s.telegramService.GetBotUsername()})
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -399,7 +428,7 @@ func (s *private) telegramSubscribeCtrl(w http.ResponseWriter, r *http.Request)
|
|||||||
var address, siteID string
|
var address, siteID string
|
||||||
address, siteID, err := s.telegramService.CheckToken(queryToken, user.ID)
|
address, siteID, err := s.telegramService.CheckToken(queryToken, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't set telegram for user", rest.ErrInternal)
|
rest.SendErrorJSON(w, r, http.StatusNotFound, err, "request is not verified yet", rest.ErrInternal)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,21 +441,33 @@ func (s *private) telegramSubscribeCtrl(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, R.JSON{"updated": true, "address": val})
|
R.RenderJSON(w, R.JSON{"updated": true, "address": val})
|
||||||
}
|
}
|
||||||
|
|
||||||
// setConfirmedEmailCtrl uses provided token parameter (generated by sendEmailConfirmationCtrl) to set email and add it to user token
|
// setConfirmedEmailCtrl uses provided token parameter (generated by sendEmailConfirmationCtrl) to set email and add it to user token
|
||||||
// PUT /email/confirm?site=siteID&tkn=jwt
|
// POST /email/confirm with site and token in json body
|
||||||
func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request) {
|
func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request) {
|
||||||
tkn := r.URL.Query().Get("tkn")
|
user := rest.MustGetUserInfo(r)
|
||||||
if tkn == "" {
|
|
||||||
|
confirm := struct {
|
||||||
|
Site string
|
||||||
|
Token string
|
||||||
|
}{}
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, hardBodyLimit)).Decode(&confirm); err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't parse request body", rest.ErrDecode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// old behavior fallback, reading from the query params
|
||||||
|
confirm.Token = r.URL.Query().Get("tkn")
|
||||||
|
confirm.Site = r.URL.Query().Get("site")
|
||||||
|
}
|
||||||
|
|
||||||
|
if confirm.Token == "" {
|
||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("missing parameter"), "token parameter is required", rest.ErrInternal)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("missing parameter"), "token parameter is required", rest.ErrInternal)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user := rest.MustGetUserInfo(r)
|
confClaims, err := s.authenticator.TokenService().Parse(confirm.Token)
|
||||||
siteID := r.URL.Query().Get("site")
|
|
||||||
|
|
||||||
confClaims, err := s.authenticator.TokenService().Parse(tkn)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
|
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
|
||||||
return
|
return
|
||||||
@@ -437,24 +478,27 @@ func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handshake.ID is user.ID + "::" + address
|
// handshake.ID is user.ID + "::" + address
|
||||||
elems := strings.Split(confClaims.Handshake.ID, "::")
|
elems := strings.Split(confClaims.Handshake.ID, "::")
|
||||||
if len(elems) != 2 || elems[0] != user.ID {
|
if len(elems) != 2 || elems[0] != user.ID {
|
||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("%s", confClaims.Handshake.ID), "invalid handshake token", rest.ErrInternal)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, fmt.Errorf("%s", confClaims.Handshake.ID), "invalid handshake token", rest.ErrInternal)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
address := elems[1]
|
address := elems[1]
|
||||||
|
s.setEmail(w, r, user.ID, confirm.Site, address)
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("[DEBUG] set email for user %s", user.ID)
|
func (s *private) setEmail(w http.ResponseWriter, r *http.Request, userID, siteID, address string) {
|
||||||
|
log.Printf("[DEBUG] set email for user %s", userID)
|
||||||
|
|
||||||
val, err := s.dataService.SetUserEmail(siteID, user.ID, address)
|
val, err := s.dataService.SetUserEmail(siteID, userID, address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
code := parseError(err, rest.ErrInternal)
|
code := parseError(err, rest.ErrInternal)
|
||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set email for user", code)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't set email for user", code)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// update User.Email from the token
|
// update User.Email field
|
||||||
claims, _, err := s.authenticator.TokenService().Get(r)
|
claims, _, err := s.authenticator.TokenService().Get(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
|
rest.SendErrorJSON(w, r, http.StatusForbidden, err, "failed to verify confirmation token", rest.ErrInternal)
|
||||||
@@ -465,7 +509,7 @@ func (s *private) setConfirmedEmailCtrl(w http.ResponseWriter, r *http.Request)
|
|||||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to set token", rest.ErrInternal)
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "failed to set token", rest.ErrInternal)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
render.JSON(w, r, R.JSON{"updated": true, "address": val})
|
R.RenderJSON(w, R.JSON{"updated": true, "address": val})
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST/GET /email/unsubscribe.html?site=siteID&tkn=jwt - unsubscribe the user in token from email notifications
|
// POST/GET /email/unsubscribe.html?site=siteID&tkn=jwt - unsubscribe the user in token from email notifications
|
||||||
@@ -488,7 +532,7 @@ func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handshake.ID is user.ID + "::" + address
|
// handshake.ID is user.ID + "::" + address
|
||||||
elems := strings.Split(confClaims.Handshake.ID, "::")
|
elems := strings.Split(confClaims.Handshake.ID, "::")
|
||||||
if len(elems) != 2 {
|
if len(elems) != 2 {
|
||||||
rest.SendErrorHTML(w, r, http.StatusBadRequest, fmt.Errorf("%s", confClaims.Handshake.ID), "invalid handshake token", rest.ErrInternal)
|
rest.SendErrorHTML(w, r, http.StatusBadRequest, fmt.Errorf("%s", confClaims.Handshake.ID), "invalid handshake token", rest.ErrInternal)
|
||||||
@@ -533,7 +577,7 @@ func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MustExecute behaves like template.Execute, but panics if an error occurs.
|
// MustExecute behaves like template.Execute, but panics if an error occurs.
|
||||||
MustExecute := func(tmpl *template.Template, wr io.Writer, data interface{}) {
|
MustExecute := func(tmpl *template.Template, wr io.Writer, data any) {
|
||||||
if err := tmpl.Execute(wr, data); err != nil {
|
if err := tmpl.Execute(wr, data); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -549,7 +593,7 @@ func (s *private) emailUnsubscribeCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
tmpl := template.Must(template.New("unsubscribe").Parse(tmplstr))
|
tmpl := template.Must(template.New("unsubscribe").Parse(tmplstr))
|
||||||
msg := bytes.Buffer{}
|
msg := bytes.Buffer{}
|
||||||
MustExecute(tmpl, &msg, nil)
|
MustExecute(tmpl, &msg, nil)
|
||||||
render.HTML(w, r, msg.String())
|
rest.HTMLResponse(w, http.StatusOK, msg.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /email?site=siteID - removes user's email
|
// DELETE /email?site=siteID - removes user's email
|
||||||
@@ -576,7 +620,7 @@ func (s *private) deleteEmailCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
render.JSON(w, r, R.JSON{"deleted": true})
|
R.RenderJSON(w, R.JSON{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /telegram?site=siteID - removes user's telegram
|
// DELETE /telegram?site=siteID - removes user's telegram
|
||||||
@@ -590,7 +634,7 @@ func (s *private) deleteTelegramCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete telegram for user", code)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't delete telegram for user", code)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
render.JSON(w, r, R.JSON{"deleted": true})
|
R.RenderJSON(w, R.JSON{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /userdata?site=siteID - exports all data about the user as a json with user info and list of all comments
|
// GET /userdata?site=siteID - exports all data about the user as a json with user info and list of all comments
|
||||||
@@ -618,13 +662,11 @@ func (s *private) userAllDataCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
var merr error
|
// send user prefix, user info and comments prefix
|
||||||
merr = multierror.Append(merr, write([]byte(`{"info": `))) // send user prefix
|
errs := []error{write([]byte(`{"info": `)), write(userB), write([]byte(`, "comments":`))}
|
||||||
merr = multierror.Append(merr, write(userB)) // send user info
|
|
||||||
merr = multierror.Append(merr, write([]byte(`, "comments":`))) // send comments prefix
|
|
||||||
|
|
||||||
// get comments in 100 in each paginated request
|
// get comments in 100 in each paginated request
|
||||||
for i := 0; i < 100; i++ {
|
for i := range 100 {
|
||||||
comments, errUser := s.dataService.User(siteID, user.ID, 100, i*100, rest.GetUserOrEmpty(r))
|
comments, errUser := s.dataService.User(siteID, user.ID, 100, i*100, rest.GetUserOrEmpty(r))
|
||||||
if errUser != nil {
|
if errUser != nil {
|
||||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, errUser, "can't get user comments", rest.ErrInternal)
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, errUser, "can't get user comments", rest.ErrInternal)
|
||||||
@@ -636,15 +678,15 @@ func (s *private) userAllDataCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
merr = multierror.Append(merr, write(b))
|
errs = append(errs, write(b))
|
||||||
if len(comments) != 100 {
|
if len(comments) != 100 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
merr = multierror.Append(merr, write([]byte(`}`)))
|
errs = append(errs, write([]byte(`}`)))
|
||||||
if merr.(*multierror.Error).ErrorOrNil() != nil {
|
if err := errors.Join(errs...); err != nil {
|
||||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, merr, "can't write user info", rest.ErrInternal)
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't write user info", rest.ErrInternal)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -656,16 +698,17 @@ func (s *private) deleteMeCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
siteID := r.URL.Query().Get("site")
|
siteID := r.URL.Query().Get("site")
|
||||||
|
|
||||||
claims := token.Claims{
|
claims := token.Claims{
|
||||||
StandardClaims: jwt.StandardClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Audience: siteID,
|
Audience: jwt.ClaimStrings{siteID},
|
||||||
Issuer: "remark42",
|
Issuer: "remark42",
|
||||||
ExpiresAt: time.Now().AddDate(0, 3, 0).Unix(),
|
ExpiresAt: jwt.NewNumericDate(time.Now().AddDate(0, 3, 0)),
|
||||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||||
},
|
},
|
||||||
User: &token.User{
|
User: &token.User{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Name: user.Name,
|
Name: user.Name,
|
||||||
Attributes: map[string]interface{}{
|
Picture: user.Picture, // carried so the avatar can be removed when the request is processed
|
||||||
|
Attributes: map[string]any{
|
||||||
"delete_me": true, // prevents this token from being used for login
|
"delete_me": true, // prevents this token from being used for login
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -678,14 +721,18 @@ func (s *private) deleteMeCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
link := fmt.Sprintf("%s/web/deleteme.html?token=%s", s.remarkURL, tokenStr)
|
link := fmt.Sprintf("%s/web/deleteme.html?token=%s", s.remarkURL, tokenStr)
|
||||||
render.JSON(w, r, R.JSON{"site": siteID, "user_id": user.ID, "token": tokenStr, "link": link})
|
R.RenderJSON(w, R.JSON{"site": siteID, "user_id": user.ID, "token": tokenStr, "link": link})
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /image - save image with form request
|
// POST /image - save image with form request
|
||||||
func (s *private) savePictureCtrl(w http.ResponseWriter, r *http.Request) {
|
func (s *private) savePictureCtrl(w http.ResponseWriter, r *http.Request) {
|
||||||
user := rest.MustGetUserInfo(r)
|
user := rest.MustGetUserInfo(r)
|
||||||
|
|
||||||
if err := r.ParseMultipartForm(5 * 1024 * 1024); err != nil { // 5M max memory, if bigger will make a file
|
r.Body = http.MaxBytesReader(w, r.Body, 32*1024*1024) // hard cap on upload to prevent memory exhaustion
|
||||||
|
// gosec G120: r.Body is already bounded by MaxBytesReader on the line above (32 MB),
|
||||||
|
// so ParseMultipartForm cannot read more than that regardless of the in-memory threshold.
|
||||||
|
// The 5 MB argument is the soft threshold above which the form is spilled to disk.
|
||||||
|
if err := r.ParseMultipartForm(5 * 1024 * 1024); err != nil { //nolint:gosec // bounded by MaxBytesReader above
|
||||||
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't parse multipart form", rest.ErrDecode)
|
rest.SendErrorJSON(w, r, http.StatusInternalServerError, err, "can't parse multipart form", rest.ErrDecode)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -703,7 +750,7 @@ func (s *private) savePictureCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
render.JSON(w, r, R.JSON{"id": id})
|
R.RenderJSON(w, R.JSON{"id": id})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *private) isReadOnly(locator store.Locator) bool {
|
func (s *private) isReadOnly(locator store.Locator) bool {
|
||||||
@@ -727,3 +774,13 @@ func randToken() (string, error) {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("%x", s.Sum(nil)), nil
|
return fmt.Sprintf("%x", s.Sum(nil)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractIP returns the IP portion of the remote address, handling both IPv4 and IPv6 formats.
|
||||||
|
// supports "ip:port", "[ip]:port", and bare "ip" formats.
|
||||||
|
func extractIP(remoteAddr string) string {
|
||||||
|
ip, _, err := net.SplitHostPort(remoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return remoteAddr // already a bare IP (no port)
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,20 +10,21 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/render"
|
"github.com/go-pkgz/auth/v2/token"
|
||||||
"github.com/go-pkgz/auth/token"
|
|
||||||
"github.com/go-pkgz/lgr"
|
"github.com/go-pkgz/lgr"
|
||||||
R "github.com/go-pkgz/rest"
|
R "github.com/go-pkgz/rest"
|
||||||
"github.com/golang-jwt/jwt"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/umputun/remark42/backend/app/notify"
|
"github.com/umputun/remark42/backend/app/notify"
|
||||||
|
"github.com/umputun/remark42/backend/app/rest/proxy"
|
||||||
"github.com/umputun/remark42/backend/app/store"
|
"github.com/umputun/remark42/backend/app/store"
|
||||||
"github.com/umputun/remark42/backend/app/store/image"
|
"github.com/umputun/remark42/backend/app/store/image"
|
||||||
)
|
)
|
||||||
@@ -37,7 +38,7 @@ func TestRest_Create(t *testing.T) {
|
|||||||
ts, _, teardown := startupT(t)
|
ts, _, teardown := startupT(t)
|
||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
resp, err := post(t, ts.URL+"/api/v1/comment",
|
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42",
|
||||||
`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
|
`{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
b, err := io.ReadAll(resp.Body)
|
b, err := io.ReadAll(resp.Body)
|
||||||
@@ -48,7 +49,7 @@ func TestRest_Create(t *testing.T) {
|
|||||||
c := R.JSON{}
|
c := R.JSON{}
|
||||||
err = json.Unmarshal(b, &c)
|
err = json.Unmarshal(b, &c)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
loc := c["locator"].(map[string]interface{})
|
loc := c["locator"].(map[string]any)
|
||||||
assert.Equal(t, "remark42", loc["site"])
|
assert.Equal(t, "remark42", loc["site"])
|
||||||
assert.Equal(t, "https://radio-t.com/blah1", loc["url"])
|
assert.Equal(t, "https://radio-t.com/blah1", loc["url"])
|
||||||
assert.True(t, len(c["id"].(string)) > 8)
|
assert.True(t, len(c["id"].(string)) > 8)
|
||||||
@@ -59,7 +60,7 @@ func TestRest_CreateFilteredCode(t *testing.T) {
|
|||||||
ts, _, teardown := startupT(t)
|
ts, _, teardown := startupT(t)
|
||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
resp, err := post(t, ts.URL+"/api/v1/comment",
|
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42",
|
||||||
`{"text": "`+"`foo<bar>`"+`", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
|
`{"text": "`+"`foo<bar>`"+`", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
b, err := io.ReadAll(resp.Body)
|
b, err := io.ReadAll(resp.Body)
|
||||||
@@ -69,8 +70,8 @@ func TestRest_CreateFilteredCode(t *testing.T) {
|
|||||||
|
|
||||||
c := R.JSON{}
|
c := R.JSON{}
|
||||||
err = json.Unmarshal(b, &c)
|
err = json.Unmarshal(b, &c)
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err, string(b))
|
||||||
loc := c["locator"].(map[string]interface{})
|
loc := c["locator"].(map[string]any)
|
||||||
assert.Equal(t, "remark42", loc["site"])
|
assert.Equal(t, "remark42", loc["site"])
|
||||||
assert.Equal(t, "https://radio-t.com/blah1", loc["url"])
|
assert.Equal(t, "https://radio-t.com/blah1", loc["url"])
|
||||||
assert.Equal(t, "`foo<bar>`", c["orig"])
|
assert.Equal(t, "`foo<bar>`", c["orig"])
|
||||||
@@ -79,6 +80,87 @@ func TestRest_CreateFilteredCode(t *testing.T) {
|
|||||||
assert.True(t, len(c["id"].(string)) > 8)
|
assert.True(t, len(c["id"].(string)) > 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// based on issue https://github.com/umputun/remark42/issues/1631
|
||||||
|
func TestRest_CreateAndPreviewWithImage(t *testing.T) {
|
||||||
|
ts, srv, teardown := startupT(t)
|
||||||
|
ts.Close()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
srv.ImageService.ProxyAPI = srv.RemarkURL + "/api/v1/img"
|
||||||
|
srv.ImageProxy = &proxy.Image{
|
||||||
|
HTTP2HTTPS: true,
|
||||||
|
CacheExternal: true,
|
||||||
|
RoutePath: "/api/v1/img",
|
||||||
|
RemarkURL: srv.RemarkURL,
|
||||||
|
ImageService: srv.ImageService,
|
||||||
|
Transport: http.DefaultTransport,
|
||||||
|
}
|
||||||
|
srv.CommentFormatter = store.NewCommentFormatter(srv.ImageProxy)
|
||||||
|
// need to recreate the server with new ImageProxy, otherwise old one will be used
|
||||||
|
ts = httptest.NewServer(srv.routes())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
var pngRead bool
|
||||||
|
// server with the test PNG image
|
||||||
|
pngServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, e := io.Copy(w, gopherPNG())
|
||||||
|
assert.NoError(t, e)
|
||||||
|
pngRead = true
|
||||||
|
}))
|
||||||
|
defer pngServer.Close()
|
||||||
|
|
||||||
|
t.Run("create", func(t *testing.T) {
|
||||||
|
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42",
|
||||||
|
`{"text": "", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode, string(b))
|
||||||
|
assert.NoError(t, resp.Body.Close())
|
||||||
|
|
||||||
|
c := R.JSON{}
|
||||||
|
err = json.Unmarshal(b, &c)
|
||||||
|
require.NoError(t, err, string(b))
|
||||||
|
assert.NotContains(t, c["text"], pngServer.URL)
|
||||||
|
assert.Contains(t, c["text"], srv.RemarkURL)
|
||||||
|
loc := c["locator"].(map[string]any)
|
||||||
|
assert.Equal(t, "remark42", loc["site"])
|
||||||
|
assert.Equal(t, "https://radio-t.com/blah1", loc["url"])
|
||||||
|
assert.True(t, len(c["id"].(string)) > 8)
|
||||||
|
assert.Equal(t, false, pngRead, "original image is not yet accessed by server")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("preview", func(t *testing.T) {
|
||||||
|
resp, err := post(t, ts.URL+"/api/v1/preview",
|
||||||
|
`{"text": "", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, string(b))
|
||||||
|
assert.NoError(t, resp.Body.Close())
|
||||||
|
|
||||||
|
assert.NotContains(t, string(b), pngServer.URL)
|
||||||
|
assert.Contains(t, string(b), srv.RemarkURL)
|
||||||
|
|
||||||
|
assert.Equal(t, false, pngRead, "original image is not yet accessed by server")
|
||||||
|
// retrieve the image from the cache
|
||||||
|
imgURL, _, _ := strings.Cut(strings.Split(string(b), "src=\"")[1], "\"")
|
||||||
|
// replace srv.RemarkURL with ts.URL
|
||||||
|
imgURL = strings.ReplaceAll(imgURL, srv.RemarkURL, ts.URL)
|
||||||
|
resp, err = http.Get(imgURL)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
b, err = io.ReadAll(resp.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, string(b))
|
||||||
|
assert.NoError(t, resp.Body.Close())
|
||||||
|
// compare image to original gopher png after decoding from base64
|
||||||
|
assert.Equal(t, gopher, base64.StdEncoding.EncodeToString(b))
|
||||||
|
|
||||||
|
assert.Equal(t, true, pngRead, "original image accessed to be shown to user")
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
func TestRest_CreateOldPost(t *testing.T) {
|
func TestRest_CreateOldPost(t *testing.T) {
|
||||||
ts, srv, teardown := startupT(t)
|
ts, srv, teardown := startupT(t)
|
||||||
defer teardown()
|
defer teardown()
|
||||||
@@ -94,7 +176,7 @@ func TestRest_CreateOldPost(t *testing.T) {
|
|||||||
assert.Equal(t, 1, len(comments))
|
assert.Equal(t, 1, len(comments))
|
||||||
|
|
||||||
// try to add new comment to the same old post
|
// try to add new comment to the same old post
|
||||||
resp, err := post(t, ts.URL+"/api/v1/comment",
|
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42",
|
||||||
`{"text": "test 123", "locator":{"site": "remark42","url": "https://radio-t.com/blah1"}}`)
|
`{"text": "test 123", "locator":{"site": "remark42","url": "https://radio-t.com/blah1"}}`)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NoError(t, resp.Body.Close())
|
assert.NoError(t, resp.Body.Close())
|
||||||
@@ -107,7 +189,7 @@ func TestRest_CreateOldPost(t *testing.T) {
|
|||||||
_, err = srv.DataService.Create(old)
|
_, err = srv.DataService.Create(old)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
resp, err = post(t, ts.URL+"/api/v1/comment",
|
resp, err = post(t, ts.URL+"/api/v1/comment?site=remark42",
|
||||||
`{"text": "test 123", "locator":{"site": "remark42","url": "https://radio-t.com/blah1"}}`)
|
`{"text": "test 123", "locator":{"site": "remark42","url": "https://radio-t.com/blah1"}}`)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NoError(t, resp.Body.Close())
|
assert.NoError(t, resp.Body.Close())
|
||||||
@@ -120,7 +202,7 @@ func TestRest_CreateTooBig(t *testing.T) {
|
|||||||
|
|
||||||
longComment := fmt.Sprintf(`{"text": "%4001s", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`, "Щ")
|
longComment := fmt.Sprintf(`{"text": "%4001s", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`, "Щ")
|
||||||
|
|
||||||
resp, err := post(t, ts.URL+"/api/v1/comment", longComment)
|
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42", longComment)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
b, err := io.ReadAll(resp.Body)
|
b, err := io.ReadAll(resp.Body)
|
||||||
@@ -133,7 +215,7 @@ func TestRest_CreateTooBig(t *testing.T) {
|
|||||||
assert.Equal(t, "invalid comment", c["details"])
|
assert.Equal(t, "invalid comment", c["details"])
|
||||||
|
|
||||||
veryLongComment := fmt.Sprintf(`{"text": "%70000s", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`, "Щ")
|
veryLongComment := fmt.Sprintf(`{"text": "%70000s", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`, "Щ")
|
||||||
resp, err = post(t, ts.URL+"/api/v1/comment", veryLongComment)
|
resp, err = post(t, ts.URL+"/api/v1/comment?site=remark42", veryLongComment)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
b, err = io.ReadAll(resp.Body)
|
b, err = io.ReadAll(resp.Body)
|
||||||
@@ -153,7 +235,7 @@ func TestRest_CreateWithRestrictedWord(t *testing.T) {
|
|||||||
badComment := `{"text": "What the duck is that?", "locator":{"url": "https://radio-t.com/blah1",
|
badComment := `{"text": "What the duck is that?", "locator":{"url": "https://radio-t.com/blah1",
|
||||||
"site": "remark42"}}`
|
"site": "remark42"}}`
|
||||||
|
|
||||||
resp, err := post(t, ts.URL+"/api/v1/comment", badComment)
|
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42", badComment)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
b, err := io.ReadAll(resp.Body)
|
b, err := io.ReadAll(resp.Body)
|
||||||
@@ -166,13 +248,32 @@ func TestRest_CreateWithRestrictedWord(t *testing.T) {
|
|||||||
assert.Equal(t, "invalid comment", c["details"])
|
assert.Equal(t, "invalid comment", c["details"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRest_CreateRelativeURL(t *testing.T) {
|
||||||
|
ts, _, teardown := startupT(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
// check that it's not possible to click insert URL button and not alter the URL in it (which is `url` by default)
|
||||||
|
relativeURLText := `{"text": "here is a link with relative URL: [google.com](url)", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`
|
||||||
|
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42", relativeURLText)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NoError(t, resp.Body.Close())
|
||||||
|
c := R.JSON{}
|
||||||
|
err = json.Unmarshal(b, &c)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "links should start with mailto:, http:// or https://", c["error"])
|
||||||
|
assert.Equal(t, "invalid comment", c["details"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestRest_CreateRejected(t *testing.T) {
|
func TestRest_CreateRejected(t *testing.T) {
|
||||||
ts, _, teardown := startupT(t)
|
ts, _, teardown := startupT(t)
|
||||||
defer teardown()
|
defer teardown()
|
||||||
body := `{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`
|
body := `{"text": "test 123", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`
|
||||||
|
|
||||||
// try to create without auth
|
// try to create without auth
|
||||||
resp, err := http.Post(ts.URL+"/api/v1/comment", "", strings.NewReader(body))
|
resp, err := http.Post(ts.URL+"/api/v1/comment?site=remark42", "", strings.NewReader(body))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
@@ -180,7 +281,7 @@ func TestRest_CreateRejected(t *testing.T) {
|
|||||||
// try with wrong aud
|
// try with wrong aud
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(body))
|
req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", strings.NewReader(body))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.Header.Add("X-JWT", devTokenBadAud)
|
req.Header.Add("X-JWT", devTokenBadAud)
|
||||||
resp, err = client.Do(req)
|
resp, err = client.Do(req)
|
||||||
@@ -194,7 +295,7 @@ func TestRest_CreateWithWrongImage(t *testing.T) {
|
|||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
// create comment
|
// create comment
|
||||||
resp, err := post(t, ts.URL+"/api/v1/comment", fmt.Sprintf(`{"text": "", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, srv.RemarkURL))
|
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42", fmt.Sprintf(`{"text": "", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, srv.RemarkURL))
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
b, err := io.ReadAll(resp.Body)
|
b, err := io.ReadAll(resp.Body)
|
||||||
@@ -216,7 +317,7 @@ func TestRest_CreateWithLazyImage(t *testing.T) {
|
|||||||
defer teardown()
|
defer teardown()
|
||||||
body := `{"text": "test 123 ", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`
|
body := `{"text": "test 123 ", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`
|
||||||
// create comment
|
// create comment
|
||||||
resp, err := post(t, ts.URL+"/api/v1/comment", body)
|
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42", body)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
b, err := io.ReadAll(resp.Body)
|
b, err := io.ReadAll(resp.Body)
|
||||||
@@ -233,7 +334,7 @@ func TestRest_CreateAndGet(t *testing.T) {
|
|||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
// create comment
|
// create comment
|
||||||
resp, err := post(t, ts.URL+"/api/v1/comment",
|
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42",
|
||||||
`{"text": "**test** *123*\n\n http://radio-t.com", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
|
`{"text": "**test** *123*\n\n http://radio-t.com", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
@@ -267,6 +368,56 @@ func TestRest_CreateAndGet(t *testing.T) {
|
|||||||
assert.Equal(t, store.User{Name: "admin", ID: "admin", Admin: true, Blocked: false, IP: ""}, comment.User, "no ip")
|
assert.Equal(t, store.User{Name: "admin", ID: "admin", Admin: true, Blocked: false, IP: ""}, comment.User, "no ip")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRest_CreateWithQuotes(t *testing.T) {
|
||||||
|
ts, srv, teardown := startupT(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
// create comment with quotes with smartypants
|
||||||
|
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42",
|
||||||
|
`{"text": "smartpants \"quoted\" text", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NoError(t, resp.Body.Close())
|
||||||
|
c := R.JSON{}
|
||||||
|
err = json.Unmarshal(b, &c)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
id := c["id"].(string)
|
||||||
|
|
||||||
|
// get created comment by id as non-admin
|
||||||
|
res, code := getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah1", ts.URL, id))
|
||||||
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
comment := store.Comment{}
|
||||||
|
err = json.Unmarshal([]byte(res), &comment)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "<p>smartpants «quoted» text</p>\n", comment.Text)
|
||||||
|
assert.Equal(t, "smartpants \"quoted\" text", comment.Orig)
|
||||||
|
|
||||||
|
// create comment with quotes without smartypants
|
||||||
|
srv.privRest.disableFancyTextFormatting = true
|
||||||
|
resp, err = post(t, ts.URL+"/api/v1/comment?site=remark42",
|
||||||
|
`{"text": "no_smartpants \"quoted\" text", "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
b, err = io.ReadAll(resp.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NoError(t, resp.Body.Close())
|
||||||
|
c = R.JSON{}
|
||||||
|
err = json.Unmarshal(b, &c)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
id = c["id"].(string)
|
||||||
|
|
||||||
|
// get created comment by id as non-admin
|
||||||
|
res, code = getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah1", ts.URL, id))
|
||||||
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
comment = store.Comment{}
|
||||||
|
err = json.Unmarshal([]byte(res), &comment)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, "<p>no_smartpants "quoted" text</p>\n", comment.Text)
|
||||||
|
assert.Equal(t, "no_smartpants \"quoted\" text", comment.Orig)
|
||||||
|
}
|
||||||
|
|
||||||
func TestRest_Update(t *testing.T) {
|
func TestRest_Update(t *testing.T) {
|
||||||
ts, _, teardown := startupT(t)
|
ts, _, teardown := startupT(t)
|
||||||
defer teardown()
|
defer teardown()
|
||||||
@@ -281,6 +432,7 @@ func TestRest_Update(t *testing.T) {
|
|||||||
strings.NewReader(`{"text":"updated text", "summary":"my edit"}`))
|
strings.NewReader(`{"text":"updated text", "summary":"my edit"}`))
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
req.Header.Add("X-JWT", devToken)
|
req.Header.Add("X-JWT", devToken)
|
||||||
|
beforeUpdate := time.Now()
|
||||||
b, err := client.Do(req)
|
b, err := client.Do(req)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
body, err := io.ReadAll(b.Body)
|
body, err := io.ReadAll(b.Body)
|
||||||
@@ -296,7 +448,7 @@ func TestRest_Update(t *testing.T) {
|
|||||||
assert.Equal(t, "<p>updated text</p>\n", c2.Text)
|
assert.Equal(t, "<p>updated text</p>\n", c2.Text)
|
||||||
assert.Equal(t, "updated text", c2.Orig)
|
assert.Equal(t, "updated text", c2.Orig)
|
||||||
assert.Equal(t, "my edit", c2.Edit.Summary)
|
assert.Equal(t, "my edit", c2.Edit.Summary)
|
||||||
assert.True(t, time.Since(c2.Edit.Timestamp) < 1*time.Second)
|
assert.WithinRange(t, c2.Edit.Timestamp, beforeUpdate, time.Now(), "edit stamped during the update")
|
||||||
|
|
||||||
// read updated comment
|
// read updated comment
|
||||||
res, code := getWithAdminAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah1", ts.URL, id))
|
res, code := getWithAdminAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah1", ts.URL, id))
|
||||||
@@ -445,7 +597,7 @@ func TestRest_DeleteChildThenParent(t *testing.T) {
|
|||||||
fmt.Sprintf("%s/api/v1/admin/comment/%s?site=remark42&url=https://radio-t.com/blah1", ts.URL, idC2), http.NoBody)
|
fmt.Sprintf("%s/api/v1/admin/comment/%s?site=remark42&url=https://radio-t.com/blah1", ts.URL, idC2), http.NoBody)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
requireAdminOnly(t, req)
|
requireAdminOnly(t, req)
|
||||||
resp, err = sendReq(t, req, adminUmputunToken)
|
resp, err = sendReq(req, adminUmputunToken)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NoError(t, resp.Body.Close())
|
assert.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -574,7 +726,7 @@ func TestRest_Vote(t *testing.T) {
|
|||||||
req, err := http.NewRequest(http.MethodPut,
|
req, err := http.NewRequest(http.MethodPut,
|
||||||
fmt.Sprintf("%s/api/v1/vote/%s?site=remark42&url=https://radio-t.com/blah&vote=%d", ts.URL, id1, val), http.NoBody)
|
fmt.Sprintf("%s/api/v1/vote/%s?site=remark42&url=https://radio-t.com/blah&vote=%d", ts.URL, id1, val), http.NoBody)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
req.Header.Add("X-JWT", devToken)
|
req.Header.Add("X-JWT", dev2Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NoError(t, resp.Body.Close())
|
assert.NoError(t, resp.Body.Close())
|
||||||
@@ -583,7 +735,7 @@ func TestRest_Vote(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, http.StatusOK, vote(1), "first vote allowed")
|
assert.Equal(t, http.StatusOK, vote(1), "first vote allowed")
|
||||||
assert.Equal(t, http.StatusBadRequest, vote(1), "second vote rejected")
|
assert.Equal(t, http.StatusBadRequest, vote(1), "second vote rejected")
|
||||||
body, code := getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
|
body, code := getWithDev2Auth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
|
||||||
assert.Equal(t, http.StatusOK, code)
|
assert.Equal(t, http.StatusOK, code)
|
||||||
cr := store.Comment{}
|
cr := store.Comment{}
|
||||||
err := json.Unmarshal([]byte(body), &cr)
|
err := json.Unmarshal([]byte(body), &cr)
|
||||||
@@ -594,7 +746,7 @@ func TestRest_Vote(t *testing.T) {
|
|||||||
assert.Equal(t, map[string]store.VotedIPInfo(nil), cr.VotedIPs, "hidden")
|
assert.Equal(t, map[string]store.VotedIPInfo(nil), cr.VotedIPs, "hidden")
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, vote(-1), "opposite vote allowed")
|
assert.Equal(t, http.StatusOK, vote(-1), "opposite vote allowed")
|
||||||
body, code = getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
|
body, code = getWithDev2Auth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
|
||||||
assert.Equal(t, http.StatusOK, code)
|
assert.Equal(t, http.StatusOK, code)
|
||||||
cr = store.Comment{}
|
cr = store.Comment{}
|
||||||
err = json.Unmarshal([]byte(body), &cr)
|
err = json.Unmarshal([]byte(body), &cr)
|
||||||
@@ -603,7 +755,7 @@ func TestRest_Vote(t *testing.T) {
|
|||||||
assert.Equal(t, 0, cr.Vote)
|
assert.Equal(t, 0, cr.Vote)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, vote(-1), "opposite vote allowed one more time")
|
assert.Equal(t, http.StatusOK, vote(-1), "opposite vote allowed one more time")
|
||||||
body, code = getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
|
body, code = getWithDev2Auth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
|
||||||
assert.Equal(t, http.StatusOK, code)
|
assert.Equal(t, http.StatusOK, code)
|
||||||
cr = store.Comment{}
|
cr = store.Comment{}
|
||||||
err = json.Unmarshal([]byte(body), &cr)
|
err = json.Unmarshal([]byte(body), &cr)
|
||||||
@@ -612,7 +764,7 @@ func TestRest_Vote(t *testing.T) {
|
|||||||
assert.Equal(t, -1, cr.Vote)
|
assert.Equal(t, -1, cr.Vote)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, vote(-1), "dbl vote not allowed")
|
assert.Equal(t, http.StatusBadRequest, vote(-1), "dbl vote not allowed")
|
||||||
body, code = getWithDevAuth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
|
body, code = getWithDev2Auth(t, fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1))
|
||||||
assert.Equal(t, http.StatusOK, code)
|
assert.Equal(t, http.StatusOK, code)
|
||||||
cr = store.Comment{}
|
cr = store.Comment{}
|
||||||
err = json.Unmarshal([]byte(body), &cr)
|
err = json.Unmarshal([]byte(body), &cr)
|
||||||
@@ -633,7 +785,7 @@ func TestRest_Vote(t *testing.T) {
|
|||||||
req, err := http.NewRequest("GET",
|
req, err := http.NewRequest("GET",
|
||||||
fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1), http.NoBody)
|
fmt.Sprintf("%s/api/v1/id/%s?site=remark42&url=https://radio-t.com/blah", ts.URL, id1), http.NoBody)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
resp, err := sendReq(t, req, adminUmputunToken)
|
resp, err := sendReq(req, adminUmputunToken)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
cr = store.Comment{}
|
cr = store.Comment{}
|
||||||
@@ -709,11 +861,11 @@ func TestRest_EmailAndTelegram(t *testing.T) {
|
|||||||
|
|
||||||
// issue good token
|
// issue good token
|
||||||
claims := token.Claims{
|
claims := token.Claims{
|
||||||
Handshake: &token.Handshake{ID: "dev::good@example.com"},
|
Handshake: &token.Handshake{ID: "provider1_dev::good@example.com"},
|
||||||
StandardClaims: jwt.StandardClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Audience: "remark42",
|
Audience: jwt.ClaimStrings{"remark42"},
|
||||||
ExpiresAt: time.Now().Add(10 * time.Minute).Unix(),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(10 * time.Minute)),
|
||||||
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)),
|
||||||
Issuer: "remark42",
|
Issuer: "remark42",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -728,40 +880,52 @@ func TestRest_EmailAndTelegram(t *testing.T) {
|
|||||||
responseCode int
|
responseCode int
|
||||||
noAuth bool
|
noAuth bool
|
||||||
cookieEmail string
|
cookieEmail string
|
||||||
|
body string
|
||||||
}{
|
}{
|
||||||
{description: "issue delete request without auth", url: "/api/v1/email", method: http.MethodDelete, responseCode: http.StatusUnauthorized, noAuth: true},
|
{description: "issue delete request without auth", url: "/api/v1/email", method: http.MethodDelete, responseCode: http.StatusUnauthorized, noAuth: true},
|
||||||
{description: "issue delete request without site_id", url: "/api/v1/email", method: http.MethodDelete, responseCode: http.StatusBadRequest},
|
{description: "issue delete request without site_id", url: "/api/v1/email", method: http.MethodDelete, responseCode: http.StatusForbidden},
|
||||||
{description: "delete non-existent user email", url: "/api/v1/email?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
|
{description: "delete non-existent user email", url: "/api/v1/email?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
|
||||||
{description: "set user email, token not set", url: "/api/v1/email/confirm?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
|
{description: "set user email, token not set", url: "/api/v1/email/confirm?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest, body: `{"site":"remark42"}`},
|
||||||
{description: "send email confirmation without address", url: "/api/v1/email/subscribe?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
|
{description: "set user email, token not set, old query param", url: "/api/v1/email/confirm?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
|
||||||
{description: "send email confirmation", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK},
|
{description: "send email confirmation without address", url: "/api/v1/email/subscribe?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest, body: `{"site":"remark42"}`},
|
||||||
{description: "set user email, token is good", url: fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com"},
|
{description: "send email confirmation without address, old query param", url: "/api/v1/email/subscribe?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
|
||||||
|
{description: "send email confirmation", url: "/api/v1/email/subscribe?site=remark42", method: http.MethodPost, responseCode: http.StatusOK, body: `{"site":"remark42","address":"good@example.com"}`},
|
||||||
|
{description: "send email confirmation, old query param", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK},
|
||||||
|
{description: "set user email, token is good", url: "/api/v1/email/confirm?site=remark42", method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com", body: fmt.Sprintf(`{"site":"remark42","token":%q}`, goodToken)},
|
||||||
|
{description: "set user email, token is good, old query param", url: fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com"},
|
||||||
{description: "send confirmation with same address", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusConflict},
|
{description: "send confirmation with same address", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusConflict},
|
||||||
{description: "get user email", url: "/api/v1/email?site=remark42", method: http.MethodGet, responseCode: http.StatusOK},
|
{description: "get user email", url: "/api/v1/email?site=remark42", method: http.MethodGet, responseCode: http.StatusOK},
|
||||||
{description: "delete user email", url: "/api/v1/email?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
|
{description: "delete user email", url: "/api/v1/email?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
|
||||||
{description: "send another confirmation", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK},
|
{description: "send another confirmation", url: "/api/v1/email/subscribe?site=remark42&address=good@example.com", method: http.MethodPost, responseCode: http.StatusOK},
|
||||||
{description: "set user email, token is good", url: fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com"},
|
{description: "set user email, token is good", url: "/api/v1/email/confirm?site=remark42", method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com", body: fmt.Sprintf(`{"site":"remark42","token":%q}`, goodToken)},
|
||||||
|
{description: "set user email, token is good, old query param", url: fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK, cookieEmail: "good@example.com"},
|
||||||
{description: "unsubscribe user, no token", url: "/email/unsubscribe.html?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
|
{description: "unsubscribe user, no token", url: "/email/unsubscribe.html?site=remark42", method: http.MethodPost, responseCode: http.StatusBadRequest},
|
||||||
{description: "unsubscribe user, wrong token", url: "/email/unsubscribe.html?site=remark42&tkn=jwt", method: http.MethodGet, responseCode: http.StatusForbidden},
|
{description: "unsubscribe user, wrong token", url: "/email/unsubscribe.html?site=remark42&tkn=jwt", method: http.MethodGet, responseCode: http.StatusForbidden},
|
||||||
{description: "unsubscribe user, good token", url: fmt.Sprintf("/email/unsubscribe.html?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK},
|
{description: "unsubscribe user, good token", url: fmt.Sprintf("/email/unsubscribe.html?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusOK},
|
||||||
{description: "unsubscribe user second time, good token", url: fmt.Sprintf("/email/unsubscribe.html?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusConflict},
|
{description: "unsubscribe user second time, good token", url: fmt.Sprintf("/email/unsubscribe.html?site=remark42&tkn=%s", goodToken), method: http.MethodPost, responseCode: http.StatusConflict},
|
||||||
{description: "issue delete request without auth", url: "/api/v1/telegram", method: http.MethodDelete, responseCode: http.StatusUnauthorized, noAuth: true},
|
{description: "issue delete request without auth", url: "/api/v1/telegram", method: http.MethodDelete, responseCode: http.StatusUnauthorized, noAuth: true},
|
||||||
{description: "issue delete request without site_id", url: "/api/v1/telegram", method: http.MethodDelete, responseCode: http.StatusBadRequest},
|
{description: "issue delete request without site_id", url: "/api/v1/telegram", method: http.MethodDelete, responseCode: http.StatusForbidden},
|
||||||
{description: "delete non-existent user telegram", url: "/api/v1/telegram?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
|
{description: "delete non-existent user telegram", url: "/api/v1/telegram?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
|
||||||
{description: "send telegram confirmation, no siteID", url: "/api/v1/telegram/subscribe", method: http.MethodGet, responseCode: http.StatusBadRequest},
|
{description: "send telegram confirmation, no siteID", url: "/api/v1/telegram/subscribe", method: http.MethodGet, responseCode: http.StatusForbidden},
|
||||||
{description: "send telegram confirmation", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodGet, responseCode: http.StatusOK},
|
{description: "send telegram confirmation", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodGet, responseCode: http.StatusOK},
|
||||||
{description: "set user telegram, token is good", url: "/api/v1/telegram/subscribe?site=remark42&tkn=good_token", method: http.MethodGet, responseCode: http.StatusOK},
|
{description: "set user telegram, token is good", url: "/api/v1/telegram/subscribe?site=remark42&tkn=good_token", method: http.MethodGet, responseCode: http.StatusOK},
|
||||||
{description: "send confirmation with same address", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodGet, responseCode: http.StatusConflict},
|
{description: "send confirmation with same address", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodGet, responseCode: http.StatusConflict},
|
||||||
{description: "delete user telegram", url: "/api/v1/telegram?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
|
{description: "delete user telegram", url: "/api/v1/telegram?site=remark42", method: http.MethodDelete, responseCode: http.StatusOK},
|
||||||
{description: "send another confirmation", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodGet, responseCode: http.StatusOK},
|
{description: "send another confirmation", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodGet, responseCode: http.StatusOK},
|
||||||
{description: "set user telegram, token is good", url: "/api/v1/telegram/subscribe?site=remark42&tkn=good_token", method: http.MethodGet, responseCode: http.StatusOK},
|
{description: "set user telegram, token is good", url: "/api/v1/telegram/subscribe?site=remark42&tkn=good_token", method: http.MethodGet, responseCode: http.StatusOK},
|
||||||
|
// telegramSubscribeCtrl mutates state, so HEAD (which stdlib ServeMux would route to the
|
||||||
|
// GET handler) must be rejected by rejectHead before it runs
|
||||||
|
{description: "HEAD is rejected on telegram subscribe", url: "/api/v1/telegram/subscribe?site=remark42", method: http.MethodHead, responseCode: http.StatusMethodNotAllowed},
|
||||||
}
|
}
|
||||||
client := http.Client{}
|
client := http.Client{}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
for _, x := range testData {
|
for _, x := range testData {
|
||||||
x := x
|
|
||||||
t.Run(x.description, func(t *testing.T) {
|
t.Run(x.description, func(t *testing.T) {
|
||||||
req, err := http.NewRequest(x.method, ts.URL+x.url, http.NoBody)
|
reqBody := io.NopCloser(strings.NewReader(x.body))
|
||||||
|
if x.body == "" {
|
||||||
|
reqBody = http.NoBody
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(x.method, ts.URL+x.url, reqBody)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
if !x.noAuth {
|
if !x.noAuth {
|
||||||
req.Header.Add("X-JWT", devToken)
|
req.Header.Add("X-JWT", devToken)
|
||||||
@@ -796,9 +960,9 @@ func TestRest_EmailNotification(t *testing.T) {
|
|||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
|
|
||||||
// create new comment from dev user
|
// create new comment from dev user
|
||||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(
|
req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", strings.NewReader(
|
||||||
`{"text": "test 123",
|
`{"text": "test 123",
|
||||||
"user": {"name": "dev::good@example.com"},
|
"user": {"name": "provider1_dev::good@example.com"},
|
||||||
"locator":{"url": "https://radio-t.com/blah1",
|
"locator":{"url": "https://radio-t.com/blah1",
|
||||||
"site": "remark42"}}`))
|
"site": "remark42"}}`))
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -810,14 +974,12 @@ func TestRest_EmailNotification(t *testing.T) {
|
|||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||||
parentComment := store.Comment{}
|
parentComment := store.Comment{}
|
||||||
require.NoError(t, render.DecodeJSON(strings.NewReader(string(body)), &parentComment))
|
require.NoError(t, json.Unmarshal(body, &parentComment))
|
||||||
// wait for mock notification Submit to kick off
|
waitForCount(t, 1, func() int { return len(mockDestination.Get()) })
|
||||||
time.Sleep(time.Millisecond * 30)
|
|
||||||
require.Equal(t, 1, len(mockDestination.Get()))
|
|
||||||
assert.Empty(t, mockDestination.Get()[0].Emails)
|
assert.Empty(t, mockDestination.Get()[0].Emails)
|
||||||
|
|
||||||
// create child comment from another user, email notification only to admin expected
|
// create child comment from another user, email notification only to admin expected
|
||||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf(
|
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", strings.NewReader(fmt.Sprintf(
|
||||||
`{"text": "test 456",
|
`{"text": "test 456",
|
||||||
"pid": %q,
|
"pid": %q,
|
||||||
"user": {"name": "other_user"},
|
"user": {"name": "other_user"},
|
||||||
@@ -831,13 +993,15 @@ func TestRest_EmailNotification(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||||
// wait for mock notification Submit to kick off
|
waitForCount(t, 2, func() int { return len(mockDestination.Get()) })
|
||||||
time.Sleep(time.Millisecond * 30)
|
|
||||||
require.Equal(t, 2, len(mockDestination.Get()))
|
|
||||||
assert.Empty(t, mockDestination.Get()[1].Emails)
|
assert.Empty(t, mockDestination.Get()[1].Emails)
|
||||||
|
|
||||||
// send confirmation token for email
|
// send confirmation token for email
|
||||||
req, err = http.NewRequest(http.MethodPost, ts.URL+"/api/v1/email/subscribe?site=remark42&address=good@example.com", http.NoBody)
|
req, err = http.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
ts.URL+"/api/v1/email/subscribe?site=remark42",
|
||||||
|
io.NopCloser(strings.NewReader(`{"site": "remark42", "address": "good@example.com"}`)),
|
||||||
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.Header.Add("X-JWT", devToken)
|
req.Header.Add("X-JWT", devToken)
|
||||||
resp, err = client.Do(req)
|
resp, err = client.Do(req)
|
||||||
@@ -846,14 +1010,35 @@ func TestRest_EmailNotification(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||||
// wait for mock notification Submit to kick off
|
waitForCount(t, 1, func() int { return len(mockDestination.GetVerify()) })
|
||||||
time.Sleep(time.Millisecond * 30)
|
|
||||||
require.Equal(t, 1, len(mockDestination.GetVerify()))
|
|
||||||
assert.Equal(t, "good@example.com", mockDestination.GetVerify()[0].Email)
|
assert.Equal(t, "good@example.com", mockDestination.GetVerify()[0].Email)
|
||||||
verificationToken := mockDestination.GetVerify()[0].Token
|
verificationToken := mockDestination.GetVerify()[0].Token
|
||||||
|
|
||||||
|
// get user information to verify lack of the subscription
|
||||||
|
req, err = http.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
ts.URL+"/api/v1/user?site=remark42",
|
||||||
|
http.NoBody)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Add("X-JWT", devToken)
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
body, err = io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||||
|
var clearUser store.User
|
||||||
|
err = json.Unmarshal(body, &clearUser)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, store.User{Name: "developer one", ID: "provider1_dev", EmailSubscription: false,
|
||||||
|
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, clearUser)
|
||||||
|
|
||||||
// verify email
|
// verify email
|
||||||
req, err = http.NewRequest(http.MethodPost, ts.URL+fmt.Sprintf("/api/v1/email/confirm?site=remark42&tkn=%s", verificationToken), http.NoBody)
|
req, err = http.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
ts.URL+"/api/v1/email/confirm?site=remark42",
|
||||||
|
io.NopCloser(strings.NewReader(fmt.Sprintf(`{"site": "remark42", "token": %q}`, verificationToken))),
|
||||||
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.Header.Add("X-JWT", devToken)
|
req.Header.Add("X-JWT", devToken)
|
||||||
resp, err = client.Do(req)
|
resp, err = client.Do(req)
|
||||||
@@ -864,7 +1049,10 @@ func TestRest_EmailNotification(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||||
|
|
||||||
// get user information to verify the subscription
|
// get user information to verify the subscription
|
||||||
req, err = http.NewRequest(http.MethodGet, ts.URL+"/api/v1/user?site=remark42", http.NoBody)
|
req, err = http.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
ts.URL+"/api/v1/user?site=remark42",
|
||||||
|
http.NoBody)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.Header.Add("X-JWT", devToken)
|
req.Header.Add("X-JWT", devToken)
|
||||||
resp, err = client.Do(req)
|
resp, err = client.Do(req)
|
||||||
@@ -873,14 +1061,14 @@ func TestRest_EmailNotification(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||||
var user store.User
|
var subscribedUser store.User
|
||||||
err = json.Unmarshal(body, &user)
|
err = json.Unmarshal(body, &subscribedUser)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, store.User{Name: "developer one", ID: "dev", EmailSubscription: true,
|
assert.Equal(t, store.User{Name: "developer one", ID: "provider1_dev", EmailSubscription: true,
|
||||||
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, user)
|
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, subscribedUser)
|
||||||
|
|
||||||
// create child comment from another user, email notification expected
|
// create child comment from another user, email notification expected
|
||||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf(
|
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", strings.NewReader(fmt.Sprintf(
|
||||||
`{"text": "test 789",
|
`{"text": "test 789",
|
||||||
"pid": %q,
|
"pid": %q,
|
||||||
"user": {"name": "other_user"},
|
"user": {"name": "other_user"},
|
||||||
@@ -894,9 +1082,7 @@ func TestRest_EmailNotification(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||||
// wait for mock notification Submit to kick off
|
waitForCount(t, 3, func() int { return len(mockDestination.Get()) })
|
||||||
time.Sleep(time.Millisecond * 30)
|
|
||||||
require.Equal(t, 3, len(mockDestination.Get()))
|
|
||||||
assert.Equal(t, []string{"good@example.com"}, mockDestination.Get()[2].Emails)
|
assert.Equal(t, []string{"good@example.com"}, mockDestination.Get()[2].Emails)
|
||||||
|
|
||||||
// delete user's email
|
// delete user's email
|
||||||
@@ -911,7 +1097,7 @@ func TestRest_EmailNotification(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
assert.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||||
|
|
||||||
// create child comment from another user, no email notification
|
// create child comment from another user, no email notification
|
||||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(
|
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", strings.NewReader(
|
||||||
`{"text": "test 321",
|
`{"text": "test 321",
|
||||||
"user": {"name": "other_user"},
|
"user": {"name": "other_user"},
|
||||||
"locator":{"url": "https://radio-t.com/blah1",
|
"locator":{"url": "https://radio-t.com/blah1",
|
||||||
@@ -924,10 +1110,78 @@ func TestRest_EmailNotification(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||||
// wait for mock notification Submit to kick off
|
waitForCountSettled(t, 4, func() int { return len(mockDestination.Get()) })
|
||||||
time.Sleep(time.Millisecond * 30)
|
|
||||||
require.Equal(t, 4, len(mockDestination.Get()))
|
|
||||||
assert.Empty(t, mockDestination.Get()[3].Emails)
|
assert.Empty(t, mockDestination.Get()[3].Emails)
|
||||||
|
|
||||||
|
// confirm email via subscribe call with query params, old behavior, email notification is expected
|
||||||
|
req, err = http.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
ts.URL+"/api/v1/email/subscribe?site=remark42&address=good@example.com",
|
||||||
|
http.NoBody,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Add("X-JWT", emailUserToken)
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
body, err = io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||||
|
waitForCount(t, 2, func() int { return len(mockDestination.GetVerify()) }, "verification email was sent")
|
||||||
|
|
||||||
|
// get email user information to verify there is no subscription yet
|
||||||
|
req, err = http.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
ts.URL+"/api/v1/user?site=remark42",
|
||||||
|
http.NoBody)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Add("X-JWT", emailUserToken)
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
body, err = io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||||
|
var unsubscribedEmailUser store.User
|
||||||
|
err = json.Unmarshal(body, &unsubscribedEmailUser)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, store.User{Name: "good@example.com test user", ID: "email_f5dfe9d2e6bd75fc74ea5fabf273b45b5baeb195", EmailSubscription: false,
|
||||||
|
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, unsubscribedEmailUser)
|
||||||
|
|
||||||
|
// confirm email via subscribe call, no email notification is expected
|
||||||
|
req, err = http.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
ts.URL+"/api/v1/email/subscribe?site=remark42",
|
||||||
|
io.NopCloser(strings.NewReader(`{"site": "remark42", "address": "good@example.com"}`)),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Add("X-JWT", emailUserToken)
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
body, err = io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||||
|
waitForCountSettled(t, 2, func() int { return len(mockDestination.GetVerify()) }, "no new verification email was sent")
|
||||||
|
|
||||||
|
// get email user information to verify the subscription happened without the confirmation call
|
||||||
|
req, err = http.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
ts.URL+"/api/v1/user?site=remark42",
|
||||||
|
http.NoBody)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Add("X-JWT", emailUserToken)
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
body, err = io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||||
|
var subscribedEmailUser store.User
|
||||||
|
err = json.Unmarshal(body, &subscribedEmailUser)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, store.User{Name: "good@example.com test user", ID: "email_f5dfe9d2e6bd75fc74ea5fabf273b45b5baeb195", EmailSubscription: true,
|
||||||
|
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, subscribedEmailUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRest_TelegramNotification(t *testing.T) {
|
func TestRest_TelegramNotification(t *testing.T) {
|
||||||
@@ -942,9 +1196,9 @@ func TestRest_TelegramNotification(t *testing.T) {
|
|||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
|
|
||||||
// create new comment from dev user
|
// create new comment from dev user
|
||||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(
|
req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", strings.NewReader(
|
||||||
`{"text": "test 123",
|
`{"text": "test 123",
|
||||||
"user": {"name": "dev::good@example.com"},
|
"user": {"name": "provider1_dev::good@example.com"},
|
||||||
"locator":{"url": "https://radio-t.com/blah1",
|
"locator":{"url": "https://radio-t.com/blah1",
|
||||||
"site": "remark42"}}`))
|
"site": "remark42"}}`))
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -956,14 +1210,12 @@ func TestRest_TelegramNotification(t *testing.T) {
|
|||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||||
parentComment := store.Comment{}
|
parentComment := store.Comment{}
|
||||||
require.NoError(t, render.DecodeJSON(strings.NewReader(string(body)), &parentComment))
|
require.NoError(t, json.Unmarshal(body, &parentComment))
|
||||||
// wait for mock notification Submit to kick off
|
waitForCount(t, 1, func() int { return len(mockDestination.Get()) })
|
||||||
time.Sleep(time.Millisecond * 30)
|
|
||||||
require.Equal(t, 1, len(mockDestination.Get()))
|
|
||||||
assert.Empty(t, mockDestination.Get()[0].Telegrams)
|
assert.Empty(t, mockDestination.Get()[0].Telegrams)
|
||||||
|
|
||||||
// create child comment from another user, telegram notification only to admin expected
|
// create child comment from another user, telegram notification only to admin expected
|
||||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf(
|
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", strings.NewReader(fmt.Sprintf(
|
||||||
`{"text": "test 456",
|
`{"text": "test 456",
|
||||||
"pid": %q,
|
"pid": %q,
|
||||||
"user": {"name": "other_user"},
|
"user": {"name": "other_user"},
|
||||||
@@ -977,9 +1229,7 @@ func TestRest_TelegramNotification(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||||
// wait for mock notification Submit to kick off
|
waitForCount(t, 2, func() int { return len(mockDestination.Get()) })
|
||||||
time.Sleep(time.Millisecond * 30)
|
|
||||||
require.Equal(t, 2, len(mockDestination.Get()))
|
|
||||||
assert.Empty(t, mockDestination.Get()[1].Telegrams)
|
assert.Empty(t, mockDestination.Get()[1].Telegrams)
|
||||||
|
|
||||||
// subscribe to telegram while the telegram destination is absent
|
// subscribe to telegram while the telegram destination is absent
|
||||||
@@ -1023,8 +1273,8 @@ func TestRest_TelegramNotification(t *testing.T) {
|
|||||||
body, err = io.ReadAll(resp.Body)
|
body, err = io.ReadAll(resp.Body)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
require.Equal(t, http.StatusInternalServerError, resp.StatusCode, string(body))
|
require.Equal(t, http.StatusNotFound, resp.StatusCode, string(body))
|
||||||
require.Equal(t, `{"code":0,"details":"can't set telegram for user","error":"not verified"}`+"\n", string(body))
|
require.Equal(t, `{"code":0,"details":"request is not verified yet","error":"not verified"}`+"\n", string(body))
|
||||||
|
|
||||||
mockTlgrm.notVerified = false
|
mockTlgrm.notVerified = false
|
||||||
|
|
||||||
@@ -1072,11 +1322,11 @@ func TestRest_TelegramNotification(t *testing.T) {
|
|||||||
var user store.User
|
var user store.User
|
||||||
err = json.Unmarshal(body, &user)
|
err = json.Unmarshal(body, &user)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, store.User{Name: "developer one", ID: "dev",
|
assert.Equal(t, store.User{Name: "developer one", ID: "provider1_dev",
|
||||||
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, user)
|
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, user)
|
||||||
|
|
||||||
// create child comment from another user, telegram notification expected
|
// create child comment from another user, telegram notification expected
|
||||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(fmt.Sprintf(
|
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", strings.NewReader(fmt.Sprintf(
|
||||||
`{"text": "test 789",
|
`{"text": "test 789",
|
||||||
"pid": %q,
|
"pid": %q,
|
||||||
"user": {"name": "other_user"},
|
"user": {"name": "other_user"},
|
||||||
@@ -1090,9 +1340,7 @@ func TestRest_TelegramNotification(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||||
// wait for mock notification Submit to kick off
|
waitForCount(t, 3, func() int { return len(mockDestination.Get()) })
|
||||||
time.Sleep(time.Millisecond * 30)
|
|
||||||
require.Equal(t, 3, len(mockDestination.Get()))
|
|
||||||
assert.Equal(t, []string{"good_telegram"}, mockDestination.Get()[2].Telegrams)
|
assert.Equal(t, []string{"good_telegram"}, mockDestination.Get()[2].Telegrams)
|
||||||
|
|
||||||
// delete user's telegram
|
// delete user's telegram
|
||||||
@@ -1107,7 +1355,7 @@ func TestRest_TelegramNotification(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
assert.Equal(t, http.StatusOK, resp.StatusCode, string(body))
|
||||||
|
|
||||||
// create child comment from another user, no telegram notification
|
// create child comment from another user, no telegram notification
|
||||||
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment", strings.NewReader(
|
req, err = http.NewRequest("POST", ts.URL+"/api/v1/comment?site=remark42", strings.NewReader(
|
||||||
`{"text": "test 321",
|
`{"text": "test 321",
|
||||||
"user": {"name": "other_user"},
|
"user": {"name": "other_user"},
|
||||||
"locator":{"url": "https://radio-t.com/blah1",
|
"locator":{"url": "https://radio-t.com/blah1",
|
||||||
@@ -1120,9 +1368,7 @@ func TestRest_TelegramNotification(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
require.Equal(t, http.StatusCreated, resp.StatusCode, string(body))
|
||||||
// wait for mock notification Submit to kick off
|
waitForCountSettled(t, 4, func() int { return len(mockDestination.Get()) })
|
||||||
time.Sleep(time.Millisecond * 30)
|
|
||||||
require.Equal(t, 4, len(mockDestination.Get()))
|
|
||||||
assert.Empty(t, mockDestination.Get()[3].Telegrams)
|
assert.Empty(t, mockDestination.Get()[3].Telegrams)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1131,7 +1377,7 @@ func TestRest_UserAllData(t *testing.T) {
|
|||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
// write 3 comments
|
// write 3 comments
|
||||||
user := store.User{ID: "dev", Name: "user name 1"}
|
user := store.User{ID: "provider1_dev", Name: "user name 1"}
|
||||||
c1 := store.Comment{User: user, Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
|
c1 := store.Comment{User: user, Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
|
||||||
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 5, 27, 1, 14, 10, 0, time.Local)}
|
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 5, 27, 1, 14, 10, 0, time.Local)}
|
||||||
c2 := store.Comment{User: user, Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "remark42",
|
c2 := store.Comment{User: user, Text: "test test #2", ParentID: "p1", Locator: store.Locator{SiteID: "remark42",
|
||||||
@@ -1145,7 +1391,7 @@ func TestRest_UserAllData(t *testing.T) {
|
|||||||
_, err = srv.DataService.Create(c3)
|
_, err = srv.DataService.Create(c3)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 1 * time.Second}
|
client := &http.Client{Timeout: waitTimeout}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
req, err := http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=remark42", http.NoBody)
|
req, err := http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=remark42", http.NoBody)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -1156,13 +1402,13 @@ func TestRest_UserAllData(t *testing.T) {
|
|||||||
require.Equal(t, "application/gzip", resp.Header.Get("Content-Type"))
|
require.Equal(t, "application/gzip", resp.Header.Get("Content-Type"))
|
||||||
|
|
||||||
ungzReader, err := gzip.NewReader(resp.Body)
|
ungzReader, err := gzip.NewReader(resp.Body)
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
ungzBody, err := io.ReadAll(ungzReader)
|
ungzBody, err := io.ReadAll(ungzReader)
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
strUungzBody := string(ungzBody)
|
strUungzBody := string(ungzBody)
|
||||||
assert.True(t, strings.HasPrefix(strUungzBody,
|
assert.True(t, strings.HasPrefix(strUungzBody,
|
||||||
`{"info": {"name":"developer one","id":"dev","picture":"http://example.com/pic.png","ip":"127.0.0.1","admin":false,"site_id":"remark42"}, "comments":[{`))
|
`{"info": {"name":"developer one","id":"provider1_dev","picture":"http://example.com/pic.png","ip":"127.0.0.1","admin":false,"site_id":"remark42"}, "comments":[{`))
|
||||||
assert.Equal(t, 3, strings.Count(strUungzBody, `"text":`), "3 comments inside")
|
assert.Equal(t, 3, strings.Count(strUungzBody, `"text":`), "3 comments inside")
|
||||||
|
|
||||||
parsed := struct {
|
parsed := struct {
|
||||||
@@ -1172,7 +1418,7 @@ func TestRest_UserAllData(t *testing.T) {
|
|||||||
|
|
||||||
err = json.Unmarshal(ungzBody, &parsed)
|
err = json.Unmarshal(ungzBody, &parsed)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, store.User{Name: "developer one", ID: "dev",
|
assert.Equal(t, store.User{Name: "developer one", ID: "provider1_dev",
|
||||||
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, parsed.Info)
|
Picture: "http://example.com/pic.png", IP: "127.0.0.1", SiteID: "remark42"}, parsed.Info)
|
||||||
assert.Equal(t, 3, len(parsed.Comments))
|
assert.Equal(t, 3, len(parsed.Comments))
|
||||||
|
|
||||||
@@ -1188,17 +1434,17 @@ func TestRest_UserAllDataManyComments(t *testing.T) {
|
|||||||
ts, srv, teardown := startupT(t)
|
ts, srv, teardown := startupT(t)
|
||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
user := store.User{ID: "dev", Name: "user name 1"}
|
user := store.User{ID: "provider1_dev", Name: "user name 1"}
|
||||||
c := store.Comment{User: user, Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
|
c := store.Comment{User: user, Text: "test test #1", Locator: store.Locator{SiteID: "remark42",
|
||||||
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 5, 27, 1, 14, 10, 0, time.Local)}
|
URL: "https://radio-t.com/blah1"}, Timestamp: time.Date(2018, 5, 27, 1, 14, 10, 0, time.Local)}
|
||||||
|
|
||||||
for i := 0; i < 51; i++ {
|
for i := range 51 {
|
||||||
c.ID = fmt.Sprintf("id-%03d", i)
|
c.ID = fmt.Sprintf("id-%03d", i)
|
||||||
c.Timestamp = c.Timestamp.Add(time.Second)
|
c.Timestamp = c.Timestamp.Add(time.Second)
|
||||||
_, err := srv.DataService.Create(c)
|
_, err := srv.DataService.Create(c)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
client := &http.Client{Timeout: 1 * time.Second}
|
client := &http.Client{Timeout: waitTimeout}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
req, err := http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=remark42", http.NoBody)
|
req, err := http.NewRequest("GET", ts.URL+"/api/v1/userdata?site=remark42", http.NoBody)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -1215,7 +1461,7 @@ func TestRest_UserAllDataManyComments(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
strUngzBody := string(ungzBody)
|
strUngzBody := string(ungzBody)
|
||||||
assert.True(t, strings.HasPrefix(strUngzBody,
|
assert.True(t, strings.HasPrefix(strUngzBody,
|
||||||
`{"info": {"name":"developer one","id":"dev","picture":"http://example.com/pic.png","ip":"127.0.0.1","admin":false,"site_id":"remark42"}, "comments":[{`))
|
`{"info": {"name":"developer one","id":"provider1_dev","picture":"http://example.com/pic.png","ip":"127.0.0.1","admin":false,"site_id":"remark42"}, "comments":[{`))
|
||||||
assert.Equal(t, 51, strings.Count(strUngzBody, `"text":`), "51 comments inside")
|
assert.Equal(t, 51, strings.Count(strUngzBody, `"text":`), "51 comments inside")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1239,12 +1485,14 @@ func TestRest_DeleteMe(t *testing.T) {
|
|||||||
err = json.Unmarshal(body, &m)
|
err = json.Unmarshal(body, &m)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "remark42", m["site"])
|
assert.Equal(t, "remark42", m["site"])
|
||||||
assert.Equal(t, "dev", m["user_id"])
|
assert.Equal(t, "provider1_dev", m["user_id"])
|
||||||
|
|
||||||
tkn := m["token"]
|
tkn := m["token"]
|
||||||
claims, err := srv.Authenticator.TokenService().Parse(tkn)
|
claims, err := srv.Authenticator.TokenService().Parse(tkn)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "dev", claims.User.ID)
|
assert.Equal(t, "provider1_dev", claims.User.ID)
|
||||||
|
assert.Equal(t, "http://example.com/pic.png", claims.User.Picture,
|
||||||
|
"delete_me token must carry the user's picture so the avatar can be removed when the request is processed")
|
||||||
assert.Equal(t, "https://demo.remark42.com/web/deleteme.html?token="+tkn, m["link"])
|
assert.Equal(t, "https://demo.remark42.com/web/deleteme.html?token="+tkn, m["link"])
|
||||||
|
|
||||||
req, err = http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/deleteme?site=remark42", ts.URL), http.NoBody)
|
req, err = http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/deleteme?site=remark42", ts.URL), http.NoBody)
|
||||||
@@ -1272,7 +1520,7 @@ func TestRest_SavePictureCtrl(t *testing.T) {
|
|||||||
|
|
||||||
client := http.Client{}
|
client := http.Client{}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/picture", ts.URL), bodyBuf)
|
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/picture?site=remark42", ts.URL), bodyBuf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.Header.Add("Content-Type", contentType)
|
req.Header.Add("Content-Type", contentType)
|
||||||
req.Header.Add("X-JWT", devToken)
|
req.Header.Add("X-JWT", devToken)
|
||||||
@@ -1339,7 +1587,9 @@ func TestRest_CreateWithPictures(t *testing.T) {
|
|||||||
Staging: "/tmp/remark42/images.staging",
|
Staging: "/tmp/remark42/images.staging",
|
||||||
Location: "/tmp/remark42/images",
|
Location: "/tmp/remark42/images",
|
||||||
}, image.ServiceParams{
|
}, image.ServiceParams{
|
||||||
EditDuration: 100 * time.Millisecond,
|
// the "not moved yet" checks below run right after the comment POST returns, so the
|
||||||
|
// commit window has to be wide enough that a stalled runner cannot close it first
|
||||||
|
EditDuration: 3 * time.Second,
|
||||||
MaxSize: 2000,
|
MaxSize: 2000,
|
||||||
ImageAPI: svc.RemarkURL + "/api/v1/picture/",
|
ImageAPI: svc.RemarkURL + "/api/v1/picture/",
|
||||||
ProxyAPI: svc.RemarkURL + "/api/v1/img",
|
ProxyAPI: svc.RemarkURL + "/api/v1/img",
|
||||||
@@ -1364,7 +1614,7 @@ func TestRest_CreateWithPictures(t *testing.T) {
|
|||||||
require.NoError(t, bodyWriter.Close())
|
require.NoError(t, bodyWriter.Close())
|
||||||
client := http.Client{}
|
client := http.Client{}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/picture", ts.URL), bodyBuf)
|
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/picture?site=remark42", ts.URL), bodyBuf)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.Header.Add("Content-Type", contentType)
|
req.Header.Add("Content-Type", contentType)
|
||||||
req.Header.Add("X-JWT", devToken)
|
req.Header.Add("X-JWT", devToken)
|
||||||
@@ -1390,7 +1640,7 @@ func TestRest_CreateWithPictures(t *testing.T) {
|
|||||||
text := fmt.Sprintf(`text 123  *xxx*  `, svc.RemarkURL, ids[0], svc.RemarkURL, ids[1], svc.RemarkURL, ids[2])
|
text := fmt.Sprintf(`text 123  *xxx*  `, svc.RemarkURL, ids[0], svc.RemarkURL, ids[1], svc.RemarkURL, ids[2])
|
||||||
body := fmt.Sprintf(`{"text": %q, "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`, text)
|
body := fmt.Sprintf(`{"text": %q, "locator":{"url": "https://radio-t.com/blah1", "site": "remark42"}}`, text)
|
||||||
|
|
||||||
resp, err := post(t, ts.URL+"/api/v1/comment", body)
|
resp, err := post(t, ts.URL+"/api/v1/comment?site=remark42", body)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
b, err := io.ReadAll(resp.Body)
|
b, err := io.ReadAll(resp.Body)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -1402,11 +1652,12 @@ func TestRest_CreateWithPictures(t *testing.T) {
|
|||||||
assert.Error(t, err, "picture %d not moved from staging yet", i)
|
assert.Error(t, err, "picture %d not moved from staging yet", i)
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(1500 * time.Millisecond)
|
// the commit runs once EditDuration expires
|
||||||
|
|
||||||
for i := range ids {
|
for i := range ids {
|
||||||
_, err = os.Stat("/tmp/remark42/images/" + ids[i])
|
require.Eventually(t, func() bool {
|
||||||
assert.NoError(t, err, "picture %d moved from staging and available in permanent location", i)
|
_, e := os.Stat("/tmp/remark42/images/" + ids[i])
|
||||||
|
return e == nil
|
||||||
|
}, waitTimeout, pollInterval, "picture %d moved from staging and available in permanent location", i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1427,3 +1678,26 @@ func (m *mockTelegram) CheckToken(string, string) (telegram, site string, err er
|
|||||||
}
|
}
|
||||||
return "good_telegram", m.site, nil
|
return "good_telegram", m.site, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractIP(t *testing.T) {
|
||||||
|
tbl := []struct {
|
||||||
|
addr string
|
||||||
|
exp string
|
||||||
|
}{
|
||||||
|
{"127.0.0.1:8080", "127.0.0.1"},
|
||||||
|
{"127.0.0.1", "127.0.0.1"},
|
||||||
|
{"192.168.1.1:443", "192.168.1.1"},
|
||||||
|
{"[::1]:8080", "::1"},
|
||||||
|
{"::1", "::1"},
|
||||||
|
{"[2001:db8::1]:8080", "2001:db8::1"},
|
||||||
|
{"2001:db8::1", "2001:db8::1"},
|
||||||
|
{"[fe80::1%25eth0]:80", "fe80::1%25eth0"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tbl {
|
||||||
|
t.Run(tt.addr, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.exp, extractIP(tt.addr))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,18 +4,19 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"crypto/sha1" // nolint
|
"crypto/sha1" // nolint
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
cache "github.com/go-pkgz/lcw/v2"
|
||||||
"github.com/go-chi/render"
|
|
||||||
cache "github.com/go-pkgz/lcw"
|
|
||||||
log "github.com/go-pkgz/lgr"
|
log "github.com/go-pkgz/lgr"
|
||||||
R "github.com/go-pkgz/rest"
|
R "github.com/go-pkgz/rest"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/skip2/go-qrcode"
|
"github.com/skip2/go-qrcode"
|
||||||
|
|
||||||
"github.com/umputun/remark42/backend/app/rest"
|
"github.com/umputun/remark42/backend/app/rest"
|
||||||
@@ -48,8 +49,18 @@ type pubStore interface {
|
|||||||
Counts(siteID string, postIDs []string) ([]store.PostInfo, error)
|
Counts(siteID string, postIDs []string) ([]store.PostInfo, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /find?site=siteID&url=post-url&format=[tree|plain]&sort=[+/-time|+/-score|+/-controversy]&view=[user|all]&since=unix_ts_msec
|
// GET /find?site=siteID&url=post-url&format=[tree|plain]&sort=[+/-time|+/-score|+/-controversy]&view=[user|all]&since=unix_ts_msec&limit=100&offset_id={id}
|
||||||
// find comments for given post. Returns in tree or plain formats, sorted
|
// find comments for given post. Returns in tree or plain formats, sorted.
|
||||||
|
//
|
||||||
|
// When `url` parameter is not set (e.g. request is for site-wide comments), does not return deleted comments.
|
||||||
|
//
|
||||||
|
// When `limit` is set, first {limit} comments are returned. When `offset_id` is set, comments are returned starting
|
||||||
|
// after the comment with the given id.
|
||||||
|
// format="tree" limits comments by top-level comments and all their replies,
|
||||||
|
// and never returns parent comment with only part of replies.
|
||||||
|
//
|
||||||
|
// `count` in the response refers to total number of non-deleted comments,
|
||||||
|
// `count_left` to amount of comments left to be returned _including deleted_.
|
||||||
func (s *public) findCommentsCtrl(w http.ResponseWriter, r *http.Request) {
|
func (s *public) findCommentsCtrl(w http.ResponseWriter, r *http.Request) {
|
||||||
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
locator := store.Locator{SiteID: r.URL.Query().Get("site"), URL: r.URL.Query().Get("url")}
|
||||||
sort := r.URL.Query().Get("sort")
|
sort := r.URL.Query().Get("sort")
|
||||||
@@ -68,7 +79,24 @@ func (s *public) findCommentsCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
since = time.Time{} // since doesn't make sense for tree
|
since = time.Time{} // since doesn't make sense for tree
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[DEBUG] get comments for %+v, sort %s, format %s, since %v", locator, sort, format, since)
|
limitParam := r.URL.Query().Get("limit")
|
||||||
|
var limit int
|
||||||
|
if limitParam != "" {
|
||||||
|
if limit, err = strconv.Atoi(limitParam); err != nil {
|
||||||
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "bad limit value", rest.ErrCommentNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offsetID := r.URL.Query().Get("offset_id")
|
||||||
|
if offsetID != "" {
|
||||||
|
if _, err = uuid.Parse(offsetID); err != nil {
|
||||||
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "bad offset_id value", rest.ErrCommentNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[DEBUG] get comments for %+v, sort %s, format %s, since %v, limit %d, offset %s", locator, sort, format, since, limit, offsetID)
|
||||||
|
|
||||||
key := cache.NewKey(locator.SiteID).ID(URLKeyWithUser(r)).Scopes(locator.SiteID, locator.URL)
|
key := cache.NewKey(locator.SiteID).ID(URLKeyWithUser(r)).Scopes(locator.SiteID, locator.URL)
|
||||||
data, err := s.cache.Get(key, func() ([]byte, error) {
|
data, err := s.cache.Get(key, func() ([]byte, error) {
|
||||||
@@ -77,22 +105,44 @@ func (s *public) findCommentsCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
comments = []store.Comment{} // error should clear comments and continue for post info
|
comments = []store.Comment{} // error should clear comments and continue for post info
|
||||||
}
|
}
|
||||||
comments = s.applyView(comments, view)
|
comments = s.applyView(comments, view)
|
||||||
|
|
||||||
|
var commentsInfo store.PostInfo
|
||||||
|
if info, ee := s.dataService.Info(locator, s.readOnlyAge); ee == nil {
|
||||||
|
commentsInfo = info
|
||||||
|
}
|
||||||
|
|
||||||
|
if !since.IsZero() { // if since is set, number of comments can be different from total in the DB
|
||||||
|
commentsInfo.Count = 0
|
||||||
|
for _, c := range comments {
|
||||||
|
if !c.Deleted {
|
||||||
|
commentsInfo.Count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// post might be readonly without any comments, Info call will fail then and ReadOnly flag should be checked separately
|
||||||
|
if !commentsInfo.ReadOnly && locator.URL != "" && s.dataService.IsReadOnly(locator) {
|
||||||
|
commentsInfo.ReadOnly = true
|
||||||
|
}
|
||||||
|
|
||||||
var b []byte
|
var b []byte
|
||||||
switch format {
|
switch format {
|
||||||
case "tree":
|
case "tree":
|
||||||
tree := service.MakeTree(comments, sort, s.readOnlyAge)
|
withInfo := treeWithInfo{Tree: service.MakeTree(comments, sort, limit, offsetID), Info: commentsInfo}
|
||||||
if tree.Nodes == nil { // eliminate json nil serialization
|
withInfo.Info.CountLeft = withInfo.CountLeft()
|
||||||
tree.Nodes = []*service.Node{}
|
withInfo.Info.LastComment = withInfo.LastComment()
|
||||||
|
if withInfo.Nodes == nil { // eliminate json nil serialization
|
||||||
|
withInfo.Nodes = []*service.Node{}
|
||||||
}
|
}
|
||||||
if s.dataService.IsReadOnly(locator) {
|
b, e = encodeJSONWithHTML(withInfo)
|
||||||
tree.Info.ReadOnly = true
|
|
||||||
}
|
|
||||||
b, e = encodeJSONWithHTML(tree)
|
|
||||||
default:
|
default:
|
||||||
withInfo := commentsWithInfo{Comments: comments}
|
if limit > 0 || offsetID != "" {
|
||||||
if info, ee := s.dataService.Info(locator, s.readOnlyAge); ee == nil {
|
comments, commentsInfo.CountLeft = limitComments(comments, limit, offsetID)
|
||||||
withInfo.Info = info
|
|
||||||
}
|
}
|
||||||
|
if limit > 0 && len(comments) > 0 {
|
||||||
|
commentsInfo.LastComment = comments[len(comments)-1].ID
|
||||||
|
}
|
||||||
|
withInfo := commentsWithInfo{Comments: comments, Info: commentsInfo}
|
||||||
b, e = encodeJSONWithHTML(withInfo)
|
b, e = encodeJSONWithHTML(withInfo)
|
||||||
}
|
}
|
||||||
return b, e
|
return b, e
|
||||||
@@ -137,7 +187,7 @@ func (s *public) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
siteID := r.URL.Query().Get("site")
|
siteID := r.URL.Query().Get("site")
|
||||||
log.Printf("[DEBUG] get last comments for %s", siteID)
|
log.Printf("[DEBUG] get last comments for %s", siteID)
|
||||||
|
|
||||||
limit, err := strconv.Atoi(chi.URLParam(r, "limit"))
|
limit, err := strconv.Atoi(r.PathValue("limit"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
limit = 0
|
limit = 0
|
||||||
}
|
}
|
||||||
@@ -171,7 +221,7 @@ func (s *public) lastCommentsCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// GET /id/{id}?site=siteID&url=post-url - gets a comment by id
|
// GET /id/{id}?site=siteID&url=post-url - gets a comment by id
|
||||||
func (s *public) commentByIDCtrl(w http.ResponseWriter, r *http.Request) {
|
func (s *public) commentByIDCtrl(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := r.PathValue("id")
|
||||||
siteID := r.URL.Query().Get("site")
|
siteID := r.URL.Query().Get("site")
|
||||||
url := r.URL.Query().Get("url")
|
url := r.URL.Query().Get("url")
|
||||||
|
|
||||||
@@ -182,7 +232,6 @@ func (s *public) commentByIDCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get comment by id", rest.ErrCommentNotFound)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get comment by id", rest.ErrCommentNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
render.Status(r, http.StatusOK)
|
|
||||||
|
|
||||||
if err = R.RenderJSONWithHTML(w, r, comment); err != nil {
|
if err = R.RenderJSONWithHTML(w, r, comment); err != nil {
|
||||||
log.Printf("[WARN] can't render last comments for url=%s, id=%s", url, id)
|
log.Printf("[WARN] can't render last comments for url=%s, id=%s", url, id)
|
||||||
@@ -247,7 +296,7 @@ func (s *public) countCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get count", rest.ErrPostNotFound)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get count", rest.ErrPostNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
render.JSON(w, r, R.JSON{"count": count, "locator": locator})
|
R.RenderJSON(w, R.JSON{"count": count, "locator": locator})
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /counts?site=siteID - get number of comments for posts from post body
|
// POST /counts?site=siteID - get number of comments for posts from post body
|
||||||
@@ -255,7 +304,7 @@ func (s *public) countMultiCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
const countBodyLimit int64 = 1024 * 128 // count request can be big for some site because it lists all urls
|
const countBodyLimit int64 = 1024 * 128 // count request can be big for some site because it lists all urls
|
||||||
siteID := r.URL.Query().Get("site")
|
siteID := r.URL.Query().Get("site")
|
||||||
posts := []string{}
|
posts := []string{}
|
||||||
if err := render.DecodeJSON(http.MaxBytesReader(w, r.Body, countBodyLimit), &posts); err != nil {
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, countBodyLimit)).Decode(&posts); err != nil {
|
||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get list of posts from request", rest.ErrSiteNotFound)
|
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get list of posts from request", rest.ErrSiteNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -315,26 +364,81 @@ func (s *public) listCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// safePictureSegment reports whether seg is acceptable as a path segment in
|
||||||
|
// the picture URL (no traversal markers, no path separators, no control
|
||||||
|
// characters). Picture IDs are server-generated hashes plus a known
|
||||||
|
// extension, so any value carrying these characters is hostile and must be
|
||||||
|
// rejected before reaching the store. Rejecting controls (CR, LF, TAB, NUL,
|
||||||
|
// etc.) also closes a log-injection vector since the rejected segment is
|
||||||
|
// echoed into the access log.
|
||||||
|
func safePictureSegment(seg string) bool {
|
||||||
|
if seg == "" || seg == "." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(seg, "/\\") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(seg, "..") { // also covers seg == ".."
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range seg {
|
||||||
|
if unicode.IsControl(r) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendPictureError writes a no-store Cache-Control header and delegates to rest.SendErrorJSON.
|
||||||
|
// Used by every rejection branch in loadPictureCtrl so error responses never inherit the
|
||||||
|
// 7-day client cache of the success path.
|
||||||
|
func sendPictureError(w http.ResponseWriter, r *http.Request, status int, err error, details string, code int) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
rest.SendErrorJSON(w, r, status, err, details, code)
|
||||||
|
}
|
||||||
|
|
||||||
// GET /picture/{user}/{id} - get picture
|
// GET /picture/{user}/{id} - get picture
|
||||||
func (s *public) loadPictureCtrl(w http.ResponseWriter, r *http.Request) {
|
func (s *public) loadPictureCtrl(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "user") + "/" + chi.URLParam(r, "id")
|
rest.SetImageDefenseHeaders(w)
|
||||||
img, err := s.imageService.Load(id)
|
|
||||||
if err != nil {
|
user, imgID := r.PathValue("user"), r.PathValue("id")
|
||||||
rest.SendErrorJSON(w, r, http.StatusBadRequest, err, "can't get image "+id, rest.ErrAssetNotFound)
|
if user == "" || imgID == "" || !safePictureSegment(user) || !safePictureSegment(imgID) {
|
||||||
|
log.Printf("[WARN] rejected picture request with unsafe id segments user=%q id=%q", user, imgID)
|
||||||
|
sendPictureError(w, r, http.StatusBadRequest, fmt.Errorf("invalid picture id"), "invalid picture id", rest.ErrAssetNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// enforce client-side caching
|
id := user + "/" + imgID
|
||||||
|
img, err := s.imageService.Load(id)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] can't load image %s: %v", id, err)
|
||||||
|
sendPictureError(w, r, http.StatusBadRequest, fmt.Errorf("image not found"), "can't get image", rest.ErrAssetNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType, err := rest.SafeImgContentType(img)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] rejecting non-image picture %s: %v", id, err)
|
||||||
|
sendPictureError(w, r, http.StatusUnsupportedMediaType, err, "invalid image content", rest.ErrAssetNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// /picture/ does not need a security-version etag prefix — the upload flow
|
||||||
|
// validates input format (readAndValidateImage) and the serve path re-validates
|
||||||
|
// the stored bytes via rest.SafeImgContentType. Bytes within the resize dimension
|
||||||
|
// limits ARE preserved verbatim by resize, so the browser defense relies on the
|
||||||
|
// response headers (validated Content-Type + nosniff + strict CSP +
|
||||||
|
// Content-Disposition: inline), not on byte normalization. Picture IDs are limited
|
||||||
|
// to safePictureSegment (alphanumeric xid-generated guids), so the comma split
|
||||||
|
// inside rest.EtagMatches cannot collide; if the ID format ever changes, revisit.
|
||||||
etag := `"` + id + `"`
|
etag := `"` + id + `"`
|
||||||
w.Header().Set("Etag", etag)
|
w.Header().Set("Etag", etag)
|
||||||
w.Header().Set("Cache-Control", "max-age=604800") // 7 days
|
w.Header().Set("Cache-Control", "max-age=604800") // 7 days
|
||||||
if match := r.Header.Get("If-None-Match"); match != "" {
|
if match := r.Header.Get("If-None-Match"); match != "" && rest.EtagMatches(match, etag) {
|
||||||
if strings.Contains(match, etag) {
|
w.WriteHeader(http.StatusNotModified)
|
||||||
w.WriteHeader(http.StatusNotModified)
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", s.imageService.ImgContentType(img))
|
w.Header().Set("Content-Type", contentType)
|
||||||
w.Header().Set("Content-Length", strconv.Itoa(len(img)))
|
w.Header().Set("Content-Length", strconv.Itoa(len(img)))
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
if _, err = io.Copy(w, bytes.NewReader(img)); err != nil {
|
if _, err = io.Copy(w, bytes.NewReader(img)); err != nil {
|
||||||
@@ -343,13 +447,14 @@ func (s *public) loadPictureCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET /robots.txt
|
// GET /robots.txt
|
||||||
func (s *public) robotsCtrl(w http.ResponseWriter, r *http.Request) {
|
func (s *public) robotsCtrl(w http.ResponseWriter, _ *http.Request) {
|
||||||
allowed := []string{"/find", "/last", "/id", "/count", "/counts", "/list", "/config", "/user",
|
allowed := []string{"/find", "/last", "/id", "/count", "/counts", "/list", "/config", "/user",
|
||||||
"/img", "/avatar", "/picture"}
|
"/img", "/avatar", "/picture"}
|
||||||
for i := range allowed {
|
for i := range allowed {
|
||||||
allowed[i] = "Allow: /api/v1" + allowed[i]
|
allowed[i] = "Allow: /api/v1" + allowed[i]
|
||||||
}
|
}
|
||||||
render.PlainText(w, r, "User-agent: *\nDisallow: /auth/\nDisallow: /api/\n"+strings.Join(allowed, "\n")+"\n")
|
responseText := fmt.Sprintf("User-agent: *\nDisallow: /auth/\nDisallow: /api/\n%s\n", strings.Join(allowed, "\n"))
|
||||||
|
rest.PlainTextResponse(w, http.StatusOK, responseText)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /qr/telegram - generates QR for provided URL, used for Telegram auth and notifications subscription. The first
|
// GET /qr/telegram - generates QR for provided URL, used for Telegram auth and notifications subscription. The first
|
||||||
@@ -381,7 +486,7 @@ func (s *public) telegramQrCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "image/png")
|
w.Header().Set("Content-Type", "image/png")
|
||||||
if _, err = w.Write(png); err != nil {
|
if _, err = w.Write(png); err != nil { //nolint:gosec // png bytes from go-qrcode, not HTML
|
||||||
log.Printf("[WARN] can't render qr, %v", err)
|
log.Printf("[WARN] can't render qr, %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -416,3 +521,25 @@ func (s *public) parseSince(r *http.Request) (time.Time, error) {
|
|||||||
}
|
}
|
||||||
return sinceTS, nil
|
return sinceTS, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// limitComments returns limited list of comments and count of comments left after limit.
|
||||||
|
// If offsetID is provided, the list will be sliced starting from the comment with this ID.
|
||||||
|
// If offsetID is not found, the full list will be returned.
|
||||||
|
// It's used for only "
|
||||||
|
func limitComments(c []store.Comment, limit int, offsetID string) (comments []store.Comment, countLeft int) {
|
||||||
|
if offsetID != "" {
|
||||||
|
for i, comment := range c {
|
||||||
|
if comment.ID == offsetID {
|
||||||
|
c = c[i+1:]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit > 0 && len(c) > limit {
|
||||||
|
countLeft = len(c) - limit
|
||||||
|
c = c[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
return c, countLeft
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
cache "github.com/go-pkgz/lcw"
|
cache "github.com/go-pkgz/lcw/v2"
|
||||||
R "github.com/go-pkgz/rest"
|
R "github.com/go-pkgz/rest"
|
||||||
|
"github.com/go-pkgz/routegroup"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/umputun/remark42/backend/app/store"
|
"github.com/umputun/remark42/backend/app/store"
|
||||||
|
"github.com/umputun/remark42/backend/app/store/image"
|
||||||
"github.com/umputun/remark42/backend/app/store/service"
|
"github.com/umputun/remark42/backend/app/store/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,13 +84,31 @@ func TestRest_Preview(t *testing.T) {
|
|||||||
assert.NoError(t, resp.Body.Close())
|
assert.NoError(t, resp.Body.Close())
|
||||||
assert.Contains(t,
|
assert.Contains(t,
|
||||||
string(b),
|
string(b),
|
||||||
"{\"code\":20,\"details\":\"can't renew staged picture cleanup timer\","+
|
`{"code":20,"details":"can't load picture from the comment",`+
|
||||||
"\"error\":\"can't get image stats for dev_user/bad_picture: stat",
|
`"error":"can't get image stats for dev_user/bad_picture: stat`,
|
||||||
)
|
)
|
||||||
assert.Contains(t,
|
assert.Contains(t,
|
||||||
string(b),
|
string(b),
|
||||||
"/pics-remark42/staging/dev_user/62/bad_picture: no such file or directory\"}\n",
|
"/pics-remark42/staging/dev_user/62/bad_picture: no such file or directory\"}\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// test quotes with and without smartypants
|
||||||
|
resp, err = post(t, ts.URL+"/api/v1/preview", `{"text": "\"quoted\" text", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
b, err = io.ReadAll(resp.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NoError(t, resp.Body.Close())
|
||||||
|
assert.Equal(t, "<p>«quoted» text</p>\n", string(b))
|
||||||
|
|
||||||
|
srv.privRest.disableFancyTextFormatting = true
|
||||||
|
resp, err = post(t, ts.URL+"/api/v1/preview", `{"text": "\"quoted\" text", "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
b, err = io.ReadAll(resp.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NoError(t, resp.Body.Close())
|
||||||
|
assert.Equal(t, "<p>"quoted" text</p>\n", string(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRest_PreviewWithWrongImage(t *testing.T) {
|
func TestRest_PreviewWithWrongImage(t *testing.T) {
|
||||||
@@ -97,8 +123,8 @@ func TestRest_PreviewWithWrongImage(t *testing.T) {
|
|||||||
assert.NoError(t, resp.Body.Close())
|
assert.NoError(t, resp.Body.Close())
|
||||||
assert.Contains(t,
|
assert.Contains(t,
|
||||||
string(b),
|
string(b),
|
||||||
"{\"code\":20,\"details\":\"can't renew staged picture cleanup timer\","+
|
`{"code":20,"details":"can't load picture from the comment",`+
|
||||||
"\"error\":\"can't get image stats for dev_user/bad_picture: stat ",
|
`"error":"can't get image stats for dev_user/bad_picture: stat `,
|
||||||
)
|
)
|
||||||
assert.Contains(t,
|
assert.Contains(t,
|
||||||
string(b),
|
string(b),
|
||||||
@@ -120,9 +146,9 @@ srv, ts := prep(t)
|
|||||||
}
|
}
|
||||||
BKT
|
BKT
|
||||||
`
|
`
|
||||||
text = strings.Replace(text, "BKT", "```", -1)
|
text = strings.ReplaceAll(text, "BKT", "```")
|
||||||
j := fmt.Sprintf(`{"text": %q, "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, text)
|
j := fmt.Sprintf(`{"text": %q, "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, text)
|
||||||
j = strings.Replace(j, "\n", "\\n", -1)
|
j = strings.ReplaceAll(j, "\n", "\\n")
|
||||||
|
|
||||||
resp, err := post(t, ts.URL+"/api/v1/preview", j)
|
resp, err := post(t, ts.URL+"/api/v1/preview", j)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -131,10 +157,10 @@ BKT
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t,
|
assert.Equal(t,
|
||||||
`<h1>h1</h1>
|
`<h1>h1</h1>
|
||||||
<pre class="chroma"><code><span class="line"><span class="cl">func TestRest_Preview(t *testing.T) {
|
<pre class="chroma"><code><span class="line"><span class="cl"><span class="k">func</span> <span class="n">TestRest_Preview</span><span class="p">(</span><span class="n">t</span> <span class="o">*</span><span class="n">testing</span><span class="o">.</span><span class="n">T</span><span class="p">)</span> <span class="p">{</span>
|
||||||
</span></span><span class="line"><span class="cl">srv, ts := prep(t)
|
</span></span><span class="line"><span class="cl"><span class="n">srv</span><span class="p">,</span> <span class="n">ts</span> <span class="p">:</span><span class="o">=</span> <span class="n">prep</span><span class="p">(</span><span class="n">t</span><span class="p">)</span>
|
||||||
</span></span><span class="line"><span class="cl"> require.NotNil(t, srv)
|
</span></span><span class="line"><span class="cl"> <span class="n">require</span><span class="o">.</span><span class="n">NotNil</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">srv</span><span class="p">)</span>
|
||||||
</span></span><span class="line"><span class="cl">}
|
</span></span><span class="line"><span class="cl"><span class="p">}</span>
|
||||||
</span></span></code></pre>`,
|
</span></span></code></pre>`,
|
||||||
string(b))
|
string(b))
|
||||||
assert.NoError(t, resp.Body.Close())
|
assert.NoError(t, resp.Body.Close())
|
||||||
@@ -148,17 +174,17 @@ func TestRest_PreviewCode(t *testing.T) {
|
|||||||
func main(aa string) int {return 0}
|
func main(aa string) int {return 0}
|
||||||
BKT
|
BKT
|
||||||
`
|
`
|
||||||
text = strings.Replace(text, "BKT", "```", -1)
|
text = strings.ReplaceAll(text, "BKT", "```")
|
||||||
j := fmt.Sprintf(`{"text": %q, "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, text)
|
j := fmt.Sprintf(`{"text": %q, "locator":{"url": "https://radio-t.com/blah1", "site": "radio-t"}}`, text)
|
||||||
j = strings.Replace(j, "\n", "\\n", -1)
|
j = strings.ReplaceAll(j, "\n", "\\n")
|
||||||
|
|
||||||
resp, err := post(t, ts.URL+"/api/v1/preview", j)
|
resp, err := post(t, ts.URL+"/api/v1/preview", j)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
b, err := io.ReadAll(resp.Body)
|
b, err := io.ReadAll(resp.Body)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, `<pre class="chroma"><code><span class="line"><span class="cl"><span class="kd">func</span> <span class="nf">main</span><span class="p">(</span><span class="nx">aa</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">int</span> <span class="p">{</span><span class="k">return</span> <span class="mi">0</span><span class="p">}</span>
|
assert.Equal(t, `<pre class="chroma"><code><span class="line"><span class="cl"><span class="kd">func</span><span class="w"> </span><span class="nf">main</span><span class="p">(</span><span class="nx">aa</span><span class="w"> </span><span class="kt">string</span><span class="p">)</span><span class="w"> </span><span class="kt">int</span><span class="w"> </span><span class="p">{</span><span class="k">return</span><span class="w"> </span><span class="mi">0</span><span class="p">}</span><span class="w">
|
||||||
</span></span></code></pre>`, string(b))
|
</span></span></span></code></pre>`, string(b))
|
||||||
assert.NoError(t, resp.Body.Close())
|
assert.NoError(t, resp.Body.Close())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +235,7 @@ func TestRest_Find(t *testing.T) {
|
|||||||
assert.Equal(t, id2, comments.Comments[0].ID)
|
assert.Equal(t, id2, comments.Comments[0].ID)
|
||||||
|
|
||||||
// get in tree mode
|
// get in tree mode
|
||||||
tree := service.Tree{}
|
tree := treeWithInfo{}
|
||||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&format=tree")
|
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&format=tree")
|
||||||
assert.Equal(t, http.StatusOK, code)
|
assert.Equal(t, http.StatusOK, code)
|
||||||
err = json.Unmarshal([]byte(res), &tree)
|
err = json.Unmarshal([]byte(res), &tree)
|
||||||
@@ -235,7 +261,7 @@ func TestRest_FindAge(t *testing.T) {
|
|||||||
_, err = srv.DataService.Create(c2)
|
_, err = srv.DataService.Create(c2)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
tree := service.Tree{}
|
tree := treeWithInfo{}
|
||||||
|
|
||||||
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&format=tree")
|
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&format=tree")
|
||||||
assert.Equal(t, http.StatusOK, code)
|
assert.Equal(t, http.StatusOK, code)
|
||||||
@@ -278,7 +304,7 @@ func TestRest_FindReadOnly(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
|
|
||||||
tree := service.Tree{}
|
tree := treeWithInfo{}
|
||||||
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&format=tree")
|
res, code := get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah1&format=tree")
|
||||||
assert.Equal(t, http.StatusOK, code)
|
assert.Equal(t, http.StatusOK, code)
|
||||||
err = json.Unmarshal([]byte(res), &tree)
|
err = json.Unmarshal([]byte(res), &tree)
|
||||||
@@ -286,7 +312,7 @@ func TestRest_FindReadOnly(t *testing.T) {
|
|||||||
assert.Equal(t, "https://radio-t.com/blah1", tree.Info.URL)
|
assert.Equal(t, "https://radio-t.com/blah1", tree.Info.URL)
|
||||||
assert.True(t, tree.Info.ReadOnly, "post is ro")
|
assert.True(t, tree.Info.ReadOnly, "post is ro")
|
||||||
|
|
||||||
tree = service.Tree{}
|
tree = treeWithInfo{}
|
||||||
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah2&format=tree")
|
res, code = get(t, ts.URL+"/api/v1/find?site=remark42&url=https://radio-t.com/blah2&format=tree")
|
||||||
assert.Equal(t, http.StatusOK, code)
|
assert.Equal(t, http.StatusOK, code)
|
||||||
err = json.Unmarshal([]byte(res), &tree)
|
err = json.Unmarshal([]byte(res), &tree)
|
||||||
@@ -325,8 +351,8 @@ func TestRest_FindUserView(t *testing.T) {
|
|||||||
require.Equal(t, 2, len(comments.Comments), "should have 2 comments")
|
require.Equal(t, 2, len(comments.Comments), "should have 2 comments")
|
||||||
assert.Equal(t, id1, comments.Comments[0].ID)
|
assert.Equal(t, id1, comments.Comments[0].ID)
|
||||||
assert.Equal(t, id2, comments.Comments[1].ID)
|
assert.Equal(t, id2, comments.Comments[1].ID)
|
||||||
assert.Equal(t, "dev", comments.Comments[0].User.ID)
|
assert.Equal(t, "provider1_dev", comments.Comments[0].User.ID)
|
||||||
assert.Equal(t, "dev", comments.Comments[1].User.ID)
|
assert.Equal(t, "provider1_dev", comments.Comments[1].User.ID)
|
||||||
assert.Equal(t, "", comments.Comments[0].Text)
|
assert.Equal(t, "", comments.Comments[0].Text)
|
||||||
assert.Equal(t, "", comments.Comments[1].Text)
|
assert.Equal(t, "", comments.Comments[1].Text)
|
||||||
|
|
||||||
@@ -356,11 +382,12 @@ func TestRest_Last(t *testing.T) {
|
|||||||
c2 := store.Comment{Text: "test test #2", ParentID: "p1",
|
c2 := store.Comment{Text: "test test #2", ParentID: "p1",
|
||||||
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah2"}}
|
Locator: store.Locator{SiteID: "remark42", URL: "https://radio-t.com/blah2"}}
|
||||||
|
|
||||||
// add 3 comments
|
// add 3 comments, with the clock pushed past a millisecond boundary in between so the two
|
||||||
|
// "since" values below are distinct
|
||||||
ts1 := time.Now().UnixNano() / 1000000
|
ts1 := time.Now().UnixNano() / 1000000
|
||||||
addComment(t, c1, ts)
|
addComment(t, c1, ts)
|
||||||
id1 := addComment(t, c1, ts)
|
id1 := addComment(t, c1, ts)
|
||||||
time.Sleep(10 * time.Millisecond)
|
waitPastMillisecond(time.Now())
|
||||||
ts2 := time.Now().UnixNano() / 1000000
|
ts2 := time.Now().UnixNano() / 1000000
|
||||||
id2 := addComment(t, c2, ts)
|
id2 := addComment(t, c2, ts)
|
||||||
|
|
||||||
@@ -440,7 +467,7 @@ func TestRest_FindUserComments(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, code, "noting for user blah")
|
assert.Equal(t, http.StatusOK, code, "noting for user blah")
|
||||||
assert.Equal(t, `{"comments":[],"count":0}`+"\n", comments)
|
assert.Equal(t, `{"comments":[],"count":0}`+"\n", comments)
|
||||||
{
|
{
|
||||||
res, code := get(t, ts.URL+"/api/v1/comments?site=remark42&user=dev")
|
res, code := get(t, ts.URL+"/api/v1/comments?site=remark42&user=provider1_dev")
|
||||||
assert.Equal(t, http.StatusOK, code)
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
|
||||||
resp := struct {
|
resp := struct {
|
||||||
@@ -459,7 +486,7 @@ func TestRest_FindUserComments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
res, code := get(t, ts.URL+"/api/v1/comments?site=remark42&user=dev&skip=1&limit=2")
|
res, code := get(t, ts.URL+"/api/v1/comments?site=remark42&user=provider1_dev&skip=1&limit=2")
|
||||||
assert.Equal(t, http.StatusOK, code)
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
|
||||||
resp := struct {
|
resp := struct {
|
||||||
@@ -477,6 +504,298 @@ func TestRest_FindUserComments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRest_FindUserComments_CWE_918(t *testing.T) {
|
||||||
|
ts, srv, teardown := startupT(t)
|
||||||
|
srv.DataService.TitleExtractor = service.NewTitleExtractor(http.Client{Timeout: time.Second}, []string{"radio-t.com"}) // required for extracting the title, bad URL test
|
||||||
|
defer srv.DataService.TitleExtractor.Close()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
backendRequestedArbitraryServer := false
|
||||||
|
arbitraryServer := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Logf("request received: %+v", r)
|
||||||
|
backendRequestedArbitraryServer = true
|
||||||
|
}))
|
||||||
|
defer arbitraryServer.Close()
|
||||||
|
|
||||||
|
arbitraryURLComment := store.Comment{Text: "arbitrary URL request test",
|
||||||
|
Locator: store.Locator{SiteID: "remark42", URL: arbitraryServer.URL}}
|
||||||
|
|
||||||
|
assert.False(t, backendRequestedArbitraryServer)
|
||||||
|
addComment(t, arbitraryURLComment, ts)
|
||||||
|
assert.False(t, backendRequestedArbitraryServer,
|
||||||
|
"no request is expected to the test server as it's not in the list of the allowed domains for the title extractor")
|
||||||
|
|
||||||
|
res, code := get(t, ts.URL+"/api/v1/comments?site=remark42&user=provider1_dev")
|
||||||
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
|
||||||
|
resp := struct {
|
||||||
|
Comments []store.Comment
|
||||||
|
Count int
|
||||||
|
}{}
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(res), &resp)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
require.Equal(t, 1, len(resp.Comments), "should have 2 comments")
|
||||||
|
|
||||||
|
assert.Equal(t, "", resp.Comments[0].PostTitle, "empty from the first post")
|
||||||
|
assert.Equal(t, arbitraryServer.URL, resp.Comments[0].Locator.URL, "arbitrary URL provided by the request")
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitPastMillisecond blocks until the wall clock moves past ts's millisecond, so whatever is
|
||||||
|
// created next gets a distinct value for the millisecond-precision "since" filter
|
||||||
|
func waitPastMillisecond(ts time.Time) {
|
||||||
|
next := ts.Truncate(time.Millisecond).Add(time.Millisecond)
|
||||||
|
time.Sleep(time.Until(next) + time.Microsecond) // a non-positive duration returns at once
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublic_FindCommentsCtrl_ConsistentCount(t *testing.T) {
|
||||||
|
// test that comment counting is consistent between tree and plain formats
|
||||||
|
// the open-route limit is lifted so the subtests below can run back to back
|
||||||
|
ts, srv, teardown := startupT(t, func(srv *Rest) { srv.openRouteLimiter = 100000 })
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
commentLocator := store.Locator{URL: "test-url", SiteID: "remark42"}
|
||||||
|
|
||||||
|
// vote for comment multiple times
|
||||||
|
setScore := func(locator store.Locator, id string, val int) {
|
||||||
|
abs := func(x int) int {
|
||||||
|
if x < 0 {
|
||||||
|
return -x
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
for i := 0; i < abs(val); i++ {
|
||||||
|
_, err := srv.DataService.Vote(service.VoteReq{
|
||||||
|
Locator: locator,
|
||||||
|
CommentID: id,
|
||||||
|
// unique user ID is needed for correct counting of controversial votes
|
||||||
|
UserID: "user" + strconv.Itoa(val) + strconv.Itoa(i),
|
||||||
|
Val: val > 0,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adding initial comments (8 to test-url and 1 to another-url) and voting, and delete two of comments to the first post.
|
||||||
|
// each comment waits for the clock to pass the previous one's millisecond so the "since"
|
||||||
|
// filter, which has millisecond precision, can tell them apart
|
||||||
|
ids := make([]string, 9)
|
||||||
|
timestamps := make([]time.Time, 9)
|
||||||
|
c1 := store.Comment{Text: "top-level comment 1", Locator: commentLocator}
|
||||||
|
ids[0], timestamps[0] = addCommentGetCreatedTime(t, c1, ts)
|
||||||
|
// #3 by score
|
||||||
|
setScore(commentLocator, ids[0], 1)
|
||||||
|
waitPastMillisecond(timestamps[0])
|
||||||
|
|
||||||
|
c2 := store.Comment{Text: "top-level comment 2", Locator: commentLocator}
|
||||||
|
ids[1], timestamps[1] = addCommentGetCreatedTime(t, c2, ts)
|
||||||
|
// #2 by score
|
||||||
|
setScore(commentLocator, ids[1], 2)
|
||||||
|
waitPastMillisecond(timestamps[1])
|
||||||
|
|
||||||
|
c3 := store.Comment{Text: "second-level comment 1", ParentID: ids[0], Locator: commentLocator}
|
||||||
|
ids[2], timestamps[2] = addCommentGetCreatedTime(t, c3, ts)
|
||||||
|
// #1 by score
|
||||||
|
setScore(commentLocator, ids[2], 10)
|
||||||
|
waitPastMillisecond(timestamps[2])
|
||||||
|
|
||||||
|
c4 := store.Comment{Text: "third-level comment 1", ParentID: ids[2], Locator: commentLocator}
|
||||||
|
ids[3], timestamps[3] = addCommentGetCreatedTime(t, c4, ts)
|
||||||
|
// #5 by score, #1 by controversy
|
||||||
|
setScore(commentLocator, ids[3], 4)
|
||||||
|
setScore(commentLocator, ids[3], -4)
|
||||||
|
waitPastMillisecond(timestamps[3])
|
||||||
|
|
||||||
|
c5 := store.Comment{Text: "second-level comment 2", ParentID: ids[1], Locator: commentLocator}
|
||||||
|
ids[4], timestamps[4] = addCommentGetCreatedTime(t, c5, ts)
|
||||||
|
// #5 by score, #2 by controversy
|
||||||
|
setScore(commentLocator, ids[4], 2)
|
||||||
|
setScore(commentLocator, ids[4], -3)
|
||||||
|
waitPastMillisecond(timestamps[4])
|
||||||
|
|
||||||
|
c6 := store.Comment{Text: "deleted third-level comment 2", ParentID: ids[4], Locator: commentLocator}
|
||||||
|
ids[5], timestamps[5] = addCommentGetCreatedTime(t, c6, ts)
|
||||||
|
// deleted later so not visible in site-wide requests
|
||||||
|
setScore(commentLocator, ids[5], 10)
|
||||||
|
setScore(commentLocator, ids[5], -10)
|
||||||
|
waitPastMillisecond(timestamps[5])
|
||||||
|
|
||||||
|
c7 := store.Comment{Text: "top-level comment 3", Locator: commentLocator}
|
||||||
|
ids[6], timestamps[6] = addCommentGetCreatedTime(t, c7, ts)
|
||||||
|
// #6 by score, #4 by controversy
|
||||||
|
setScore(commentLocator, ids[6], -3)
|
||||||
|
setScore(commentLocator, ids[6], 1)
|
||||||
|
waitPastMillisecond(timestamps[6])
|
||||||
|
|
||||||
|
c8 := store.Comment{Text: "deleted second-level comment 3", ParentID: ids[6], Locator: commentLocator}
|
||||||
|
ids[7], timestamps[7] = addCommentGetCreatedTime(t, c8, ts)
|
||||||
|
// deleted later so not visible in site-wide requests
|
||||||
|
setScore(commentLocator, ids[7], -20)
|
||||||
|
|
||||||
|
c9 := store.Comment{Text: "comment to post 2", Locator: store.Locator{URL: "another-url", SiteID: "remark42"}}
|
||||||
|
ids[8], timestamps[8] = addCommentGetCreatedTime(t, c9, ts)
|
||||||
|
// #7 by score
|
||||||
|
setScore(store.Locator{URL: "another-url", SiteID: "remark42"}, ids[8], -25)
|
||||||
|
|
||||||
|
// delete two comments bringing the total from 9 to 6
|
||||||
|
err := srv.DataService.Delete(commentLocator, ids[7], store.SoftDelete)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
err = srv.DataService.Delete(commentLocator, ids[5], store.HardDelete)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
srv.Cache.Flush(cache.FlusherRequest{})
|
||||||
|
|
||||||
|
commentLocator.URL = "readonly-test"
|
||||||
|
// set post without comments to read-only
|
||||||
|
assert.NoError(t, srv.DataService.SetReadOnly(commentLocator, true))
|
||||||
|
|
||||||
|
sinceTenSecondsAgo := strconv.FormatInt(time.Now().Add(-time.Second*10).UnixNano()/1000000, 10)
|
||||||
|
sinceTS := make([]string, 9)
|
||||||
|
formattedTS := make([]string, 9)
|
||||||
|
for i, created := range timestamps {
|
||||||
|
sinceTS[i] = strconv.FormatInt(created.UnixNano()/1000000, 10)
|
||||||
|
formattedTS[i] = created.Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
t.Logf("last timestamp: %v", timestamps[7])
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
params string
|
||||||
|
expectedBody string
|
||||||
|
}{
|
||||||
|
// test parameters url, format, since, sort
|
||||||
|
{"", fmt.Sprintf(`"info":{"count":7,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[8])},
|
||||||
|
{"url=test-url", fmt.Sprintf(`"info":{"url":"test-url","count":6,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[7])},
|
||||||
|
{"format=plain", fmt.Sprintf(`"info":{"count":7,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[8])},
|
||||||
|
{"format=plain&url=test-url", fmt.Sprintf(`"info":{"url":"test-url","count":6,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[7])},
|
||||||
|
{"since=" + sinceTenSecondsAgo, fmt.Sprintf(`"info":{"count":7,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[8])},
|
||||||
|
{"url=test-url&since=" + sinceTenSecondsAgo, fmt.Sprintf(`"info":{"url":"test-url","count":6,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[7])},
|
||||||
|
{"since=" + sinceTS[0], fmt.Sprintf(`"info":{"count":7,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[8])},
|
||||||
|
{"url=test-url&since=" + sinceTS[0], fmt.Sprintf(`"info":{"url":"test-url","count":6,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[7])},
|
||||||
|
{"since=" + sinceTS[1], fmt.Sprintf(`"info":{"count":6,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[8])},
|
||||||
|
{"url=test-url&since=" + sinceTS[1], fmt.Sprintf(`"info":{"url":"test-url","count":5,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[7])},
|
||||||
|
{"since=" + sinceTS[4], fmt.Sprintf(`"info":{"count":3,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[8])},
|
||||||
|
{"url=test-url&since=" + sinceTS[4], fmt.Sprintf(`"info":{"url":"test-url","count":2,"count_left":0,"first_time":%q,"last_time":%q}`, formattedTS[0], formattedTS[7])},
|
||||||
|
{"format=tree", `"info":{"count":7`},
|
||||||
|
{"format=tree&url=test-url", `"info":{"url":"test-url","count":6`},
|
||||||
|
{"format=tree&sort=+time", `"info":{"count":7`},
|
||||||
|
{"format=tree&url=test-url&sort=+time", `"info":{"url":"test-url","count":6`},
|
||||||
|
{"format=tree&sort=-score", `"info":{"count":7`},
|
||||||
|
{"format=tree&url=test-url&sort=-score", `"info":{"url":"test-url","count":6`},
|
||||||
|
{"sort=+time", fmt.Sprintf(`"score":-25,"vote":0,"time":%q}],"info":{"count":7`, formattedTS[8])},
|
||||||
|
{"sort=-time", fmt.Sprintf(`"score":1,"vote":0,"time":%q}],"info":{"count":7`, formattedTS[0])},
|
||||||
|
{"sort=+score", fmt.Sprintf(`"score":10,"vote":0,"time":%q}],"info":{"count":7`, formattedTS[2])},
|
||||||
|
{"sort=+score&url=test-url", fmt.Sprintf(`"score":10,"vote":0,"time":%q}],"info":{"url":"test-url","count":6`, formattedTS[2])},
|
||||||
|
{"sort=-score", fmt.Sprintf(`"score":-25,"vote":0,"time":%q}],"info":{"count":7`, formattedTS[8])},
|
||||||
|
{"sort=-score&url=test-url", fmt.Sprintf(`"score":-2,"vote":0,"controversy":1.5874010519681994,"time":%q}],"info":{"url":"test-url","count":6`, formattedTS[6])},
|
||||||
|
{"sort=-time&since=" + sinceTS[4], fmt.Sprintf(`"score":-1,"vote":0,"controversy":2.924017738212866,"time":%q}],"info":{"count":3`, formattedTS[4])},
|
||||||
|
{"sort=-score&since=" + sinceTS[3], fmt.Sprintf(`"score":-25,"vote":0,"time":%q}],"info":{"count":4`, formattedTS[8])},
|
||||||
|
{"sort=-score&url=test-url&since=" + sinceTS[3], fmt.Sprintf(`"score":-2,"vote":0,"controversy":1.5874010519681994,"time":%q}],"info":{"url":"test-url","count":3`, formattedTS[6])},
|
||||||
|
{"sort=+controversy&url=test-url&since=" + sinceTS[5], fmt.Sprintf(`"score":-2,"vote":0,"controversy":1.5874010519681994,"time":%q}],"info":{"url":"test-url","count":1`, formattedTS[6])},
|
||||||
|
// three comments of which last one deleted and doesn't have controversy so returned last
|
||||||
|
{"sort=-controversy&url=test-url&since=" + sinceTS[5], fmt.Sprintf(`"score":0,"vote":0,"time":%q,"delete":true}],"info":{"url":"test-url","count":1`, formattedTS[7])},
|
||||||
|
// test readonly status for the post without comments
|
||||||
|
{"url=readonly-test", `"info":{"count":0,"count_left":0,"read_only":true`},
|
||||||
|
{"format=tree&url=readonly-test", `"info":{"count":0,"count_left":0,"read_only":true`},
|
||||||
|
|
||||||
|
// test parameters limit, offset_id for format=plain
|
||||||
|
{"limit=bad", `{"code":1,"details":"bad limit value","error":"strconv.Atoi: parsing \"bad\": invalid syntax"}`},
|
||||||
|
{"offset_id=bad", `{"code":1,"details":"bad offset_id value","error":"invalid UUID length: 3"}`},
|
||||||
|
{"limit=2", `"info":{"count":7,"count_left":5,"last_comment":"` + ids[1]},
|
||||||
|
{"limit=6", `"info":{"count":7,"count_left":1,"last_comment":"` + ids[6]},
|
||||||
|
{"limit=7", `"info":{"count":7,"count_left":0,"last_comment":"` + ids[8]},
|
||||||
|
{"limit=2&url=test-url", `"info":{"url":"test-url","count":6,"count_left":6,"last_comment":"` + ids[1]},
|
||||||
|
{"limit=6&url=test-url", `"info":{"url":"test-url","count":6,"count_left":2,"last_comment":"` + ids[5]},
|
||||||
|
{"limit=7&url=test-url", `"info":{"url":"test-url","count":6,"count_left":1,"last_comment":"` + ids[6]},
|
||||||
|
{fmt.Sprintf("limit=2&offset_id=%s", ids[2]), `"info":{"count":7,"count_left":2,"last_comment":"` + ids[4]},
|
||||||
|
{fmt.Sprintf("limit=2&offset_id=%s", ids[3]), `"info":{"count":7,"count_left":1,"last_comment":"` + ids[6]},
|
||||||
|
{fmt.Sprintf("limit=2&offset_id=%s", ids[4]), `"info":{"count":7,"count_left":0`},
|
||||||
|
{fmt.Sprintf("limit=1&offset_id=%s", ids[6]), `"info":{"count":7,"count_left":0`},
|
||||||
|
{fmt.Sprintf("limit=2&offset_id=%s", ids[8]), `"info":{"count":7,"count_left":0`},
|
||||||
|
{fmt.Sprintf("limit=2&url=test-url&offset_id=%s", ids[2]), `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[4]},
|
||||||
|
{fmt.Sprintf("limit=2&url=test-url&offset_id=%s", ids[3]), `"info":{"url":"test-url","count":6,"count_left":2,"last_comment":"` + ids[5]},
|
||||||
|
{fmt.Sprintf("limit=2&url=test-url&offset_id=%s", ids[4]), `"info":{"url":"test-url","count":6,"count_left":1,"last_comment":"` + ids[6]},
|
||||||
|
{fmt.Sprintf("limit=1&url=test-url&offset_id=%s", ids[6]), `"info":{"url":"test-url","count":6,"count_left":0,"last_comment":"` + ids[7]},
|
||||||
|
{fmt.Sprintf("limit=2&url=test-url&offset_id=%s", ids[8]), `"info":{"url":"test-url","count":6,"count_left":6,`},
|
||||||
|
// deleted comment, offset is ignored in site-wide request but not for particular URL
|
||||||
|
{fmt.Sprintf("limit=2&offset_id=%s", ids[5]), `"info":{"count":7,"count_left":5,"last_comment":"` + ids[1]},
|
||||||
|
{fmt.Sprintf("limit=2&url=test-url&offset_id=%s", ids[5]), `"info":{"url":"test-url","count":6,"count_left":0,"last_comment":"` + ids[7]},
|
||||||
|
// non-existing comment, offset is ignored, deleted comments included into request with "url"
|
||||||
|
{fmt.Sprintf("limit=1&offset_id=%s", uuid.New().String()), `"info":{"count":7,"count_left":6,"last_comment":"` + ids[0]},
|
||||||
|
{fmt.Sprintf("limit=1&url=test-url&offset_id=%s", uuid.New().String()), `"info":{"url":"test-url","count":6,"count_left":7,"last_comment":"` + ids[0]},
|
||||||
|
// since is ignored for tree format, so we test it only for plain
|
||||||
|
{"limit=6&since=" + sinceTenSecondsAgo, `"info":{"count":7,"count_left":1,"last_comment":"` + ids[6]},
|
||||||
|
{"limit=1&since=" + sinceTS[4], `"info":{"count":3,"count_left":2,"last_comment":"` + ids[4]},
|
||||||
|
{"limit=6&url=test-url&since=" + sinceTenSecondsAgo, `"info":{"url":"test-url","count":6,"count_left":2,"last_comment":"` + ids[5]},
|
||||||
|
{"limit=1&url=test-url&since=" + sinceTS[4], `"info":{"url":"test-url","count":2,"count_left":3,"last_comment":"` + ids[4]},
|
||||||
|
// start with deleted comment timestamp
|
||||||
|
{"limit=1&since=" + sinceTS[5], `"info":{"count":2,"count_left":1,"last_comment":"` + ids[6]},
|
||||||
|
{"limit=1&since=" + sinceTS[6], `"info":{"count":2,"count_left":1,"last_comment":"` + ids[6]},
|
||||||
|
{"limit=1&url=test-url&since=" + sinceTS[5], `"info":{"url":"test-url","count":1,"count_left":2,"last_comment":"` + ids[5]},
|
||||||
|
{"limit=1&url=test-url&since=" + sinceTS[6], `"info":{"url":"test-url","count":1,"count_left":1,"last_comment":"` + ids[6]},
|
||||||
|
// test sort
|
||||||
|
{"limit=1&sort=+time&url=test-url", `"info":{"url":"test-url","count":6,"count_left":7,"last_comment":"` + ids[0]},
|
||||||
|
{"limit=1&sort=-time&url=test-url", `"info":{"url":"test-url","count":6,"count_left":7,"last_comment":"` + ids[7]},
|
||||||
|
{"limit=1&sort=+score&url=test-url", `"info":{"url":"test-url","count":6,"count_left":7,"last_comment":"` + ids[6]},
|
||||||
|
{"limit=1&sort=-score&url=test-url", `"info":{"url":"test-url","count":6,"count_left":7,"last_comment":"` + ids[2]},
|
||||||
|
{"limit=1&sort=+controversy&url=test-url", `"info":{"url":"test-url","count":6,"count_left":7,"last_comment":"` + ids[0]},
|
||||||
|
{"limit=1&sort=-controversy&url=test-url", `"info":{"url":"test-url","count":6,"count_left":7,"last_comment":"` + ids[3]},
|
||||||
|
|
||||||
|
// test parameters limit, offset_id for format=tree
|
||||||
|
{"format=tree&limit=bad", `{"code":1,"details":"bad limit value","error":"strconv.Atoi: parsing \"bad\": invalid syntax"}`},
|
||||||
|
{"format=tree&offset_id=bad", `{"code":1,"details":"bad offset_id value","error":"invalid UUID length: 3"}`},
|
||||||
|
{"format=tree&limit=2", `"info":{"count":7,"count_left":4,"last_comment":"` + ids[0]},
|
||||||
|
{"format=tree&limit=6", `"info":{"count":7,"count_left":1,"last_comment":"` + ids[6]},
|
||||||
|
{"format=tree&limit=7", `"info":{"count":7,"count_left":0,"last_comment":"` + ids[8]},
|
||||||
|
{"format=tree&url=test-url&limit=2", `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[0]},
|
||||||
|
{"format=tree&url=test-url&limit=6", `"info":{"url":"test-url","count":6,"count_left":0,"last_comment":"` + ids[6]},
|
||||||
|
{"format=tree&url=test-url&limit=7", `"info":{"url":"test-url","count":6,"count_left":0,"last_comment":"` + ids[6]},
|
||||||
|
// start after first top-level comment
|
||||||
|
{fmt.Sprintf("format=tree&limit=2&offset_id=%s", ids[0]), `"info":{"count":7,"count_left":2,"last_comment":"` + ids[1]},
|
||||||
|
{fmt.Sprintf("format=tree&url=test-url&limit=2&offset_id=%s", ids[0]), `"info":{"url":"test-url","count":6,"count_left":1,"last_comment":"` + ids[1]},
|
||||||
|
// start after second top-level comment
|
||||||
|
{fmt.Sprintf("format=tree&limit=2&offset_id=%s", ids[1]), `"info":{"count":7,"count_left":0,"last_comment":"` + ids[8]},
|
||||||
|
{fmt.Sprintf("format=tree&url=test-url&limit=2&offset_id=%s", ids[1]), `"info":{"url":"test-url","count":6,"count_left":0,"last_comment":"` + ids[6]},
|
||||||
|
// start after third top-level comment, so expect comment to post 2, or no comments on post 1 if "url" is set
|
||||||
|
{fmt.Sprintf("format=tree&limit=1&offset_id=%s", ids[6]), `"info":{"count":7,"count_left":0,"last_comment":"` + ids[8]},
|
||||||
|
{fmt.Sprintf("format=tree&url=test-url&limit=1&offset_id=%s", ids[6]), `"info":{"url":"test-url","count":6,"count_left":0`},
|
||||||
|
// non-root comment IDs or non-existing IDs are ignored
|
||||||
|
{fmt.Sprintf("format=tree&limit=2&offset_id=%s", ids[2]), `"info":{"count":7,"count_left":4,"last_comment":"` + ids[0]},
|
||||||
|
{fmt.Sprintf("format=tree&limit=2&offset_id=%s", ids[3]), `"info":{"count":7,"count_left":4,"last_comment":"` + ids[0]},
|
||||||
|
{fmt.Sprintf("format=tree&limit=2&offset_id=%s", ids[4]), `"info":{"count":7,"count_left":4,"last_comment":"` + ids[0]},
|
||||||
|
{fmt.Sprintf("format=tree&limit=2&offset_id=%s", ids[7]), `"info":{"count":7,"count_left":4,"last_comment":"` + ids[0]},
|
||||||
|
{fmt.Sprintf("format=tree&limit=1&offset_id=%s", uuid.New().String()), `"info":{"count":7,"count_left":4,"last_comment":"` + ids[0]},
|
||||||
|
{fmt.Sprintf("format=tree&url=test-url&limit=2&offset_id=%s", ids[2]), `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[0]},
|
||||||
|
{fmt.Sprintf("format=tree&url=test-url&limit=2&offset_id=%s", ids[3]), `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[0]},
|
||||||
|
{fmt.Sprintf("format=tree&url=test-url&limit=2&offset_id=%s", ids[4]), `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[0]},
|
||||||
|
{fmt.Sprintf("format=tree&url=test-url&limit=2&offset_id=%s", ids[7]), `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[0]},
|
||||||
|
{fmt.Sprintf("format=tree&url=test-url&limit=1&offset_id=%s", uuid.New().String()), `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[0]},
|
||||||
|
// test sort
|
||||||
|
{"format=tree&limit=1&sort=+time&url=test-url", `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[0]},
|
||||||
|
{"format=tree&limit=1&sort=-time&url=test-url", `"info":{"url":"test-url","count":6,"count_left":5,"last_comment":"` + ids[6]},
|
||||||
|
{"format=tree&limit=1&sort=+score&url=test-url", `"info":{"url":"test-url","count":6,"count_left":5,"last_comment":"` + ids[6]},
|
||||||
|
{"format=tree&limit=1&sort=-score&url=test-url", `"info":{"url":"test-url","count":6,"count_left":4,"last_comment":"` + ids[1]},
|
||||||
|
{"format=tree&limit=1&sort=+controversy&url=test-url", `"info":{"url":"test-url","count":6,"count_left":3,"last_comment":"` + ids[0]},
|
||||||
|
{"format=tree&limit=1&sort=-controversy&url=test-url", `"info":{"url":"test-url","count":6,"count_left":5,"last_comment":"` + ids[6]},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.params, func(t *testing.T) {
|
||||||
|
url := fmt.Sprintf(ts.URL+"/api/v1/find?site=remark42&%s", tc.params)
|
||||||
|
body, code := get(t, url)
|
||||||
|
// bad-request cases are identified by their error response body rather than
|
||||||
|
// a "=bad" substring of the params: comment IDs are random UUIDs and one
|
||||||
|
// starting with "bad" (e.g. offset_id=bad49e60-...) would otherwise be
|
||||||
|
// misread as a bad request, making this test flaky.
|
||||||
|
expectedStatus := http.StatusOK
|
||||||
|
if strings.Contains(tc.expectedBody, `"error":`) {
|
||||||
|
expectedStatus = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
assert.Equal(t, expectedStatus, code)
|
||||||
|
assert.Contains(t, body, tc.expectedBody)
|
||||||
|
t.Log(body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRest_UserInfo(t *testing.T) {
|
func TestRest_UserInfo(t *testing.T) {
|
||||||
ts, _, teardown := startupT(t)
|
ts, _, teardown := startupT(t)
|
||||||
defer teardown()
|
defer teardown()
|
||||||
@@ -486,7 +805,7 @@ func TestRest_UserInfo(t *testing.T) {
|
|||||||
user := store.User{}
|
user := store.User{}
|
||||||
err := json.Unmarshal([]byte(body), &user)
|
err := json.Unmarshal([]byte(body), &user)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, store.User{Name: "developer one", ID: "dev", Picture: "http://example.com/pic.png",
|
assert.Equal(t, store.User{Name: "developer one", ID: "provider1_dev", Picture: "http://example.com/pic.png",
|
||||||
IP: "127.0.0.1", SiteID: "remark42"}, user)
|
IP: "127.0.0.1", SiteID: "remark42"}, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,7 +946,7 @@ func TestRest_Config(t *testing.T) {
|
|||||||
err := json.Unmarshal([]byte(body), &j)
|
err := json.Unmarshal([]byte(body), &j)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, 300.0, j["edit_duration"])
|
assert.Equal(t, 300.0, j["edit_duration"])
|
||||||
assert.EqualValues(t, []interface{}{"a1", "a2"}, j["admins"])
|
assert.EqualValues(t, []any{"a1", "a2"}, j["admins"])
|
||||||
assert.Equal(t, "admin@remark-42.com", j["admin_email"])
|
assert.Equal(t, "admin@remark-42.com", j["admin_email"])
|
||||||
assert.Equal(t, 4000.0, j["max_comment_size"])
|
assert.Equal(t, 4000.0, j["max_comment_size"])
|
||||||
assert.Equal(t, -5.0, j["low_score"])
|
assert.Equal(t, -5.0, j["low_score"])
|
||||||
@@ -668,13 +987,26 @@ func TestRest_QR(t *testing.T) {
|
|||||||
assert.Equal(t, "image/png", r.Header.Get("Content-Type"))
|
assert.Equal(t, "image/png", r.Header.Get("Content-Type"))
|
||||||
assert.Equal(t, http.StatusOK, r.StatusCode)
|
assert.Equal(t, http.StatusOK, r.StatusCode)
|
||||||
|
|
||||||
// compare the image
|
// compare the decoded image rather than the encoded bytes: the pixels are what the endpoint
|
||||||
|
// promises, while the byte stream is whatever the toolchain's png encoder produces, and
|
||||||
|
// pinning that fails on a go release that changes it
|
||||||
fh, err := os.Open("testdata/qr_test.png")
|
fh, err := os.Open("testdata/qr_test.png")
|
||||||
defer func() { assert.NoError(t, fh.Close()) }()
|
defer func() { assert.NoError(t, fh.Close()) }()
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
img, err := io.ReadAll(fh)
|
|
||||||
assert.NoError(t, err)
|
want, err := png.Decode(fh)
|
||||||
assert.Equal(t, img, bdy)
|
require.NoError(t, err)
|
||||||
|
got, err := png.Decode(bytes.NewReader(bdy))
|
||||||
|
require.NoError(t, err, "the endpoint did not return a decodable png")
|
||||||
|
|
||||||
|
require.Equal(t, want.Bounds(), got.Bounds(), "the qr code is not the size it used to be")
|
||||||
|
for y := want.Bounds().Min.Y; y < want.Bounds().Max.Y; y++ {
|
||||||
|
for x := want.Bounds().Min.X; x < want.Bounds().Max.X; x++ {
|
||||||
|
if want.At(x, y) != got.At(x, y) {
|
||||||
|
t.Fatalf("the qr code differs at %d,%d: want %v, got %v", x, y, want.At(x, y), got.At(x, y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRest_Info(t *testing.T) {
|
func TestRest_Info(t *testing.T) {
|
||||||
@@ -725,3 +1057,197 @@ func TestRest_Robots(t *testing.T) {
|
|||||||
"Allow: /api/v1/list\nAllow: /api/v1/config\nAllow: /api/v1/user\nAllow: /api/v1/img\n"+
|
"Allow: /api/v1/list\nAllow: /api/v1/config\nAllow: /api/v1/user\nAllow: /api/v1/img\n"+
|
||||||
"Allow: /api/v1/avatar\nAllow: /api/v1/picture\n", body)
|
"Allow: /api/v1/avatar\nAllow: /api/v1/picture\n", body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRest_LoadPictureRejectsPathTraversal reproduces the unauthenticated path-traversal
|
||||||
|
// vulnerability in GET /api/v1/picture/{user}/{id}. Before the fix, the handler concatenated
|
||||||
|
// the URL params verbatim into a filesystem path via path.Join, so a request like
|
||||||
|
// `/api/v1/picture/../remark.db` would resolve to `<base>/../remark.db`, escaping the image
|
||||||
|
// directory. Even when the file did not exist (default Partitions=100 mitigates direct hits),
|
||||||
|
// the FS error message leaked the constructed internal path back to the unauthenticated caller.
|
||||||
|
func TestRest_LoadPictureRejectsPathTraversal(t *testing.T) {
|
||||||
|
ts, _, teardown := startupT(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
// A literal ".." is normalized away by net/http.ServeMux before routing: the request
|
||||||
|
// is redirected to the cleaned path, which matches no picture route, so it never reaches
|
||||||
|
// loadPictureCtrl and resolves to 404. The traversal is neutralized at the router level
|
||||||
|
// (the cleaned path can only ever reach defined routes or the webRoot-bounded file server),
|
||||||
|
// so nothing is served either way.
|
||||||
|
{name: "dotdot in user segment", path: "/api/v1/picture/../remark.db", wantStatus: http.StatusNotFound},
|
||||||
|
// Encoded traversal is not cleaned by the router, so the handler's safePictureSegment
|
||||||
|
// validation is what rejects it, with 400.
|
||||||
|
{name: "dotdot in id segment", path: "/api/v1/picture/dev_user/..%2Fremark.db", wantStatus: http.StatusBadRequest},
|
||||||
|
{name: "encoded dotdot in user segment", path: "/api/v1/picture/%2E%2E/remark.db", wantStatus: http.StatusBadRequest},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, ts.URL+c.path, http.NoBody)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
assert.Equal(t, c.wantStatus, resp.StatusCode)
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
s := string(body)
|
||||||
|
assert.NotContains(t, s, "..", "error body must not echo traversal marker")
|
||||||
|
assert.NotContains(t, s, "remark.db", "error body must not echo attacker-supplied filename")
|
||||||
|
assert.NotContains(t, s, "no such file", "error body must not leak filesystem state")
|
||||||
|
assert.NotContains(t, s, "/var/", "error body must not leak internal filesystem path")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRest_LoadPictureRejectsControlCharsInSegment makes sure a CRLF / tab / NUL
|
||||||
|
// in the URL segment is rejected by safePictureSegment. Without the rejection
|
||||||
|
// the [WARN] log line constructed from %q-formatted segments would still be
|
||||||
|
// safe (Go's %q escapes control chars), but a future log change to %s would
|
||||||
|
// turn this into log forgery — and no legitimate picture id ever needs control
|
||||||
|
// characters, so the right place to slam the door is in the validator.
|
||||||
|
func TestRest_LoadPictureRejectsControlCharsInSegment(t *testing.T) {
|
||||||
|
ts, _, teardown := startupT(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{name: "lf in user segment", path: "/api/v1/picture/dev%0Auser/abc.png"},
|
||||||
|
{name: "cr in user segment", path: "/api/v1/picture/dev%0Duser/abc.png"},
|
||||||
|
{name: "tab in user segment", path: "/api/v1/picture/dev%09user/abc.png"},
|
||||||
|
{name: "lf in id segment", path: "/api/v1/picture/dev_user/abc%0A.png"},
|
||||||
|
{name: "nul in id segment", path: "/api/v1/picture/dev_user/abc%00.png"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, ts.URL+c.path, http.NoBody)
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
s := string(body)
|
||||||
|
assert.Contains(t, s, "invalid picture id", "must reject as invalid input, not fall through to storage")
|
||||||
|
assert.NotContains(t, s, "no such file", "must not reach the filesystem")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRest_LoadPictureDefenseHeaders saves a real PNG via the standard upload handler
|
||||||
|
// and asserts that GET /api/v1/picture/{user}/{id} carries the layered defense headers
|
||||||
|
// (strict CSP, nosniff, Content-Disposition with filename) and that the strict ETag
|
||||||
|
// matcher does not 304 on a substring-of-the-real-etag (the pre-fix matcher would).
|
||||||
|
func TestRest_LoadPictureDefenseHeaders(t *testing.T) {
|
||||||
|
ts, _, teardown := startupT(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
// upload a real PNG via /api/v1/picture
|
||||||
|
bodyBuf := &bytes.Buffer{}
|
||||||
|
bodyWriter := multipart.NewWriter(bodyBuf)
|
||||||
|
fileWriter, err := bodyWriter.CreateFormFile("file", "picture.png")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = io.Copy(fileWriter, gopherPNG())
|
||||||
|
require.NoError(t, err)
|
||||||
|
contentType := bodyWriter.FormDataContentType()
|
||||||
|
require.NoError(t, bodyWriter.Close())
|
||||||
|
|
||||||
|
client := http.Client{}
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/picture?site=remark42", ts.URL), bodyBuf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Add("Content-Type", contentType)
|
||||||
|
req.Header.Add("X-JWT", devToken)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
m := map[string]string{}
|
||||||
|
require.NoError(t, json.Unmarshal(body, &m))
|
||||||
|
require.NotEmpty(t, m["id"])
|
||||||
|
|
||||||
|
// fetch the picture and assert defense headers
|
||||||
|
resp, err = http.Get(fmt.Sprintf("%s/api/v1/picture/%s", ts.URL, m["id"]))
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.Equal(t, "default-src 'none'; sandbox; frame-ancestors 'none'",
|
||||||
|
resp.Header.Get("Content-Security-Policy"))
|
||||||
|
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
|
||||||
|
assert.Equal(t, `inline; filename="image"`, resp.Header.Get("Content-Disposition"))
|
||||||
|
assert.Equal(t, "image/png", resp.Header.Get("Content-Type"))
|
||||||
|
realEtag := resp.Header.Get("Etag")
|
||||||
|
require.NotEmpty(t, realEtag)
|
||||||
|
|
||||||
|
// strict matcher: an If-None-Match value that CONTAINS the real etag as a substring
|
||||||
|
// but is not equal to it must NOT trigger 304. The pre-fix matcher used
|
||||||
|
// strings.Contains(header, etag) and would have returned true here.
|
||||||
|
require.True(t, len(realEtag) > 4)
|
||||||
|
substringMatch := "prefix-" + realEtag + "-suffix"
|
||||||
|
req2, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/picture/%s", ts.URL, m["id"]), http.NoBody)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req2.Header.Set("If-None-Match", substringMatch)
|
||||||
|
resp2, err := client.Do(req2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp2.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusOK, resp2.StatusCode,
|
||||||
|
"strict etag matcher must NOT 304 when real etag appears only as a substring of If-None-Match; got %q vs real %q", substringMatch, realEtag)
|
||||||
|
|
||||||
|
// sanity: the exact real etag DOES validate
|
||||||
|
req3, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/picture/%s", ts.URL, m["id"]), http.NoBody)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req3.Header.Set("If-None-Match", realEtag)
|
||||||
|
resp3, err := client.Do(req3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp3.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusNotModified, resp3.StatusCode, "exact etag must round-trip as 304")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRest_LoadPictureRejectsNonImage proves the /picture/ handler rejects bytes that
|
||||||
|
// don't sniff as a real image — even when retrieved successfully from the image store.
|
||||||
|
// Uses a StoreMock so we can return arbitrary attacker bytes for a valid-looking id.
|
||||||
|
func TestRest_LoadPictureRejectsNonImage(t *testing.T) {
|
||||||
|
htmlBody := []byte("<html><body><script>alert(document.domain)</script></body></html>")
|
||||||
|
|
||||||
|
imageStore := image.StoreMock{LoadFunc: func(string) ([]byte, error) {
|
||||||
|
return htmlBody, nil
|
||||||
|
}}
|
||||||
|
// minimal public struct on purpose: the reject path only exercises imageService.Load
|
||||||
|
// (other fields like dataService, cache, commentFormatter are not touched here).
|
||||||
|
p := &public{imageService: image.NewService(&imageStore, image.ServiceParams{})}
|
||||||
|
|
||||||
|
router := routegroup.New(http.NewServeMux())
|
||||||
|
router.HandleFunc("GET /api/v1/picture/{user}/{id}", p.loadPictureCtrl)
|
||||||
|
ts := httptest.NewServer(router)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(ts.URL + "/api/v1/picture/dev_user/abc.png")
|
||||||
|
require.NoError(t, err)
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusUnsupportedMediaType, resp.StatusCode,
|
||||||
|
"non-image bytes must be rejected as 415")
|
||||||
|
assert.False(t, strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html"),
|
||||||
|
"reject response must not be text/html; got %q", resp.Header.Get("Content-Type"))
|
||||||
|
assert.NotContains(t, string(body), "<script>",
|
||||||
|
"attacker payload must not be echoed back")
|
||||||
|
assert.Equal(t, "no-store", resp.Header.Get("Cache-Control"),
|
||||||
|
"rejection path must not be cacheable")
|
||||||
|
// defense headers still present on the reject path
|
||||||
|
assert.Equal(t, "default-src 'none'; sandbox; frame-ancestors 'none'",
|
||||||
|
resp.Header.Get("Content-Security-Policy"))
|
||||||
|
assert.Equal(t, "nosniff", resp.Header.Get("X-Content-Type-Options"))
|
||||||
|
assert.Equal(t, `inline; filename="image"`, resp.Header.Get("Content-Disposition"))
|
||||||
|
}
|
||||||
|
|||||||
+469
-211
@@ -4,23 +4,28 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math/rand"
|
"io/fs"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-pkgz/auth"
|
"github.com/go-pkgz/auth/v2"
|
||||||
"github.com/go-pkgz/auth/avatar"
|
"github.com/go-pkgz/auth/v2/avatar"
|
||||||
"github.com/go-pkgz/auth/token"
|
"github.com/go-pkgz/auth/v2/provider"
|
||||||
cache "github.com/go-pkgz/lcw"
|
"github.com/go-pkgz/auth/v2/token"
|
||||||
|
cache "github.com/go-pkgz/lcw/v2"
|
||||||
R "github.com/go-pkgz/rest"
|
R "github.com/go-pkgz/rest"
|
||||||
|
"github.com/go-pkgz/routegroup"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
@@ -35,13 +40,21 @@ import (
|
|||||||
"github.com/umputun/remark42/backend/app/store/engine"
|
"github.com/umputun/remark42/backend/app/store/engine"
|
||||||
"github.com/umputun/remark42/backend/app/store/image"
|
"github.com/umputun/remark42/backend/app/store/image"
|
||||||
"github.com/umputun/remark42/backend/app/store/service"
|
"github.com/umputun/remark42/backend/app/store/service"
|
||||||
|
"github.com/umputun/remark42/backend/app/webassets"
|
||||||
)
|
)
|
||||||
|
|
||||||
var devToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImRldmVsb3BlciBvbmUiLCJpZCI6ImRldiIsInBpY3R1cmUiOiJodHRwOi8vZXhhbXBsZS5jb20vcGljLnBuZyIsImlwIjoiMTI3LjAuMC4xIiwiZW1haWwiOiJtZUBleGFtcGxlLmNvbSJ9fQ.aKUAXiZxXypgV7m1wEOgUcyPOvUDXHDi3A06YWKbcLg`
|
// To generate a token, enter one of the tokens here into https://jwt.io, change the secret to one you're using in your test
|
||||||
|
// ("secret" in case of startupT), and alter the fields you want to be changed.
|
||||||
|
|
||||||
|
var devToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImRldmVsb3BlciBvbmUiLCJpZCI6InByb3ZpZGVyMV9kZXYiLCJwaWN0dXJlIjoiaHR0cDovL2V4YW1wbGUuY29tL3BpYy5wbmciLCJpcCI6IjEyNy4wLjAuMSIsImVtYWlsIjoibWVAZXhhbXBsZS5jb20ifX0.dirTS_ahSF6375sdO2iodm2K2UmRTzQNQMFiHuTQCVs`
|
||||||
|
|
||||||
|
var dev2Token = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImRldmVsb3BlciBvbmUiLCJpZCI6InByb3ZpZGVyMV9kZXYyIiwicGljdHVyZSI6Imh0dHA6Ly9leGFtcGxlLmNvbS9waWMucG5nIiwiaXAiOiIxMjcuMC4wLjEiLCJlbWFpbCI6Im1lQGV4YW1wbGUuY29tIn19.qsR_PupfjIq7uw0eAuyGV8nsUoMx9v541c9olnRInRQ`
|
||||||
|
|
||||||
var anonToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImFub255bW91cyB0ZXN0IHVzZXIiLCJpZCI6ImFub255bW91c190ZXN0X3VzZXIiLCJwaWN0dXJlIjoiaHR0cDovL2V4YW1wbGUuY29tL3BpYy5wbmciLCJpcCI6IjEyNy4wLjAuMSIsImVtYWlsIjoiYW5vbkBleGFtcGxlLmNvbSJ9fQ.gAae2WMxZNZE5ebVboptPEyQ7Nk6EQxciNnGJ_mPOuU`
|
var anonToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6ImFub255bW91cyB0ZXN0IHVzZXIiLCJpZCI6ImFub255bW91c190ZXN0X3VzZXIiLCJwaWN0dXJlIjoiaHR0cDovL2V4YW1wbGUuY29tL3BpYy5wbmciLCJpcCI6IjEyNy4wLjAuMSIsImVtYWlsIjoiYW5vbkBleGFtcGxlLmNvbSJ9fQ.gAae2WMxZNZE5ebVboptPEyQ7Nk6EQxciNnGJ_mPOuU`
|
||||||
|
|
||||||
var devTokenBadAud = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0Ml9iYWQiLCJleHAiOjM3ODkxOTE4MjIsImp0aSI6InJhbmRvbSBpZCIsImlzcyI6InJlbWFyazQyIiwibmJmIjoxNTIxODg0MjIyLCJ1c2VyIjp7Im5hbWUiOiJkZXZlbG9wZXIgb25lIiwiaWQiOiJkZXYiLCJwaWN0dXJlIjoiaHR0cDovL2V4YW1wbGUuY29tL3BpYy5wbmciLCJpcCI6IjEyNy4wLjAuMSIsImVtYWlsIjoibWVAZXhhbXBsZS5jb20ifX0.FuTTocVtcxr4VjpfIICvU2yOb3su28VkDzj94H9Q3xY`
|
var emailUserToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6Mzc4OTE5MTgyMiwianRpIjoicmFuZG9tIGlkIiwiaXNzIjoicmVtYXJrNDIiLCJuYmYiOjE1MjE4ODQyMjIsInVzZXIiOnsibmFtZSI6Imdvb2RAZXhhbXBsZS5jb20gdGVzdCB1c2VyIiwiaWQiOiJlbWFpbF9mNWRmZTlkMmU2YmQ3NWZjNzRlYTVmYWJmMjczYjQ1YjViYWViMTk1IiwicGljdHVyZSI6Imh0dHA6Ly9leGFtcGxlLmNvbS9waWMucG5nIiwiaXAiOiIxMjcuMC4wLjEiLCJlbWFpbCI6Imdvb2RAZXhhbXBsZS5jb20ifX0.vH2HN1JpuXL8okTJq1A-zGHQ-l2ILcwxvDDEmu2zwks`
|
||||||
|
|
||||||
|
var devTokenBadAud = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJyZW1hcms0Ml9iYWQiLCJleHAiOjM3ODkxOTE4MjIsImp0aSI6InJhbmRvbSBpZCIsImlzcyI6InJlbWFyazQyIiwibmJmIjoxNTIxODg0MjIyLCJ1c2VyIjp7Im5hbWUiOiJkZXZlbG9wZXIgb25lIiwiaWQiOiJwcm92aWRlcjFfZGV2IiwicGljdHVyZSI6Imh0dHA6Ly9leGFtcGxlLmNvbS9waWMucG5nIiwiaXAiOiIxMjcuMC4wLjEiLCJlbWFpbCI6Im1lQGV4YW1wbGUuY29tIn19.X-lvnHvBz6VfEbVV4f-bjcZuLY5pYtvEansk_TQMrX8`
|
||||||
|
|
||||||
var adminUmputunToken = `eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6MTk1NDU5Nzk4MCwianRpIjoiOTdhMmUwYWM0ZGM3ZDVmNjkyNmQ1ZTg2MjBhY2VmOWE0MGMwIiwiaWF0IjoxNDU0NTk3NjgwLCJpc3MiOiJyZW1hcms0MiIsInVzZXIiOnsibmFtZSI6IlVtcHV0dW4iLCJpZCI6ImdpdGh1Yl9lZjBmNzA2YTciLCJwaWN0dXJlIjoiaHR0cHM6Ly9yZW1hcms0Mi5yYWRpby10LmNvbS9hcGkvdjEvYXZhdGFyL2NiNDJmZjQ5M2FkZTY5NmQ4OGEzYTU5MGYxMzZhZTllMzRkZTdjMWIuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.dZiOjWHguo9f42XCMooMcv4EmYFzifl_-LEvPZHCtks`
|
var adminUmputunToken = `eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJyZW1hcms0MiIsImV4cCI6MTk1NDU5Nzk4MCwianRpIjoiOTdhMmUwYWM0ZGM3ZDVmNjkyNmQ1ZTg2MjBhY2VmOWE0MGMwIiwiaWF0IjoxNDU0NTk3NjgwLCJpc3MiOiJyZW1hcms0MiIsInVzZXIiOnsibmFtZSI6IlVtcHV0dW4iLCJpZCI6ImdpdGh1Yl9lZjBmNzA2YTciLCJwaWN0dXJlIjoiaHR0cHM6Ly9yZW1hcms0Mi5yYWRpby10LmNvbS9hcGkvdjEvYXZhdGFyL2NiNDJmZjQ5M2FkZTY5NmQ4OGEzYTU5MGYxMzZhZTllMzRkZTdjMWIuaW1hZ2UiLCJhdHRycyI6eyJhZG1pbiI6dHJ1ZSwiYmxvY2tlZCI6ZmFsc2V9fX0.dZiOjWHguo9f42XCMooMcv4EmYFzifl_-LEvPZHCtks`
|
||||||
|
|
||||||
@@ -60,22 +73,319 @@ func TestRest_FileServer(t *testing.T) {
|
|||||||
_ = os.Remove(testHTMLFile)
|
_ = os.Remove(testHTMLFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRest_FileServerStaticAssets covers the static file server behaviors that are
|
||||||
|
// sensitive to the router: the bare /web -> /web/ redirect, cache headers applied to
|
||||||
|
// served assets, 404 for missing files, and the directory-listing block.
|
||||||
|
func TestRest_FileServerStaticAssets(t *testing.T) {
|
||||||
|
ts, srv, teardown := startupT(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(srv.WebRoot+"/asset-test.html", []byte("static body"), 0o600))
|
||||||
|
require.NoError(t, os.MkdirAll(srv.WebRoot+"/subdir-test", 0o700))
|
||||||
|
defer func() {
|
||||||
|
_ = os.Remove(srv.WebRoot + "/asset-test.html")
|
||||||
|
_ = os.RemoveAll(srv.WebRoot + "/subdir-test")
|
||||||
|
}()
|
||||||
|
|
||||||
|
noRedirect := http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||||
|
defer noRedirect.CloseIdleConnections()
|
||||||
|
|
||||||
|
t.Run("bare /web redirects to /web/", func(t *testing.T) {
|
||||||
|
resp, err := noRedirect.Get(ts.URL + "/web")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusMovedPermanently, resp.StatusCode)
|
||||||
|
assert.Equal(t, "/web/", resp.Header.Get("Location"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("serves an existing asset with cache headers", func(t *testing.T) {
|
||||||
|
resp, err := noRedirect.Get(ts.URL + "/web/asset-test.html")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.Equal(t, "static body", string(body))
|
||||||
|
assert.NotEmpty(t, resp.Header.Get("Etag"), "cacheControl must set an Etag on served assets")
|
||||||
|
assert.Contains(t, resp.Header.Get("Cache-Control"), "max-age", "cacheControl must set max-age on served assets")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing asset returns 404", func(t *testing.T) {
|
||||||
|
_, code := get(t, ts.URL+"/web/does-not-exist.html")
|
||||||
|
assert.Equal(t, http.StatusNotFound, code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("directory listing is blocked", func(t *testing.T) {
|
||||||
|
resp, err := noRedirect.Get(ts.URL + "/web/subdir-test/")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode, "directory listings must be blocked")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRest_FileServerBackendAssets covers the assets embedded in the binary and the rule that a
|
||||||
|
// name the frontend build provides is served from there instead. WebRoot is a fresh empty
|
||||||
|
// directory so the frontend side is known, rather than the shared temp dir startupT defaults to.
|
||||||
|
func TestRest_FileServerBackendAssets(t *testing.T) {
|
||||||
|
ts, srv, teardown := startupT(t, func(srv *Rest) { srv.WebRoot = t.TempDir() })
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
t.Run("serves every embedded asset byte for byte", func(t *testing.T) {
|
||||||
|
for _, name := range []string{"privacy.html", "markdown-help.html", "400x400.jpeg"} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
want, err := fs.ReadFile(webassets.FS, name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
body, code := get(t, ts.URL+"/web/"+name)
|
||||||
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Equal(t, string(want), body, "the bytes must come from the embedded assets")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("serves the image with its own content type", func(t *testing.T) {
|
||||||
|
resp, err := http.Get(ts.URL + "/web/400x400.jpeg")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.Equal(t, "image/jpeg", resp.Header.Get("Content-Type"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("head is served", func(t *testing.T) {
|
||||||
|
resp, err := http.Head(ts.URL + "/web/privacy.html")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("frontend output wins over the embedded copy", func(t *testing.T) {
|
||||||
|
require.NoError(t, os.WriteFile(srv.WebRoot+"/privacy.html", []byte("operator's own policy"), 0o600))
|
||||||
|
t.Cleanup(func() { _ = os.Remove(srv.WebRoot + "/privacy.html") })
|
||||||
|
|
||||||
|
body, code := get(t, ts.URL+"/web/privacy.html")
|
||||||
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Equal(t, "operator's own policy", body)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("traversal out of the asset root is refused", func(t *testing.T) {
|
||||||
|
for _, p := range []string{"/web/../../etc/passwd", "/web/..%2f..%2fetc%2fpasswd", "/web/%2e%2e/%2e%2e/etc/passwd"} {
|
||||||
|
t.Run(p, func(t *testing.T) {
|
||||||
|
body, code := get(t, ts.URL+p)
|
||||||
|
assert.NotContains(t, body, "root:", "must never serve a file outside the served roots")
|
||||||
|
assert.NotEqual(t, http.StatusInternalServerError, code, "a rejected name must not surface as 500")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing in both still returns 404", func(t *testing.T) {
|
||||||
|
_, code := get(t, ts.URL+"/web/neither-source-has-this.html")
|
||||||
|
assert.Equal(t, http.StatusNotFound, code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRest_FileServerEmbeddedFrontend covers the branch taken when no web root exists on disk,
|
||||||
|
// which is how the released binary runs. The frontend stands in for the copy embedded at
|
||||||
|
// app/cmd/web, so a name it provides and a name only the assets provide are both exercised.
|
||||||
|
func TestRest_FileServerEmbeddedFrontend(t *testing.T) {
|
||||||
|
frontend := fstest.MapFS{"index.html": {Data: []byte("embedded frontend index")}}
|
||||||
|
router := routegroup.New(http.NewServeMux())
|
||||||
|
addFileServer(router, frontend, filepath.Join(t.TempDir(), "absent"), "test-version", "https://remark.example.com")
|
||||||
|
|
||||||
|
ts := httptest.NewServer(router)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
t.Run("serves the embedded frontend", func(t *testing.T) {
|
||||||
|
body, code := get(t, ts.URL+"/web/index.html")
|
||||||
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Equal(t, "embedded frontend index", body)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, name := range []string{"privacy.html", "markdown-help.html", "400x400.jpeg"} {
|
||||||
|
t.Run("falls back to "+name, func(t *testing.T) {
|
||||||
|
want, err := fs.ReadFile(webassets.FS, name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
body, code := get(t, ts.URL+"/web/"+name)
|
||||||
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Equal(t, string(want), body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("a name neither source has is missing", func(t *testing.T) {
|
||||||
|
_, code := get(t, ts.URL+"/web/nothing-here.html")
|
||||||
|
assert.Equal(t, http.StatusNotFound, code)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a name the operating system rejects is missing, not an error", func(t *testing.T) {
|
||||||
|
_, code := get(t, ts.URL+"/web/a%00b.html")
|
||||||
|
assert.Equal(t, http.StatusNotFound, code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRest_FileServerRoutesEmbedded drives the whole router the released binary runs: no web root
|
||||||
|
// on disk, and the frontend read from WebFS. It is what pins the web/ prefix routes() strips, which
|
||||||
|
// a test calling addFileServer directly cannot see.
|
||||||
|
func TestRest_FileServerRoutesEmbedded(t *testing.T) {
|
||||||
|
frontend := fstest.MapFS{
|
||||||
|
"web/index.html": {Data: []byte("embedded index")},
|
||||||
|
"web/iframe.html": {Data: []byte("embedded iframe")},
|
||||||
|
"web/remark.mjs": {Data: []byte("embedded bundle")},
|
||||||
|
}
|
||||||
|
ts, _, teardown := startupT(t, func(srv *Rest) {
|
||||||
|
srv.WebRoot = filepath.Join(t.TempDir(), "absent")
|
||||||
|
srv.WebFS = frontend
|
||||||
|
})
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
t.Run("serves the frontend from under the web prefix", func(t *testing.T) {
|
||||||
|
for name, want := range map[string]string{
|
||||||
|
"index.html": "embedded index",
|
||||||
|
"iframe.html": "embedded iframe",
|
||||||
|
"remark.mjs": "embedded bundle",
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
body, code := get(t, ts.URL+"/web/"+name)
|
||||||
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Equal(t, want, body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the prefix is stripped rather than exposed", func(t *testing.T) {
|
||||||
|
_, code := get(t, ts.URL+"/web/web/index.html")
|
||||||
|
assert.Equal(t, http.StatusNotFound, code, "the web/ prefix must not be reachable as a path")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the embedded assets still answer alongside it", func(t *testing.T) {
|
||||||
|
want, err := fs.ReadFile(webassets.FS, "privacy.html")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
body, code := get(t, ts.URL+"/web/privacy.html")
|
||||||
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Equal(t, string(want), body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// refusingSubFS is an fs.FS whose Sub refuses, which is the only way fs.Sub returns a nil
|
||||||
|
// filesystem. routes() has to survive it, since a nil frontend would panic on the first request.
|
||||||
|
type refusingSubFS struct{}
|
||||||
|
|
||||||
|
func (refusingSubFS) Open(name string) (fs.File, error) {
|
||||||
|
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
||||||
|
}
|
||||||
|
func (refusingSubFS) Sub(string) (fs.FS, error) { return nil, errors.New("refused") }
|
||||||
|
|
||||||
|
// TestRest_FileServerFrontendSourceRefused covers the branch where the frontend source cannot be
|
||||||
|
// sub-rooted: /web must keep serving the embedded assets rather than panicking.
|
||||||
|
func TestRest_FileServerFrontendSourceRefused(t *testing.T) {
|
||||||
|
ts, _, teardown := startupT(t, func(srv *Rest) {
|
||||||
|
srv.WebRoot = filepath.Join(t.TempDir(), "absent")
|
||||||
|
srv.WebFS = refusingSubFS{}
|
||||||
|
})
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
t.Run("the embedded assets still serve", func(t *testing.T) {
|
||||||
|
want, err := fs.ReadFile(webassets.FS, "privacy.html")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
body, code := get(t, ts.URL+"/web/privacy.html")
|
||||||
|
assert.Equal(t, http.StatusOK, code)
|
||||||
|
assert.Equal(t, string(want), body)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a frontend name is missing rather than fatal", func(t *testing.T) {
|
||||||
|
_, code := get(t, ts.URL+"/web/iframe.html")
|
||||||
|
assert.Equal(t, http.StatusNotFound, code)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRest_RejectHeadOnDestructiveGET verifies that HEAD is blocked on the state-mutating
|
||||||
|
// GET routes (which stdlib http.ServeMux would otherwise route to the GET handler) while
|
||||||
|
// still being served for safe, read-only routes.
|
||||||
|
func TestRest_RejectHeadOnDestructiveGET(t *testing.T) {
|
||||||
|
ts, _, teardown := startupT(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
client := http.Client{}
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
|
||||||
|
t.Run("HEAD is rejected on a destructive GET route", func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest(http.MethodHead, ts.URL+"/api/v1/admin/deleteme?site=remark42", http.NoBody)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.SetBasicAuth("admin", "password")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode, "HEAD must not reach a state-mutating GET handler")
|
||||||
|
assert.Equal(t, "GET", resp.Header.Get("Allow"), "405 must carry an Allow header")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HEAD is rejected on the email unsubscribe route", func(t *testing.T) {
|
||||||
|
// emailUnsubscribeCtrl deletes the user's email subscription on GET, so HEAD (which
|
||||||
|
// ServeMux would route to the GET handler) must be rejected before it runs
|
||||||
|
resp, err := client.Head(ts.URL + "/email/unsubscribe.html?site=remark42")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode, "HEAD must not reach the email-unsubscribe handler")
|
||||||
|
assert.Equal(t, "GET, POST", resp.Header.Get("Allow"), "Allow must list every method the resource supports")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HEAD still works on a safe read-only route", func(t *testing.T) {
|
||||||
|
resp, err := client.Head(ts.URL + "/api/v1/config?site=remark42")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode, "HEAD must still be served for safe read-only routes")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrong method on a known route returns 405 with Allow", func(t *testing.T) {
|
||||||
|
// method-in-pattern is new under ServeMux; a wrong method on a known route must
|
||||||
|
// still yield 405 with the allowed methods advertised
|
||||||
|
resp, err := client.Post(ts.URL+"/api/v1/config?site=remark42", "application/json", http.NoBody)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
|
||||||
|
assert.Contains(t, resp.Header.Get("Allow"), "GET", "405 must advertise the allowed methods")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRest_AvatarMounts verifies both avatar mounts (root /avatar/ and /api/v1/avatar/)
|
||||||
|
// still route to the avatar handler after the chi Mount -> ServeMux Handle rewiring,
|
||||||
|
// rather than falling through to a router 404.
|
||||||
|
func TestRest_AvatarMounts(t *testing.T) {
|
||||||
|
ts, _, teardown := startupT(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
for _, path := range []string{"/api/v1/avatar/nonexistent.image", "/avatar/nonexistent.image"} {
|
||||||
|
t.Run(path, func(t *testing.T) {
|
||||||
|
body, code := get(t, ts.URL+path)
|
||||||
|
// the avatar handler responds (403 "can't load avatar"), not a router 404
|
||||||
|
assert.Equal(t, http.StatusForbidden, code, "avatar mount must reach the avatar handler")
|
||||||
|
assert.Contains(t, body, "can't load avatar", "request must reach the avatar handler, not a routing 404")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRest_Shutdown(t *testing.T) {
|
func TestRest_Shutdown(t *testing.T) {
|
||||||
srv := Rest{Authenticator: &auth.Service{}, ImageProxy: &proxy.Image{}}
|
srv := Rest{Authenticator: &auth.Service{}, ImageProxy: &proxy.Image{}}
|
||||||
|
port := chooseUnusedPort(t)
|
||||||
done := make(chan bool)
|
done := make(chan bool)
|
||||||
|
|
||||||
// without waiting for channel close at the end goroutine will stay alive after test finish
|
// without waiting for channel close at the end goroutine will stay alive after test finish
|
||||||
// which would create data race with next test
|
// which would create data race with next test
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(200 * time.Millisecond)
|
srv.Run("127.0.0.1", port)
|
||||||
srv.Shutdown()
|
|
||||||
close(done)
|
close(done)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
st := time.Now()
|
defer srv.Shutdown() // a failed readiness wait must not leave srv.Run behind for goleak
|
||||||
srv.Run("127.0.0.1", 0)
|
waitForServerStart(t, port)
|
||||||
assert.True(t, time.Since(st).Seconds() < 1, "should take about 100ms")
|
srv.Shutdown()
|
||||||
<-done
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(serverStopTimeout):
|
||||||
|
t.Fatal("rest server did not stop after Shutdown")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRest_filterComments(t *testing.T) {
|
func TestRest_filterComments(t *testing.T) {
|
||||||
@@ -94,7 +404,7 @@ func TestRest_filterComments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRest_RunStaticSSLMode(t *testing.T) {
|
func TestRest_RunStaticSSLMode(t *testing.T) {
|
||||||
sslPort := chooseRandomUnusedPort()
|
sslPort := chooseUnusedPort(t)
|
||||||
srv := Rest{
|
srv := Rest{
|
||||||
Authenticator: auth.NewService(auth.Opts{
|
Authenticator: auth.NewService(auth.Opts{
|
||||||
AvatarStore: avatar.NewLocalFS("/tmp"),
|
AvatarStore: avatar.NewLocalFS("/tmp"),
|
||||||
@@ -111,16 +421,16 @@ func TestRest_RunStaticSSLMode(t *testing.T) {
|
|||||||
RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort),
|
RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort),
|
||||||
}
|
}
|
||||||
|
|
||||||
port := chooseRandomUnusedPort()
|
port := chooseUnusedPort(t)
|
||||||
go func() {
|
go func() {
|
||||||
srv.Run("", port)
|
srv.Run("", port)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
waitForHTTPSServerStart(sslPort)
|
waitForServerStart(t, sslPort, port)
|
||||||
|
|
||||||
client := http.Client{
|
client := http.Client{
|
||||||
// prevent http redirect
|
// prevent http redirect
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -149,7 +459,7 @@ func TestRest_RunStaticSSLMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRest_RunAutocertModeHTTPOnly(t *testing.T) {
|
func TestRest_RunAutocertModeHTTPOnly(t *testing.T) {
|
||||||
sslPort := chooseRandomUnusedPort()
|
sslPort := chooseUnusedPort(t)
|
||||||
srv := Rest{
|
srv := Rest{
|
||||||
Authenticator: &auth.Service{},
|
Authenticator: &auth.Service{},
|
||||||
ImageProxy: &proxy.Image{},
|
ImageProxy: &proxy.Image{},
|
||||||
@@ -160,17 +470,17 @@ func TestRest_RunAutocertModeHTTPOnly(t *testing.T) {
|
|||||||
RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort),
|
RemarkURL: fmt.Sprintf("https://localhost:%d", sslPort),
|
||||||
}
|
}
|
||||||
|
|
||||||
port := chooseRandomUnusedPort()
|
port := chooseUnusedPort(t)
|
||||||
go func() {
|
go func() {
|
||||||
// can't check https server locally, just only http server
|
// can't check https server locally, just only http server
|
||||||
srv.Run("", port)
|
srv.Run("", port)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
waitForHTTPSServerStart(sslPort)
|
waitForServerStart(t, sslPort, port)
|
||||||
|
|
||||||
client := http.Client{
|
client := http.Client{
|
||||||
// prevent http redirect
|
// prevent http redirect
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -185,28 +495,6 @@ func TestRest_RunAutocertModeHTTPOnly(t *testing.T) {
|
|||||||
srv.Shutdown()
|
srv.Shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRest_rejectAnonUser(t *testing.T) {
|
|
||||||
ts := httptest.NewServer(fakeAuth(rejectAnonUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
fmt.Fprintln(w, "Hello")
|
|
||||||
}))))
|
|
||||||
defer ts.Close()
|
|
||||||
|
|
||||||
resp, err := http.Get(ts.URL)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, resp.Body.Close())
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "use not logged in")
|
|
||||||
|
|
||||||
resp, err = http.Get(ts.URL + "?fake_id=anonymous_user123&fake_name=test")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, resp.Body.Close())
|
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode, "anon rejected")
|
|
||||||
|
|
||||||
resp, err = http.Get(ts.URL + "?fake_id=real_user123&fake_name=test")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, resp.Body.Close())
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode, "real user")
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_URLKey(t *testing.T) {
|
func Test_URLKey(t *testing.T) {
|
||||||
tbl := []struct {
|
tbl := []struct {
|
||||||
url string
|
url string
|
||||||
@@ -219,7 +507,6 @@ func Test_URLKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i, tt := range tbl {
|
for i, tt := range tbl {
|
||||||
tt := tt
|
|
||||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||||
r, err := http.NewRequest("GET", tt.url, http.NoBody)
|
r, err := http.NewRequest("GET", tt.url, http.NoBody)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -244,7 +531,6 @@ func Test_URLKeyWithUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i, tt := range tbl {
|
for i, tt := range tbl {
|
||||||
tt := tt
|
|
||||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
||||||
r, err := http.NewRequest("GET", tt.url, http.NoBody)
|
r, err := http.NewRequest("GET", tt.url, http.NoBody)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -271,7 +557,6 @@ func TestRest_parseError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for n, tt := range tbl {
|
for n, tt := range tbl {
|
||||||
tt := tt
|
|
||||||
t.Run(strconv.Itoa(n), func(t *testing.T) {
|
t.Run(strconv.Itoa(n), func(t *testing.T) {
|
||||||
res := parseError(tt.err, rest.ErrInternal)
|
res := parseError(tt.err, rest.ErrInternal)
|
||||||
assert.Equal(t, tt.res, res)
|
assert.Equal(t, tt.res, res)
|
||||||
@@ -279,150 +564,40 @@ func TestRest_parseError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRest_cacheControl(t *testing.T) {
|
|
||||||
tbl := []struct {
|
|
||||||
url string
|
|
||||||
version string
|
|
||||||
exp time.Duration
|
|
||||||
etag string
|
|
||||||
maxAge int
|
|
||||||
}{
|
|
||||||
{"http://example.com/foo", "v1", time.Hour, "b433be1ea19edaee9dc92ca4b895b6bdf3c058cb", 3600},
|
|
||||||
{"http://example.com/foo2", "v1", 10 * time.Hour, "6d8466aef3246c1057452561acddf7ad9d0d99e0", 36000},
|
|
||||||
{"http://example.com/foo", "v2", time.Hour, "481700c52aab0dfbca99f3ffc2a4fbb27884c114", 3600},
|
|
||||||
{"https://example.com/foo", "v2", time.Hour, "bebd4f1b87f474792c4e75e5affe31fbf67f5778", 3600},
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, tt := range tbl {
|
|
||||||
tt := tt
|
|
||||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", tt.url, nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
h := cacheControl(tt.exp, tt.version)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
resp := w.Result()
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
assert.NoError(t, resp.Body.Close())
|
|
||||||
t.Logf("%+v", resp.Header)
|
|
||||||
assert.Equal(t, `"`+tt.etag+`"`, resp.Header.Get("Etag"))
|
|
||||||
assert.Equal(t, `max-age=`+strconv.Itoa(int(tt.exp.Seconds()))+", no-cache", resp.Header.Get("Cache-Control"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRest_frameAncestors(t *testing.T) {
|
func TestRest_frameAncestors(t *testing.T) {
|
||||||
tbl := []struct {
|
ts, _, teardown := startupT(t, func(o *Rest) {
|
||||||
hosts []string
|
o.AllowedAncestors = []string{"'self'", "https://example.com"}
|
||||||
header string
|
})
|
||||||
}{
|
|
||||||
{[]string{"http://example.com"}, "frame-ancestors http://example.com;"},
|
|
||||||
{[]string{}, ""},
|
|
||||||
{[]string{"http://example.com", "http://example2.com"}, "frame-ancestors http://example.com http://example2.com;"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, tt := range tbl {
|
// test case with frame-ancestors
|
||||||
tt := tt
|
client := http.Client{}
|
||||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
resp, err := client.Get(ts.URL + "/web/index.html")
|
||||||
req := httptest.NewRequest("GET", "http://example.com", nil)
|
require.NoError(t, err)
|
||||||
w := httptest.NewRecorder()
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors 'self' https://example.com;")
|
||||||
|
// httptest.Server.Close waits on connections still in use, and a deferred close does not run
|
||||||
|
// until the test ends, so the body has to be released before the server is torn down here
|
||||||
|
require.NoError(t, resp.Body.Close())
|
||||||
|
client.CloseIdleConnections()
|
||||||
|
teardown()
|
||||||
|
|
||||||
h := frameAncestors(tt.hosts)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
// test case without frame-ancestors
|
||||||
h.ServeHTTP(w, req)
|
ts, _, teardown = startupT(t, func(srv *Rest) {
|
||||||
resp := w.Result()
|
srv.AllowedAncestors = []string{}
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
})
|
||||||
assert.NoError(t, resp.Body.Close())
|
defer teardown()
|
||||||
t.Logf("%+v", resp.Header)
|
resp, err = client.Get(ts.URL + "/web/index.html")
|
||||||
assert.Equal(t, tt.header, resp.Header.Get("Content-Security-Policy"))
|
require.NoError(t, err)
|
||||||
})
|
defer resp.Body.Close()
|
||||||
}
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
}
|
assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors *;")
|
||||||
|
|
||||||
func TestRest_subscribersOnly(t *testing.T) {
|
|
||||||
paidSubUser := &token.User{}
|
|
||||||
paidSubUser.SetPaidSub(true)
|
|
||||||
|
|
||||||
tbl := []struct {
|
|
||||||
subsOnly bool
|
|
||||||
user token.User
|
|
||||||
setUser bool
|
|
||||||
status int
|
|
||||||
}{
|
|
||||||
{true, token.User{}, false, http.StatusUnauthorized},
|
|
||||||
{true, token.User{}, true, http.StatusForbidden},
|
|
||||||
{false, token.User{}, false, http.StatusOK},
|
|
||||||
{false, token.User{}, true, http.StatusOK},
|
|
||||||
{true, *paidSubUser, true, http.StatusOK},
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, tt := range tbl {
|
|
||||||
tt := tt
|
|
||||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "http://example.com", nil)
|
|
||||||
if tt.setUser {
|
|
||||||
req = token.SetUserInfo(req, tt.user)
|
|
||||||
}
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h := subscribersOnly(tt.subsOnly)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
resp := w.Result()
|
|
||||||
assert.Equal(t, tt.status, resp.StatusCode)
|
|
||||||
assert.NoError(t, resp.Body.Close())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_validEmailAuth(t *testing.T) {
|
|
||||||
tbl := []struct {
|
|
||||||
req string
|
|
||||||
status int
|
|
||||||
}{
|
|
||||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someone", http.StatusOK},
|
|
||||||
{"/auth/email/login?site=site-with-dash_and_underscore-and.dot&address=umputun%example.com&user=someone", http.StatusOK},
|
|
||||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someone+blah", http.StatusOK},
|
|
||||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=Евгений+Умпутун", http.StatusOK},
|
|
||||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=12", http.StatusForbidden},
|
|
||||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=..blah+blah", http.StatusForbidden},
|
|
||||||
{"/auth/email/login?site=remark42&address=umputun%example.com&user=someonelooong+loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong", http.StatusForbidden},
|
|
||||||
{"/auth/twitter/login?site=remark42&address=umputun%example.com&user=..blah+blah", http.StatusOK},
|
|
||||||
{"/auth/email/login?site=remark42&address=umputun%example.com", http.StatusOK},
|
|
||||||
{"/auth/email/login?site=remark42&address=umputun+example.com&user=someone", http.StatusForbidden},
|
|
||||||
{"/auth/email/login?site=bad!site&address=umputun%example.com&user=someone", http.StatusForbidden},
|
|
||||||
{"/auth/email/login?site=loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooongsite&address=umputun%example.com&user=someone", http.StatusForbidden},
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, tt := range tbl {
|
|
||||||
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
|
||||||
req := httptest.NewRequest("GET", "http://example.com"+tt.req, http.NoBody)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h := validEmailAuth()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
||||||
h.ServeHTTP(w, req)
|
|
||||||
resp := w.Result()
|
|
||||||
assert.Equal(t, tt.status, resp.StatusCode)
|
|
||||||
assert.NoError(t, resp.Body.Close())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// randomPath pick a file or folder name which is not in use for sure
|
|
||||||
func randomPath(tempDir, basename, suffix string) (string, error) {
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
fname := fmt.Sprintf("/%s/%s-%d%s", tempDir, basename, rand.Int31(), suffix)
|
|
||||||
fmt.Printf("fname %q", fname)
|
|
||||||
_, err := os.Stat(fname)
|
|
||||||
if err != nil {
|
|
||||||
return fname, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("cannot create temp file in %s", tempDir)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// startupT runs fully configured testing server
|
// startupT runs fully configured testing server
|
||||||
// srvHook is an optional func to set some Rest param after the creation but prior to Run
|
// srvHook is an optional func to set some Rest param after the creation but prior to Run
|
||||||
func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, srv *Rest, teardown func()) {
|
func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, srv *Rest, teardown func()) {
|
||||||
tmp := os.TempDir()
|
tmp := os.TempDir()
|
||||||
testDB, err := randomPath(tmp, "test-remark", ".db")
|
testDB := filepath.Join(t.TempDir(), "test-remark.db") // per-test dir, removed when the test ends
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_ = os.RemoveAll(tmp + "/ava-remark42")
|
_ = os.RemoveAll(tmp + "/ava-remark42")
|
||||||
_ = os.RemoveAll(tmp + "/pics-remark42")
|
_ = os.RemoveAll(tmp + "/pics-remark42")
|
||||||
@@ -430,7 +605,7 @@ func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, sr
|
|||||||
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: testDB, SiteID: "remark42"})
|
b, err := engine.NewBoltDB(bolt.Options{}, engine.BoltSite{FileName: testDB, SiteID: "remark42"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
memCache := cache.NewScache(cache.NewNopCache())
|
memCache := cache.NewScache[[]byte](cache.NewNopCache[[]byte]())
|
||||||
|
|
||||||
astore := adminstore.NewStaticStore("123456", []string{"remark42"}, []string{"a1", "a2"}, "admin@remark-42.com")
|
astore := adminstore.NewStaticStore("123456", []string{"remark42"}, []string{"a1", "a2"}, "admin@remark-42.com")
|
||||||
restrictedWordsMatcher := service.NewRestrictedWordsMatcher(service.StaticRestrictedWordsLister{Words: []string{"duck"}})
|
restrictedWordsMatcher := service.NewRestrictedWordsMatcher(service.StaticRestrictedWordsLister{Words: []string{"duck"}})
|
||||||
@@ -450,7 +625,7 @@ func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, sr
|
|||||||
DataService: dataStore,
|
DataService: dataStore,
|
||||||
Authenticator: auth.NewService(auth.Opts{
|
Authenticator: auth.NewService(auth.Opts{
|
||||||
AdminPasswd: "password",
|
AdminPasswd: "password",
|
||||||
SecretReader: token.SecretFunc(func(aud string) (string, error) { return "secret", nil }),
|
SecretReader: token.SecretFunc(func(string) (string, error) { return "secret", nil }),
|
||||||
AvatarStore: avatar.NewLocalFS(tmp + "/ava-remark42"),
|
AvatarStore: avatar.NewLocalFS(tmp + "/ava-remark42"),
|
||||||
}),
|
}),
|
||||||
Cache: memCache,
|
Cache: memCache,
|
||||||
@@ -479,21 +654,30 @@ func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, sr
|
|||||||
Cache: memCache,
|
Cache: memCache,
|
||||||
KeyStore: astore,
|
KeyStore: astore,
|
||||||
},
|
},
|
||||||
NotifyService: notify.NopService,
|
NotifyService: notify.NopService,
|
||||||
EmojiEnabled: true,
|
EmojiEnabled: true,
|
||||||
|
openRouteLimiter: 100,
|
||||||
}
|
}
|
||||||
srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = -5, -10
|
srv.ScoreThresholds.Low, srv.ScoreThresholds.Critical = -5, -10
|
||||||
|
|
||||||
|
// add some providers. Needed because we don't allow users with unlisted providers to authenticate
|
||||||
|
providers := []string{"provider1", "anonymous", "github", "email"}
|
||||||
|
for _, p := range providers {
|
||||||
|
srv.Authenticator.AddDirectProvider(p, provider.CredCheckerFunc(func(_, _ string) (ok bool, err error) {
|
||||||
|
return true, nil
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
for _, h := range srvHook {
|
for _, h := range srvHook {
|
||||||
h(srv)
|
h(srv)
|
||||||
}
|
}
|
||||||
|
|
||||||
ts = httptest.NewServer(srv.routes())
|
routes := srv.routes()
|
||||||
|
ts = httptest.NewServer(routes)
|
||||||
|
|
||||||
teardown = func() {
|
teardown = func() {
|
||||||
ts.Close()
|
ts.Close()
|
||||||
require.NoError(t, srv.DataService.Close())
|
require.NoError(t, srv.DataService.Close())
|
||||||
_ = os.Remove(testDB)
|
|
||||||
_ = os.RemoveAll(tmp + "/ava-remark42")
|
_ = os.RemoveAll(tmp + "/ava-remark42")
|
||||||
_ = os.RemoveAll(tmp + "/pics-remark42")
|
_ = os.RemoveAll(tmp + "/pics-remark42")
|
||||||
}
|
}
|
||||||
@@ -501,6 +685,44 @@ func startupT(t *testing.T, srvHook ...func(srv *Rest)) (ts *httptest.Server, sr
|
|||||||
return ts, srv, teardown
|
return ts, srv, teardown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// outer bound before a wait is called a hang, generous enough for a loaded CI runner
|
||||||
|
waitTimeout = 30 * time.Second
|
||||||
|
pollInterval = 10 * time.Millisecond
|
||||||
|
|
||||||
|
// budget for a server to stop once asked, tight enough to catch a shutdown that hangs
|
||||||
|
serverStopTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// connect budget for a single probe, kept off the poll interval so a slow loopback connect
|
||||||
|
// on a loaded runner does not look like a server that is not listening
|
||||||
|
probeDialTimeout = time.Second
|
||||||
|
|
||||||
|
// window to prove something did not happen
|
||||||
|
notifySettle = 300 * time.Millisecond
|
||||||
|
|
||||||
|
// poll interval for waits that issue an HTTP request. the admin routes allow 10 req/s and
|
||||||
|
// the open ones 100 in tests, so this stays below the tighter of the two and the poll
|
||||||
|
// cannot manufacture the 429s it would then have to interpret
|
||||||
|
httpPoll = 150 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// waitForCount blocks until got reaches want, failing the test with the last value it saw.
|
||||||
|
// for work that is delivered asynchronously, such as notifications reaching a mock destination
|
||||||
|
func waitForCount(t *testing.T, want int, got func() int, msgAndArgs ...any) {
|
||||||
|
t.Helper()
|
||||||
|
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||||
|
assert.Equal(c, want, got(), msgAndArgs...)
|
||||||
|
}, waitTimeout, pollInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForCountSettled waits for got to reach want and then holds it there, so a delivery
|
||||||
|
// arriving late is caught rather than passing because the count was read the instant it matched
|
||||||
|
func waitForCountSettled(t *testing.T, want int, got func() int, msgAndArgs ...any) {
|
||||||
|
t.Helper()
|
||||||
|
waitForCount(t, want, got, msgAndArgs...)
|
||||||
|
require.Never(t, func() bool { return got() != want }, notifySettle, pollInterval, msgAndArgs...)
|
||||||
|
}
|
||||||
|
|
||||||
// fake auth middleware make user authenticated and uses query's fake_id for ID and fake_name for Name
|
// fake auth middleware make user authenticated and uses query's fake_id for ID and fake_name for Name
|
||||||
func fakeAuth(next http.Handler) http.Handler {
|
func fakeAuth(next http.Handler) http.Handler {
|
||||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -524,7 +746,7 @@ func get(t *testing.T, url string) (response string, statusCode int) {
|
|||||||
return string(body), r.StatusCode
|
return string(body), r.StatusCode
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReq(_ *testing.T, r *http.Request, tkn string) (*http.Response, error) {
|
func sendReq(r *http.Request, tkn string) (*http.Response, error) {
|
||||||
client := http.Client{Timeout: 5 * time.Second}
|
client := http.Client{Timeout: 5 * time.Second}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
if tkn != "" {
|
if tkn != "" {
|
||||||
@@ -547,6 +769,20 @@ func getWithDevAuth(t *testing.T, url string) (body string, code int) {
|
|||||||
return string(b), r.StatusCode
|
return string(b), r.StatusCode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getWithDev2Auth(t *testing.T, url string) (body string, code int) {
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
req, err := http.NewRequest("GET", url, http.NoBody)
|
||||||
|
require.NoError(t, err)
|
||||||
|
req.Header.Add("X-JWT", dev2Token)
|
||||||
|
r, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
b, err := io.ReadAll(r.Body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
require.NoError(t, r.Body.Close())
|
||||||
|
return string(b), r.StatusCode
|
||||||
|
}
|
||||||
|
|
||||||
func getWithAdminAuth(t *testing.T, url string) (response string, statusCode int) {
|
func getWithAdminAuth(t *testing.T, url string) (response string, statusCode int) {
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
@@ -569,13 +805,17 @@ func post(t *testing.T, url, body string) (*http.Response, error) {
|
|||||||
return client.Do(req)
|
return client.Do(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func addComment(t *testing.T, c store.Comment, ts *httptest.Server) string {
|
func addCommentGetCreatedTime(t *testing.T, c store.Comment, ts *httptest.Server) (id string, created time.Time) {
|
||||||
b, err := json.Marshal(c)
|
b, err := json.Marshal(c)
|
||||||
require.NoError(t, err, "can't marshal comment %+v", c)
|
require.NoError(t, err, "can't marshal comment %+v", c)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
defer client.CloseIdleConnections()
|
defer client.CloseIdleConnections()
|
||||||
req, err := http.NewRequest("POST", ts.URL+"/api/v1/comment", bytes.NewBuffer(b))
|
postURL := ts.URL + "/api/v1/comment"
|
||||||
|
if c.Locator.SiteID != "" {
|
||||||
|
postURL += "?site=" + c.Locator.SiteID
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("POST", postURL, bytes.NewBuffer(b))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
req.Header.Add("X-JWT", devToken)
|
req.Header.Add("X-JWT", devToken)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
@@ -588,45 +828,63 @@ func addComment(t *testing.T, c store.Comment, ts *httptest.Server) string {
|
|||||||
crResp := R.JSON{}
|
crResp := R.JSON{}
|
||||||
err = json.Unmarshal(b, &crResp)
|
err = json.Unmarshal(b, &crResp)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
time.Sleep(time.Nanosecond * 10)
|
created, err = time.Parse(time.RFC3339, crResp["time"].(string))
|
||||||
return crResp["id"].(string)
|
require.NoError(t, err)
|
||||||
|
return crResp["id"].(string), created
|
||||||
|
}
|
||||||
|
|
||||||
|
func addComment(t *testing.T, c store.Comment, ts *httptest.Server) string {
|
||||||
|
id, _ := addCommentGetCreatedTime(t, c, ts)
|
||||||
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireAdminOnly(t *testing.T, req *http.Request) {
|
func requireAdminOnly(t *testing.T, req *http.Request) {
|
||||||
resp, err := sendReq(t, req, "") // no-auth user
|
resp, err := sendReq(req, "") // no-auth user
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
|
|
||||||
resp, err = sendReq(t, req, devToken) // non-admin user
|
resp, err = sendReq(req, devToken) // non-admin user
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, resp.Body.Close())
|
require.NoError(t, resp.Body.Close())
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func chooseRandomUnusedPort() (port int) {
|
// chooseUnusedPort asks the kernel for a free port from the ephemeral range, which makes a
|
||||||
for i := 0; i < 10; i++ {
|
// collision between concurrently running package test binaries very unlikely
|
||||||
port = 40000 + int(rand.Int31n(10000))
|
func chooseUnusedPort(t *testing.T) int {
|
||||||
if ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)); err == nil {
|
t.Helper()
|
||||||
_ = ln.Close()
|
ln, err := net.Listen("tcp", ":0")
|
||||||
break
|
require.NoError(t, err, "no free port available")
|
||||||
}
|
port := ln.Addr().(*net.TCPAddr).Port
|
||||||
}
|
require.NoError(t, ln.Close())
|
||||||
return port
|
return port
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForHTTPSServerStart(port int) {
|
// waitForServerStart blocks until something accepts on every listed port, failing the test
|
||||||
// wait for up to 3 seconds for HTTPS server to start
|
// naming the port that never came up
|
||||||
for i := 0; i < 300; i++ {
|
func waitForServerStart(t *testing.T, ports ...int) {
|
||||||
time.Sleep(time.Millisecond * 10)
|
t.Helper()
|
||||||
conn, _ := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), time.Millisecond*10)
|
for _, port := range ports {
|
||||||
if conn != nil {
|
require.Eventually(t, func() bool {
|
||||||
|
conn, err := net.DialTimeout("tcp", fmt.Sprintf("localhost:%d", port), probeDialTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
break
|
return true
|
||||||
}
|
}, waitTimeout, pollInterval, "server on port %d didn't start", port)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
goleak.VerifyTestMain(m)
|
goleak.VerifyTestMain(
|
||||||
|
m,
|
||||||
|
// this will be fixed in https://github.com/hashicorp/golang-lru/issues/159
|
||||||
|
goleak.IgnoreTopFunction("github.com/hashicorp/golang-lru/v2/expirable.NewLRU[...].func1"),
|
||||||
|
// regexp2, pulled in by chroma for syntax highlighting, keeps one shared clock goroutine
|
||||||
|
// alive for up to a second after the last match with a timeout, sleeping in 100ms ticks.
|
||||||
|
// it ends on its own, but a binary that finishes inside that window is reported as leaking
|
||||||
|
goleak.IgnoreAnyFunction("github.com/dlclark/regexp2/v2.runClock"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
cache "github.com/go-pkgz/lcw"
|
cache "github.com/go-pkgz/lcw/v2"
|
||||||
log "github.com/go-pkgz/lgr"
|
log "github.com/go-pkgz/lgr"
|
||||||
"github.com/gorilla/feeds"
|
"github.com/gorilla/feeds"
|
||||||
|
|
||||||
@@ -56,8 +56,7 @@ func (s *rss) postCommentsCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
if _, err = w.Write(data); err != nil { //nolint:gosec // xml feed bytes from gorilla/feeds, not HTML
|
||||||
if _, err = w.Write(data); err != nil {
|
|
||||||
log.Printf("[WARN] failed to send response to %s, %s", r.RemoteAddr, err)
|
log.Printf("[WARN] failed to send response to %s, %s", r.RemoteAddr, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,7 +87,7 @@ func (s *rss) siteCommentsCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
if _, err = w.Write(data); err != nil {
|
if _, err = w.Write(data); err != nil { //nolint:gosec // xml feed bytes from gorilla/feeds, not HTML
|
||||||
log.Printf("[WARN] failed to send response to %s, %s", r.RemoteAddr, err)
|
log.Printf("[WARN] failed to send response to %s, %s", r.RemoteAddr, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,7 +119,7 @@ func (s *rss) repliesCtrl(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
if _, err = w.Write(data); err != nil {
|
if _, err = w.Write(data); err != nil { //nolint:gosec // xml feed bytes from gorilla/feeds, not HTML
|
||||||
log.Printf("[WARN] failed to send response to %s, %s", r.RemoteAddr, err)
|
log.Printf("[WARN] failed to send response to %s, %s", r.RemoteAddr, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,22 +14,29 @@ import (
|
|||||||
"github.com/umputun/remark42/backend/app/store"
|
"github.com/umputun/remark42/backend/app/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// rssPubTime returns a second-aligned base timestamp and formats it the way the feed does, so
|
||||||
|
// comment pubDates are pinned rather than dependent on when in the second the test runs.
|
||||||
|
func rssPubTime() (base time.Time, pubDate string) {
|
||||||
|
base = time.Now().Truncate(time.Second)
|
||||||
|
return base, base.Format(time.RFC1123Z)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_RssPost(t *testing.T) {
|
func TestServer_RssPost(t *testing.T) {
|
||||||
ts, rst, teardown := startupT(t)
|
ts, rst, teardown := startupT(t)
|
||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
waitOnSecChange()
|
base, pubDate := rssPubTime()
|
||||||
|
|
||||||
c1 := store.Comment{
|
c1 := store.Comment{
|
||||||
ID: "1234567890",
|
ID: "1234567890",
|
||||||
Text: "test 123",
|
Text: "test 123",
|
||||||
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
Timestamp: base,
|
||||||
User: store.User{ID: "u1", Name: "developer one"},
|
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
||||||
|
User: store.User{ID: "u1", Name: "developer one"},
|
||||||
}
|
}
|
||||||
id1, err := rst.DataService.Create(c1)
|
id1, err := rst.DataService.Create(c1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "1234567890", id1)
|
assert.Equal(t, "1234567890", id1)
|
||||||
pubDate := time.Now().Format(time.RFC1123Z)
|
|
||||||
|
|
||||||
res, code := get(t, ts.URL+"/api/v1/rss/post?site=remark42&url=https://radio-t.com/blah1")
|
res, code := get(t, ts.URL+"/api/v1/rss/post?site=remark42&url=https://radio-t.com/blah1")
|
||||||
assert.Equal(t, http.StatusOK, code)
|
assert.Equal(t, http.StatusOK, code)
|
||||||
@@ -63,21 +70,21 @@ func TestServer_RssSite(t *testing.T) {
|
|||||||
ts, rst, teardown := startupT(t)
|
ts, rst, teardown := startupT(t)
|
||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
waitOnSecChange()
|
base, pubDate := rssPubTime()
|
||||||
|
|
||||||
pubDate := time.Now().Format(time.RFC1123Z)
|
|
||||||
|
|
||||||
c1 := store.Comment{
|
c1 := store.Comment{
|
||||||
ID: "comment-id-1",
|
ID: "comment-id-1",
|
||||||
Text: "test 123",
|
Text: "test 123",
|
||||||
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"},
|
Timestamp: base,
|
||||||
User: store.User{ID: "u1", Name: "developer one"},
|
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"},
|
||||||
|
User: store.User{ID: "u1", Name: "developer one"},
|
||||||
}
|
}
|
||||||
c2 := store.Comment{
|
c2 := store.Comment{
|
||||||
ID: "comment-id-2",
|
ID: "comment-id-2",
|
||||||
Text: "xyz test",
|
Text: "xyz test",
|
||||||
Locator: store.Locator{URL: "https://radio-t.com/blah11", SiteID: "remark42"},
|
Timestamp: base.Add(time.Millisecond),
|
||||||
User: store.User{ID: "u1", Name: "developer one"},
|
Locator: store.Locator{URL: "https://radio-t.com/blah11", SiteID: "remark42"},
|
||||||
|
User: store.User{ID: "u1", Name: "developer one"},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := rst.DataService.Create(c1)
|
_, err := rst.DataService.Create(c1)
|
||||||
@@ -126,22 +133,22 @@ func TestServer_RssWithReply(t *testing.T) {
|
|||||||
ts, rst, teardown := startupT(t)
|
ts, rst, teardown := startupT(t)
|
||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
waitOnSecChange()
|
base, pubDate := rssPubTime()
|
||||||
|
|
||||||
pubDate := time.Now().Format(time.RFC1123Z)
|
|
||||||
|
|
||||||
c1 := store.Comment{
|
c1 := store.Comment{
|
||||||
ID: "comment-id-1",
|
ID: "comment-id-1",
|
||||||
Text: "test 123",
|
Text: "test 123",
|
||||||
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"},
|
Timestamp: base,
|
||||||
User: store.User{ID: "u1", Name: "developer one"},
|
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"},
|
||||||
|
User: store.User{ID: "u1", Name: "developer one"},
|
||||||
}
|
}
|
||||||
c2 := store.Comment{
|
c2 := store.Comment{
|
||||||
ID: "comment-id-2",
|
ID: "comment-id-2",
|
||||||
ParentID: "comment-id-1",
|
ParentID: "comment-id-1",
|
||||||
Text: "xyz test",
|
Text: "xyz test",
|
||||||
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"},
|
Timestamp: base.Add(time.Millisecond),
|
||||||
User: store.User{ID: "u1", Name: "developer one"},
|
Locator: store.Locator{URL: "https://radio-t.com/blah10", SiteID: "remark42"},
|
||||||
|
User: store.User{ID: "u1", Name: "developer one"},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := rst.DataService.Create(c1)
|
_, err := rst.DataService.Create(c1)
|
||||||
@@ -186,42 +193,45 @@ func TestServer_RssReplies(t *testing.T) {
|
|||||||
ts, srv, teardown := startupT(t)
|
ts, srv, teardown := startupT(t)
|
||||||
defer teardown()
|
defer teardown()
|
||||||
|
|
||||||
waitOnSecChange()
|
base, pubDate := rssPubTime()
|
||||||
|
|
||||||
pubDate := time.Now().Format(time.RFC1123Z)
|
|
||||||
|
|
||||||
c1 := store.Comment{
|
c1 := store.Comment{
|
||||||
ID: "comment-1",
|
ID: "comment-1",
|
||||||
Text: "c1",
|
Text: "c1",
|
||||||
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
Timestamp: base,
|
||||||
User: store.User{ID: "user1", Name: "user1"},
|
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
||||||
|
User: store.User{ID: "user1", Name: "user1"},
|
||||||
}
|
}
|
||||||
c2 := store.Comment{
|
c2 := store.Comment{
|
||||||
ID: "comment-2",
|
ID: "comment-2",
|
||||||
Text: "reply to c1 from user2",
|
Text: "reply to c1 from user2",
|
||||||
ParentID: "comment-1",
|
ParentID: "comment-1",
|
||||||
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
Timestamp: base.Add(time.Millisecond),
|
||||||
User: store.User{ID: "user2", Name: "user2"},
|
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
||||||
|
User: store.User{ID: "user2", Name: "user2"},
|
||||||
}
|
}
|
||||||
c3 := store.Comment{
|
c3 := store.Comment{
|
||||||
ID: "comment-3",
|
ID: "comment-3",
|
||||||
Text: "reply to c1 from user3",
|
Text: "reply to c1 from user3",
|
||||||
ParentID: "comment-1",
|
ParentID: "comment-1",
|
||||||
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
Timestamp: base.Add(2 * time.Millisecond),
|
||||||
User: store.User{ID: "user3", Name: "user3"},
|
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
||||||
|
User: store.User{ID: "user3", Name: "user3"},
|
||||||
}
|
}
|
||||||
c4 := store.Comment{
|
c4 := store.Comment{
|
||||||
ID: "comment-4",
|
ID: "comment-4",
|
||||||
Text: "reply to c2 from developer one",
|
Text: "reply to c2 from developer one",
|
||||||
ParentID: "comment-2",
|
ParentID: "comment-2",
|
||||||
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
Timestamp: base.Add(3 * time.Millisecond),
|
||||||
User: store.User{ID: "dev", Name: "developer one"},
|
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
||||||
|
User: store.User{ID: "dev", Name: "developer one"},
|
||||||
}
|
}
|
||||||
c5 := store.Comment{
|
c5 := store.Comment{
|
||||||
ID: "comment-5",
|
ID: "comment-5",
|
||||||
Text: "developer one",
|
Text: "developer one",
|
||||||
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
Timestamp: base.Add(4 * time.Millisecond),
|
||||||
User: store.User{ID: "dev", Name: "developer one"},
|
Locator: store.Locator{URL: "https://radio-t.com/blah1", SiteID: "remark42"},
|
||||||
|
User: store.User{ID: "dev", Name: "developer one"},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := srv.DataService.Create(c1)
|
_, err := srv.DataService.Create(c1)
|
||||||
@@ -270,24 +280,15 @@ func TestServer_RssReplies(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusBadRequest, code)
|
assert.Equal(t, http.StatusBadRequest, code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitOnSecChange() {
|
|
||||||
for {
|
|
||||||
if time.Now().Nanosecond() < 100000000 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(10 * time.Nanosecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// clean formatting, i.e. multiple spaces, \t, \n
|
// clean formatting, i.e. multiple spaces, \t, \n
|
||||||
func cleanRssFormatting(expected, actual string) (cleanExp, cleanAct string) {
|
func cleanRssFormatting(expected, actual string) (cleanExp, cleanAct string) {
|
||||||
reSpaces := regexp.MustCompile(`[\s\p{Zs}]{2,}`)
|
reSpaces := regexp.MustCompile(`[\s\p{Zs}]{2,}`)
|
||||||
|
|
||||||
expected = strings.Replace(expected, "\n", " ", -1)
|
expected = strings.ReplaceAll(expected, "\n", " ")
|
||||||
expected = strings.Replace(expected, "\t", " ", -1)
|
expected = strings.ReplaceAll(expected, "\t", " ")
|
||||||
expected = reSpaces.ReplaceAllString(expected, " ")
|
expected = reSpaces.ReplaceAllString(expected, " ")
|
||||||
|
|
||||||
actual = strings.Replace(actual, "\n", " ", -1)
|
actual = strings.ReplaceAll(actual, "\n", " ")
|
||||||
actual = reSpaces.ReplaceAllString(actual, " ")
|
actual = reSpaces.ReplaceAllString(actual, " ")
|
||||||
return expected, actual
|
return expected, actual
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-18
@@ -2,16 +2,16 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
|
||||||
log "github.com/go-pkgz/lgr"
|
log "github.com/go-pkgz/lgr"
|
||||||
"golang.org/x/crypto/acme/autocert"
|
|
||||||
|
|
||||||
R "github.com/go-pkgz/rest"
|
R "github.com/go-pkgz/rest"
|
||||||
|
"github.com/go-pkgz/routegroup"
|
||||||
|
"golang.org/x/crypto/acme/autocert"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sslMode defines ssl mode for rest server
|
// sslMode defines ssl mode for rest server
|
||||||
@@ -40,13 +40,13 @@ type SSLConfig struct {
|
|||||||
|
|
||||||
// httpToHTTPSRouter creates new router which does redirect from http to https server
|
// httpToHTTPSRouter creates new router which does redirect from http to https server
|
||||||
// with default middlewares. Used in 'static' ssl mode.
|
// with default middlewares. Used in 'static' ssl mode.
|
||||||
func (s *Rest) httpToHTTPSRouter() chi.Router {
|
func (s *Rest) httpToHTTPSRouter() http.Handler {
|
||||||
log.Printf("[DEBUG] create https-to-http redirect routes")
|
log.Printf("[DEBUG] create http-to-https redirect routes")
|
||||||
router := chi.NewRouter()
|
router := routegroup.New(http.NewServeMux())
|
||||||
router.Use(middleware.RealIP, R.Recoverer(log.Default()))
|
router.Use(R.Recoverer(log.Default()))
|
||||||
router.Use(middleware.Throttle(1000), middleware.Timeout(60*time.Second))
|
router.Use(R.Throttle(1000), R.Timeout(60*time.Second))
|
||||||
|
|
||||||
router.Handle("/*", s.redirectHandler())
|
router.Handle("/", s.redirectHandler())
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,26 +54,45 @@ func (s *Rest) httpToHTTPSRouter() chi.Router {
|
|||||||
// with default middlewares. This part is necessary to obtain certificate from LE.
|
// with default middlewares. This part is necessary to obtain certificate from LE.
|
||||||
// If it receives not a acme challenge it performs redirect to https server.
|
// If it receives not a acme challenge it performs redirect to https server.
|
||||||
// Used in 'auto' ssl mode.
|
// Used in 'auto' ssl mode.
|
||||||
func (s *Rest) httpChallengeRouter(m *autocert.Manager) chi.Router {
|
func (s *Rest) httpChallengeRouter(m *autocert.Manager) http.Handler {
|
||||||
log.Printf("[DEBUG] create http-challenge routes")
|
log.Printf("[DEBUG] create http-challenge routes")
|
||||||
router := chi.NewRouter()
|
router := routegroup.New(http.NewServeMux())
|
||||||
router.Use(middleware.RealIP, R.Recoverer(log.Default()))
|
router.Use(R.Recoverer(log.Default()))
|
||||||
router.Use(middleware.Throttle(1000), middleware.Timeout(60*time.Second))
|
router.Use(R.Throttle(1000), R.Timeout(60*time.Second))
|
||||||
|
|
||||||
router.Handle("/*", m.HTTPHandler(s.redirectHandler()))
|
router.Handle("/", m.HTTPHandler(s.redirectHandler()))
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Rest) redirectHandler() http.Handler {
|
func (s *Rest) redirectHandler() http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
newURL := s.RemarkURL + r.URL.Path
|
newURL, err := s.redirectURL(r)
|
||||||
if r.URL.RawQuery != "" {
|
if err != nil {
|
||||||
newURL += "?" + r.URL.RawQuery
|
log.Printf("[WARN] failed to build redirect URL, %s", err)
|
||||||
|
http.Error(w, "invalid redirect URL", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, newURL, http.StatusTemporaryRedirect)
|
http.Redirect(w, r, newURL, http.StatusTemporaryRedirect)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Rest) redirectURL(r *http.Request) (string, error) {
|
||||||
|
baseURL, err := url.Parse(s.RemarkURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse remark URL: %w", err)
|
||||||
|
}
|
||||||
|
if baseURL.Scheme != "http" && baseURL.Scheme != "https" || baseURL.Host == "" {
|
||||||
|
return "", fmt.Errorf("remark URL must be absolute HTTP(S) URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
basePath := strings.TrimRight(baseURL.Path, "/")
|
||||||
|
requestPath := "/" + strings.TrimLeft(r.URL.Path, "/")
|
||||||
|
baseURL.Path = basePath + requestPath
|
||||||
|
baseURL.RawQuery = r.URL.RawQuery
|
||||||
|
baseURL.Fragment = ""
|
||||||
|
return baseURL.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Rest) makeAutocertManager() *autocert.Manager {
|
func (s *Rest) makeAutocertManager() *autocert.Manager {
|
||||||
return &autocert.Manager{
|
return &autocert.Manager{
|
||||||
Prompt: autocert.AcceptTOS,
|
Prompt: autocert.AcceptTOS,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func TestSSL_Redirect(t *testing.T) {
|
|||||||
|
|
||||||
client := http.Client{
|
client := http.Client{
|
||||||
// prevent http redirect
|
// prevent http redirect
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -40,6 +40,16 @@ func TestSSL_Redirect(t *testing.T) {
|
|||||||
assert.Equal(t, "https://localhost:443/blah?param=1", resp.Header.Get("Location"))
|
assert.Equal(t, "https://localhost:443/blah?param=1", resp.Header.Get("Location"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSSL_RedirectURLKeepsConfiguredHost(t *testing.T) {
|
||||||
|
rest := Rest{RemarkURL: "https://localhost:443/base"}
|
||||||
|
req, err := http.NewRequest("GET", "http://example.com//evil.test/path?next=//evil.test", http.NoBody)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
redirectURL, err := rest.redirectURL(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "https://localhost:443/base/evil.test/path?next=//evil.test", redirectURL)
|
||||||
|
}
|
||||||
|
|
||||||
func TestSSL_ACME_HTTPChallengeRouter(t *testing.T) {
|
func TestSSL_ACME_HTTPChallengeRouter(t *testing.T) {
|
||||||
rest := Rest{
|
rest := Rest{
|
||||||
RemarkURL: "https://localhost:443",
|
RemarkURL: "https://localhost:443",
|
||||||
@@ -56,7 +66,7 @@ func TestSSL_ACME_HTTPChallengeRouter(t *testing.T) {
|
|||||||
|
|
||||||
client := http.Client{
|
client := http.Client{
|
||||||
// prevent http redirect
|
// prevent http redirect
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
return http.ErrUseLastResponse
|
return http.ErrUseLastResponse
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"comments": [
|
||||||
|
{
|
||||||
|
"commentHex": "e7a2ef4b4aa1414a7ee65a989889aaecd9d5e7e3bca598ea7a967b4dbcaa8e11",
|
||||||
|
"domain": "example.com",
|
||||||
|
"url": "/example",
|
||||||
|
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
|
||||||
|
"markdown": "",
|
||||||
|
"html": "",
|
||||||
|
"parentHex": "root",
|
||||||
|
"score": 0,
|
||||||
|
"state": "approved",
|
||||||
|
"creationDate": "2022-10-25T07:25:46.807555Z",
|
||||||
|
"direction": 0,
|
||||||
|
"deleted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"commentHex": "a29e741145daceb4ca5b3e5e279e05b56f73c04703d93b944718ef757e15317f",
|
||||||
|
"domain": "example.com",
|
||||||
|
"url": "/example",
|
||||||
|
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
|
||||||
|
"markdown": "",
|
||||||
|
"html": "",
|
||||||
|
"parentHex": "root",
|
||||||
|
"score": 0,
|
||||||
|
"state": "approved",
|
||||||
|
"creationDate": "2023-07-26T12:24:55.058552Z",
|
||||||
|
"direction": 0,
|
||||||
|
"deleted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"commentHex": "46baf36433830a4e8bda1de56290cf5fd74c08bfa844fee4ec1744985dc77010",
|
||||||
|
"domain": "example.com",
|
||||||
|
"url": "/example",
|
||||||
|
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
|
||||||
|
"markdown": "",
|
||||||
|
"html": "",
|
||||||
|
"parentHex": "root",
|
||||||
|
"score": 0,
|
||||||
|
"state": "approved",
|
||||||
|
"creationDate": "2023-10-31T11:03:25.403282Z",
|
||||||
|
"direction": 0,
|
||||||
|
"deleted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"commentHex": "6d3bb64ff73b5f9d6a959212ffde472a51abf8bdefaa5ed843659796bceef9de",
|
||||||
|
"domain": "example.com",
|
||||||
|
"url": "/example",
|
||||||
|
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
|
||||||
|
"markdown": "",
|
||||||
|
"html": "",
|
||||||
|
"parentHex": "46baf36433830a4e8bda1de56290cf5fd74c08bfa844fee4ec1744985dc77010",
|
||||||
|
"score": 0,
|
||||||
|
"state": "approved",
|
||||||
|
"creationDate": "2023-11-01T22:23:47.112062Z",
|
||||||
|
"direction": 0,
|
||||||
|
"deleted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"commentHex": "23fcfcd03745ed71a9d23a9b59387a313df57e5c0faad8ba5dc96112766312c5",
|
||||||
|
"domain": "example.com",
|
||||||
|
"url": "/example",
|
||||||
|
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
|
||||||
|
"markdown": "",
|
||||||
|
"html": "",
|
||||||
|
"parentHex": "root",
|
||||||
|
"score": 0,
|
||||||
|
"state": "approved",
|
||||||
|
"creationDate": "2023-10-23T12:33:03.370182Z",
|
||||||
|
"direction": 0,
|
||||||
|
"deleted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"commentHex": "d0ad6f11cf0c5f8e17457a378a6bb789f412c6b7ef7ada4ae06ec8451f7a18aa",
|
||||||
|
"domain": "example.com",
|
||||||
|
"url": "/example",
|
||||||
|
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
|
||||||
|
"markdown": "",
|
||||||
|
"html": "",
|
||||||
|
"parentHex": "root",
|
||||||
|
"score": 0,
|
||||||
|
"state": "approved",
|
||||||
|
"creationDate": "2023-10-18T01:18:38.193625Z",
|
||||||
|
"direction": 0,
|
||||||
|
"deleted": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"commentHex": "098960fd01c1fc7c0d3ea428f52fab97ea5c18aa52f3565bba679224daddc687",
|
||||||
|
"domain": "example.com",
|
||||||
|
"url": "/example",
|
||||||
|
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
|
||||||
|
"markdown": "",
|
||||||
|
"html": "",
|
||||||
|
"parentHex": "23fcfcd03745ed71a9d23a9b59387a313df57e5c0faad8ba5dc96112766312c5",
|
||||||
|
"score": 0,
|
||||||
|
"state": "approved",
|
||||||
|
"creationDate": "2023-11-01T22:24:04.639965Z",
|
||||||
|
"direction": 0,
|
||||||
|
"deleted": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"commenters": [
|
||||||
|
{
|
||||||
|
"commenterHex": "018407e4b12b35f43b1d804d82607b341bef80c4325dd047d93f2cbb439cff85",
|
||||||
|
"email": "undefined",
|
||||||
|
"name": "blank",
|
||||||
|
"link": "undefined",
|
||||||
|
"photo": "undefined",
|
||||||
|
"provider": "anon",
|
||||||
|
"joinDate": "2022-06-09T15:54:29.865919Z",
|
||||||
|
"isModerator": false,
|
||||||
|
"deleted": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// webFiles serves /web from two sources: a name present in the frontend build is served from there,
|
||||||
|
// and any other name from the assets embedded in the binary.
|
||||||
|
type webFiles struct {
|
||||||
|
frontend fs.FS
|
||||||
|
embedded fs.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open resolves the name against both sources, and answers a missing .js with the .mjs sibling.
|
||||||
|
// The build stopped emitting .js while integrations still request it; the bundles carry no module
|
||||||
|
// syntax, so the same bytes serve both names.
|
||||||
|
func (w webFiles) Open(name string) (fs.File, error) {
|
||||||
|
// fs.ValidPath alone is not enough: it accepts names the operating system rejects, NUL among
|
||||||
|
// them, and os.DirFS turns those into fs.ErrInvalid, which renders as 500 rather than 404
|
||||||
|
if _, err := filepath.Localize(name); err != nil || !fs.ValidPath(name) {
|
||||||
|
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := w.open(name)
|
||||||
|
if err == nil {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, fs.ErrNotExist) || !strings.HasSuffix(name, ".js") {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
alias, aliasErr := w.open(strings.TrimSuffix(name, ".js") + ".mjs")
|
||||||
|
if aliasErr == nil {
|
||||||
|
return alias, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(aliasErr, fs.ErrNotExist) {
|
||||||
|
return nil, aliasErr
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// open looks the name up in the frontend build first. Only a missing file falls through to the
|
||||||
|
// embedded assets; every other error is returned so an unreadable file keeps reporting as one
|
||||||
|
// rather than being replaced by the embedded copy or reported as missing.
|
||||||
|
func (w webFiles) open(name string) (fs.File, error) {
|
||||||
|
f, err := w.frontend.Open(name)
|
||||||
|
if err == nil {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if name == "." {
|
||||||
|
// the embedded set is a flat list of files; only the frontend build answers for the
|
||||||
|
// directory itself, so a missing web root reports as missing rather than listing them
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return w.embedded.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// emptyFS stands in for a frontend source that could not be opened, so a misconfigured one serves
|
||||||
|
// nothing instead of panicking or serving the build at paths it does not belong at
|
||||||
|
type emptyFS struct{}
|
||||||
|
|
||||||
|
func (emptyFS) Open(name string) (fs.File, error) {
|
||||||
|
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
|
||||||
|
}
|
||||||
|
|
||||||
|
// remarkURLPlaceholder is what the frontend build carries wherever the instance URL belongs. The
|
||||||
|
// bundler cannot know that URL, so it emits this marker and every distribution fills it in: the
|
||||||
|
// docker image rewrites the files under the web root at container start, and the binary, which
|
||||||
|
// serves the build embedded in itself and has nothing to rewrite, does it here.
|
||||||
|
const remarkURLPlaceholder = "{% REMARK_URL %}"
|
||||||
|
|
||||||
|
// templatedFS fills the instance URL into the files carrying the placeholder. Without it the
|
||||||
|
// binary serves whatever the build baked in, which is a host no visitor can reach, and the widget
|
||||||
|
// falls back to it whenever a page omits remark_config.host.
|
||||||
|
type templatedFS struct {
|
||||||
|
fs fs.FS
|
||||||
|
remarkURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open substitutes in the file types the frontend templates, and hands everything else through
|
||||||
|
// untouched so images and stylesheets keep streaming from their original source
|
||||||
|
func (t templatedFS) Open(name string) (fs.File, error) {
|
||||||
|
f, err := t.fs.Open(name)
|
||||||
|
if err != nil || !templatedName(name) {
|
||||||
|
return f, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return f, err
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(f)
|
||||||
|
if cerr := f.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
body = bytes.ReplaceAll(body, []byte(remarkURLPlaceholder), []byte(t.remarkURL))
|
||||||
|
return &memFile{Reader: bytes.NewReader(body), info: sizedInfo{FileInfo: info, size: int64(len(body))}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// templatedName reports whether the frontend templates this file type. It mirrors the set the
|
||||||
|
// docker image rewrites, so both distributions substitute in the same files
|
||||||
|
func templatedName(name string) bool {
|
||||||
|
switch filepath.Ext(name) {
|
||||||
|
case ".html", ".js", ".mjs":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// memFile is a substituted file held in memory. The file server needs a seeker to answer range
|
||||||
|
// requests and to sniff a content type, which a substituted body no longer has on disk
|
||||||
|
type memFile struct {
|
||||||
|
*bytes.Reader
|
||||||
|
info fs.FileInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *memFile) Stat() (fs.FileInfo, error) { return f.info, nil }
|
||||||
|
func (f *memFile) Close() error { return nil }
|
||||||
|
|
||||||
|
// sizedInfo reports the length after substitution. The file server writes Content-Length from it,
|
||||||
|
// so reporting the length on disk would truncate the response or leave the client waiting
|
||||||
|
type sizedInfo struct {
|
||||||
|
fs.FileInfo
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i sizedInfo) Size() int64 { return i.size }
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user