Compare commits
920 commits
database-d
...
develop
Author | SHA1 | Date | |
---|---|---|---|
|
72fa575fee | ||
|
01e239e657 | ||
|
79ab0a3786 | ||
|
188b41ef53 | ||
|
c249abdb10 | ||
|
2b79ab0313 | ||
|
db66a885fb | ||
|
79a66ff140 | ||
|
4601951bbf | ||
|
e1db89311d | ||
|
f7966996d7 | ||
|
a7fa19f2e4 | ||
|
7b5d583b76 | ||
|
b765c02166 | ||
|
0f9c32161c | ||
|
63963b6b19 | ||
|
d9deb29730 | ||
|
e65b73dc73 | ||
|
0be229cdaa | ||
|
4504617c1a | ||
|
17039e620e | ||
|
fbf505cff0 | ||
|
3966f9fa40 | ||
|
387ddac892 | ||
|
e3b94d7fb5 | ||
|
121a21d900 | ||
|
eab66ee5fc | ||
|
83e0a57338 | ||
|
b643e0520f | ||
|
f88aa393c5 | ||
|
eca7bcda0a | ||
|
6b4179fa01 | ||
|
e29f371232 | ||
|
b7de165b76 | ||
|
f49c0b1c4c | ||
|
2fcd45819f | ||
|
d06077c432 | ||
|
5b6d17c9b9 | ||
|
c046dd04e7 | ||
|
6384f4667b | ||
|
82de4558de | ||
|
ec7d336bc2 | ||
|
6d57d9d6a1 | ||
|
7fbf49a0f0 | ||
|
76818287d6 | ||
|
646fff775c | ||
|
ec8e0b4a8b | ||
|
d1c4efc143 | ||
|
c2ea61c82e | ||
|
6e01bb7d94 | ||
|
94f12dfc29 | ||
|
9421cfd422 | ||
|
8193a41082 | ||
|
1b20d3704f | ||
|
9c0a2f8b13 | ||
|
74a0947fdb | ||
|
984e5bc415 | ||
|
7f1cc6bf8f | ||
|
0ce5d3ba26 | ||
|
8d3d7419cd | ||
|
6b47fd9e35 | ||
|
25808c7281 | ||
|
2f37c666fb | ||
|
4f3bacb182 | ||
|
71033ecc3d | ||
|
f80139456e | ||
|
5198add7aa | ||
|
096430f1cd | ||
|
8d14c09dc6 | ||
|
0adffb98a6 | ||
|
deec706f14 | ||
|
ceb84bfc3c | ||
|
401f70c7ee | ||
|
0233a62f91 | ||
|
4753eef550 | ||
|
d159e0a1ca | ||
|
52d6ea60f3 | ||
|
2a668d18d3 | ||
|
b9f50883a9 | ||
|
680c0f5564 | ||
|
b8d652eb1a | ||
|
884a479de6 | ||
|
2bef7a2100 | ||
|
c6d54665ac | ||
|
24e94302d9 | ||
|
8372386abd | ||
|
23f0669f7a | ||
|
bfb9e3c4fb | ||
|
1b888e0c04 | ||
|
4e60f0fe6a | ||
|
0eb7c3bf47 | ||
|
de167b162c | ||
|
d03ae0e93d | ||
|
47c584d0a2 | ||
|
e338880609 | ||
|
1dd37bc56d | ||
|
226bb14716 | ||
|
68d43f7af9 | ||
|
5cc89b6795 | ||
|
f5d9839a70 | ||
|
57497f9542 | ||
|
8cf0d9c02c | ||
|
bdcf369b3d | ||
|
69eb4d6b0a | ||
|
d11270a340 | ||
|
1b69a89c59 | ||
|
038a80c25e | ||
|
9ece6682ef | ||
|
b0b06ec945 | ||
|
41e1989345 | ||
|
34d902062f | ||
|
5b7e2a6f2f | ||
|
ed9ff51b68 | ||
|
e36db46eb6 | ||
|
83ffea7fa0 | ||
|
3dd0a9b8dc | ||
|
427f4980b9 | ||
|
0bf67639c1 | ||
|
e34a58d0ef | ||
|
6d547040ef | ||
|
216f36f47b | ||
|
a352a3518a | ||
|
1a3f3f0ec6 | ||
|
306ca173c6 | ||
|
1d89dea72e | ||
|
22de459a72 | ||
|
5be1f2451c | ||
|
3a53353ed8 | ||
|
56cad35b19 | ||
|
ff84c7aa4d | ||
|
4c6169d55d | ||
|
ab1b2922cc | ||
|
9401d047d6 | ||
|
54b46b61db | ||
|
235a3ee143 | ||
|
f4accd5064 | ||
|
bc00ae1963 | ||
|
775d86cb00 | ||
|
90e564870d | ||
|
62c26e78ba | ||
|
69002fdcbf | ||
|
4acf08d9e9 | ||
|
df7fee2018 | ||
|
c64c7c77ae | ||
|
e788b90b04 | ||
|
66f049cc39 | ||
|
ff07c447ee | ||
|
d33a556732 | ||
|
737d76176a | ||
|
8e6ddc1993 | ||
|
b85afa1ea6 | ||
|
6b8cc591cc | ||
|
859a4b37e5 | ||
|
3caa33b9bf | ||
|
e932467ac9 | ||
|
aac4514577 | ||
|
21f5073717 | ||
|
64d1a2f536 | ||
|
e4e059cb13 | ||
|
feab841609 | ||
|
3e7d236c6d | ||
|
289730e24a | ||
|
a1becfdc83 | ||
|
0bf0b425ee | ||
|
10994c532f | ||
|
ae70c2dbe4 | ||
|
cdb1ffd1da | ||
|
d467fdf158 | ||
|
643d025381 | ||
|
ee485e0488 | ||
|
5204b3b752 | ||
|
45ca9c4c2b | ||
|
71fd25870d | ||
|
dd797c8145 | ||
|
3870749e5e | ||
|
87b3585c44 | ||
|
bf213cd0b0 | ||
|
815500ab78 | ||
|
4aad0338bf | ||
|
711cb387a5 | ||
|
e3323d11c8 | ||
|
076c4ae2f2 | ||
|
530a36fc53 | ||
|
8207a25fa9 | ||
|
7b84dafea7 | ||
|
ed60aea39e | ||
|
8f02449ee8 | ||
|
1e37f60d50 | ||
|
c18987705c | ||
|
7db4b699e2 | ||
|
26ba79ff02 | ||
|
b232e7efd7 | ||
|
64dcb56793 | ||
|
273267343a | ||
|
27e82f0409 | ||
|
167971771e | ||
|
2275a288b9 | ||
|
f96f8268f0 | ||
|
74f3ded250 | ||
|
c1609cdb90 | ||
|
e96e657430 | ||
|
f404f7b928 | ||
|
7dda53146d | ||
|
e2fde518ca | ||
|
c75507ca8f | ||
|
82e7dcd3f3 | ||
|
361c887e2c | ||
|
13ca890709 | ||
|
c6323dba8c | ||
|
dcc6f036c6 | ||
|
d7d44cb4e1 | ||
|
2a496bd000 | ||
|
15047b7288 | ||
|
d1afa44a2e | ||
|
ac40b2f733 | ||
|
e2b2ba4577 | ||
|
cc75be1eb5 | ||
|
221d0d7dbb | ||
|
cc9705447d | ||
|
06968e7341 | ||
|
62f9b2948e | ||
|
a8afa18ab2 | ||
|
b291b89904 | ||
|
96eb800eaa | ||
|
36f4e30595 | ||
|
177cbf2e57 | ||
|
334d499fb3 | ||
|
322d0d618a | ||
|
c9dc8d5a90 | ||
|
d48262a6df | ||
|
83f230ddaf | ||
|
efe669b874 | ||
|
aa72bcba50 | ||
|
8626aa12cc | ||
|
264bef03b1 | ||
|
e0c165ff1e | ||
|
2986f83121 | ||
|
3d8b8ecc93 | ||
|
5d4ebb59c7 | ||
|
2b5318e7a6 | ||
|
baf1d76475 | ||
|
94bb566e4f | ||
|
d3f312a1e2 | ||
|
ebeb45ac5a | ||
|
3dc515c249 | ||
|
10a415a7ec | ||
|
a8c5468f65 | ||
|
43ba111e21 | ||
|
299686c13e | ||
|
dff01a6136 | ||
|
8f03da0ec1 | ||
|
142c5d6cec | ||
|
526db318c4 | ||
|
fe1f821422 | ||
|
2fde648519 | ||
|
3e21ecb53c | ||
|
3ba29aaa2c | ||
|
c60d135060 | ||
|
4c48733a3a | ||
|
f2474798bb | ||
|
9c9fa8bf62 | ||
|
3981b6dddb | ||
|
da3e5d0606 | ||
|
51c46621d8 | ||
|
21a1c738d1 | ||
|
0814ec28dc | ||
|
c7729a0432 | ||
|
a408f0f9ea | ||
|
e9b03c9350 | ||
|
65ec6b44e1 | ||
|
21efde71f7 | ||
|
8755f1706c | ||
|
41138e4ab2 | ||
|
0860d1db1f | ||
|
b54de10663 | ||
|
78e59b749b | ||
|
20fec65e6b | ||
|
cf53730f6c | ||
|
dbdbcfd100 | ||
|
54eb2db14d | ||
|
e65086b635 | ||
|
b753d41964 | ||
|
5d5a8536c8 | ||
|
9580cffb3d | ||
|
1aee7ed125 | ||
|
989d7eb2fc | ||
|
ba8aebaa6f | ||
|
949f13bf66 | ||
|
f92f7b13cb | ||
|
98790ee371 | ||
|
a9733c30cf | ||
|
d3f935f693 | ||
|
3eb3146ae9 | ||
|
229607a5ab | ||
|
d476c3b2f7 | ||
|
6946d3b785 | ||
|
e0372979d9 | ||
|
639770be4d | ||
|
b0b166e827 | ||
|
e2237653bb | ||
|
77823a382b | ||
|
b6d17a9594 | ||
|
e1e05e5f29 | ||
|
67dbc9b22b | ||
|
3f5fd6e2d2 | ||
|
3a7554abe8 | ||
|
e350b7ce8a | ||
|
1a61128dfc | ||
|
ddabab041a | ||
|
2ba840634b | ||
|
ac9c53cfff | ||
|
1a4845aca8 | ||
|
7c0e69cf41 | ||
|
cdaa13a260 | ||
|
0dcfd1809d | ||
|
ad6c8f30bc | ||
|
86c76b0442 | ||
|
43176ed7ea | ||
|
64772aa203 | ||
|
40b9c08c86 | ||
|
ea81e2c839 | ||
|
02fb079a9f | ||
|
0746ec8567 | ||
|
7e5d60043d | ||
|
af875b4d87 | ||
|
8dd7b40c02 | ||
|
8834253502 | ||
|
7feea370ed | ||
|
680f0d1e20 | ||
|
bc53300e33 | ||
|
af0927cf5c | ||
|
ee665c0c68 | ||
|
83765d5cbc | ||
|
77cc1cc816 | ||
|
118eb732f4 | ||
|
99d72881cf | ||
|
fc5a79a6bc | ||
|
a0f1e1821f | ||
|
f84b4b0f74 | ||
|
7a84d27dca | ||
|
3e6669828c | ||
|
bbcb61bc53 | ||
|
8684ff04a4 | ||
|
93d5fd152d | ||
|
6903dd4349 | ||
|
b5021f2b0c | ||
|
29c898867a | ||
|
17614b5e02 | ||
|
950090c0d7 | ||
|
01c920b253 | ||
|
4c1678f91e | ||
|
4b33c51ece | ||
|
99d17e5e97 | ||
|
6347301867 | ||
|
7f83bb2706 | ||
|
02383768ed | ||
|
f85241e037 | ||
|
a080e51aaa | ||
|
57b12f31c9 | ||
|
c58eedba7d | ||
|
9767910b1f | ||
|
ac1b947b18 | ||
|
a5c80b98e7 | ||
|
7b5326ada9 | ||
|
2c644dd262 | ||
|
7687341512 | ||
|
beb964a9f1 | ||
|
42c7e22b98 | ||
|
4f2b17ddb1 | ||
|
63eb682a60 | ||
|
ccef3bfdc7 | ||
|
2c44fb780a | ||
|
f43a3a8bfa | ||
|
61d1537fce | ||
|
d08f067e9c | ||
|
3696483d91 | ||
|
11266dd87e | ||
|
de0c1085b4 | ||
|
2cf7693a8e | ||
|
b74fd70ab5 | ||
|
915351c4af | ||
|
92504b6721 | ||
|
16c6788b62 | ||
|
2433745504 | ||
|
17c8e78a5c | ||
|
84fea7abba | ||
|
59767804a9 | ||
|
dec0142a5b | ||
|
f2dce539f4 | ||
|
ea0703949d | ||
|
35ac24223d | ||
|
0a19dc1ec2 | ||
|
baaf0580f5 | ||
|
e5103d555f | ||
|
cab7fc8647 | ||
|
face603a0e | ||
|
9a45030911 | ||
|
4680e2e046 | ||
|
c3ae4e6d3c | ||
|
dd88083b2a | ||
|
fd44bc5707 | ||
|
9ee83ae885 | ||
|
e92c33aae4 | ||
|
0d554ce180 | ||
|
a0e936ee1b | ||
|
46bb8e65a1 | ||
|
df7be46417 | ||
|
d1e6daee16 | ||
|
43ca80f3eb | ||
|
1530bf37ef | ||
|
401c8c1f4c | ||
|
b190a1508b | ||
|
27f68ef0cf | ||
|
69ab0d34e0 | ||
|
97a5121924 | ||
|
129f428bfa | ||
|
8c1785b904 | ||
|
a2f9642238 | ||
|
5b3d25b5cc | ||
|
6e5f7e87d2 | ||
|
e91748c0bc | ||
|
414d5b0a1c | ||
|
c4b124e37c | ||
|
f4977c7a34 | ||
|
6ad1f41cf4 | ||
|
3270470b68 | ||
|
2a0298cd46 | ||
|
a122e4e98a | ||
|
44bfd4573e | ||
|
cc69f9f2f1 | ||
|
ae7e42e24e | ||
|
fc8e209def | ||
|
e963755393 | ||
|
2288ccf2a2 | ||
|
a58180543e | ||
|
5be1938a8a | ||
|
c42439886c | ||
|
adb4fdc5fe | ||
|
b7f732b915 | ||
|
940d220bf3 | ||
|
48075fc183 | ||
|
577bdf14aa | ||
|
672fa10b94 | ||
|
de5e91cb71 | ||
|
6291f4f155 | ||
|
273c9cf418 | ||
|
fbb3000e4d | ||
|
6b336e22aa | ||
|
cbc2427475 | ||
|
276304d5b8 | ||
|
65bc73e527 | ||
|
d37ab544e8 | ||
|
1bdcf7096a | ||
|
ed771380fb | ||
|
720a8c1975 | ||
|
f933b36170 | ||
|
e91ffe2dcb | ||
|
3008668a7d | ||
|
0ddca40529 | ||
|
2ea235f0c4 | ||
|
e983c4527f | ||
|
25e4d6448b | ||
|
230c736583 | ||
|
e7245536f3 | ||
|
42db4b38f6 | ||
|
c05f7056c4 | ||
|
e42ba392c6 | ||
|
9341784c0c | ||
|
f0697fd555 | ||
|
7695f8c2e4 | ||
|
85fb2a952b | ||
|
6740fbe097 | ||
|
2938bba15a | ||
|
ddc7087d1e | ||
|
b010484493 | ||
|
73e0b72878 | ||
|
14f5100d6a | ||
|
5c89812764 | ||
|
7a71731274 | ||
|
b0f792c211 | ||
|
73450a50e3 | ||
|
895e04c8c4 | ||
|
4565c6dd90 | ||
|
a7c4a318f3 | ||
|
7c32dc1045 | ||
|
2903c86875 | ||
|
e5347dd924 | ||
|
c9c2adde0f | ||
|
b2c6c6c167 | ||
|
5a4ff2a9de | ||
|
c01fb585ba | ||
|
affcd270bb | ||
|
14a8961457 | ||
|
4e0912b32a | ||
|
02bb5013a7 | ||
|
7257af2905 | ||
|
36455eea2b | ||
|
967ee9679c | ||
|
d3d77cee54 | ||
|
7c1c1218b1 | ||
|
b092421f6e | ||
|
a6c93c37da | ||
|
1d8facfe1c | ||
|
f689706baa | ||
|
f06ab629d1 | ||
|
e4164cbf67 | ||
|
3b58d77e67 | ||
|
c0fdd8af49 | ||
|
c06a739f9b | ||
|
4ec8ffa699 | ||
|
e0a0d71c84 | ||
|
3ab21f7834 | ||
|
61974fadc0 | ||
|
439f8bd262 | ||
|
63fa8d299a | ||
|
27b43ac2f1 | ||
|
51a83069c4 | ||
|
ac7583eadb | ||
|
8ac2d0b310 | ||
|
866a585119 | ||
|
4228761eb3 | ||
|
68297acb74 | ||
|
de601e16ac | ||
|
484d2736ce | ||
|
f8888df746 | ||
|
0c7aba1f53 | ||
|
02490c798c | ||
|
11e636359d | ||
|
50c4e944a4 | ||
|
e58e457b25 | ||
|
af4e0b4f1c | ||
|
ed74228795 | ||
|
2c1d3a51af | ||
|
23818c6104 | ||
|
5510ef15b5 | ||
|
5ecf613cb5 | ||
|
9cbd254d64 | ||
|
733301d364 | ||
|
f1eae4007e | ||
|
f70fc0c4e2 | ||
|
2a9aa84366 | ||
|
64f1d71524 | ||
|
5a3e8d59b6 | ||
|
6f665e7e4b | ||
|
d7c9f56b40 | ||
|
47aa436caa | ||
|
424bd55816 | ||
|
3e282e4c85 | ||
|
85efbcccfc | ||
|
4a58a94e26 | ||
|
9aa5fc4420 | ||
|
636c9b35c0 | ||
|
a6a4bd38c1 | ||
|
811f996e84 | ||
|
3984042905 | ||
|
321c1af607 | ||
|
9f525876f4 | ||
|
9b336dee8c | ||
|
9aeeb52bdb | ||
|
9484880bca | ||
|
f2e3cd8bd7 | ||
|
00f2152c2b | ||
|
4cf9500704 | ||
|
fbb67bc9ef | ||
|
4f32af2d7f | ||
|
97242cd5ec | ||
|
bd77145bf3 | ||
|
1ea728b1e9 | ||
|
c813d08230 | ||
|
1ac5c4ab4d | ||
|
ff976a950e | ||
|
1f6d0e2e70 | ||
|
5b2c350b5d | ||
|
b3dd06c79b | ||
|
71b211b11e | ||
|
33d47ca420 | ||
|
c2c6b69044 | ||
|
706ae9cc77 | ||
|
1abc9b643f | ||
|
8a8288d2af | ||
|
e36e39cb73 | ||
|
583693ed8d | ||
|
19beabe2d1 | ||
|
ebdb932090 | ||
|
4c0fcdf7c6 | ||
|
9ed2687543 | ||
|
530439772d | ||
|
33cf9263f5 | ||
|
a10827cd50 | ||
|
65caaca659 | ||
|
2d38e8b65e | ||
|
8c0978419f | ||
|
391844fab9 | ||
|
e6c36fc2ef | ||
|
e6417d911c | ||
|
795748457c | ||
|
6c1ab93717 | ||
|
6049213661 | ||
|
9a55d38e4b | ||
|
676b673c94 | ||
|
b1cea637cb | ||
|
f31e4d650d | ||
|
53ea85dc86 | ||
|
fcf01a6039 | ||
|
30fc088cec | ||
|
3aa621ee36 | ||
|
d52e2826f8 | ||
|
ed00417d8d | ||
|
9f925c8138 | ||
|
0eb1a2deec | ||
|
b262fa144c | ||
|
0aafd0c368 | ||
|
3493921837 | ||
|
7d4df23d3c | ||
|
3b91400b62 | ||
|
bb008aa66c | ||
|
667cbb97ed | ||
|
e1cde913e2 | ||
|
345313200e | ||
|
211b02c209 | ||
|
b1e22795b1 | ||
|
cf0403d955 | ||
|
c1aed45388 | ||
|
083d8c4d67 | ||
|
e3c7a8ac3a | ||
|
454e781ed4 | ||
|
1b6f9b6742 | ||
|
5961eb8f27 | ||
|
f5f28550fb | ||
|
c22a751ab7 | ||
|
2768ea9414 | ||
|
13a3a68d54 | ||
|
ec7b299fd3 | ||
|
f534ee1dec | ||
|
678653ac30 | ||
|
75a79d49bd | ||
|
2908080b52 | ||
|
d6d510aec9 | ||
|
5ba0ea2b04 | ||
|
a96d4474ef | ||
|
a7190795f7 | ||
|
70dbfcfba4 | ||
|
da8c08668f | ||
|
61daca2b0d | ||
|
f847ade1ef | ||
|
3a789f5a00 | ||
|
79715891fb | ||
|
eb76faa129 | ||
|
7c1244e6b1 | ||
|
c31a87fb76 | ||
|
1b1d3064c9 | ||
|
3f36ede885 | ||
|
f821ead3a1 | ||
|
8be71481c8 | ||
|
a8a6525006 | ||
|
98d88b9a4b | ||
|
ac90cb2c80 | ||
|
00a5a4f7ab | ||
|
505b124db7 | ||
|
f75d4cb75d | ||
|
21579cfa71 | ||
|
1779aeaf8c | ||
|
62d29166f4 | ||
|
e60398f0b4 | ||
|
ce69117c79 | ||
|
d8019bba0d | ||
|
820c5ae557 | ||
|
3a915ad8ea | ||
|
8d27ee6d99 | ||
|
6f8d70043f | ||
|
9d0ba2bed4 | ||
|
cef51a7797 | ||
|
0ed9c9c746 | ||
|
217430e56b | ||
|
7a09a47de2 | ||
|
455e50c9a8 | ||
|
a78b36b871 | ||
|
00cceca104 | ||
|
4db2cb8986 | ||
|
a773d94dc7 | ||
|
04d404e61f | ||
|
21e9b4a667 | ||
|
63f023ea98 | ||
|
ab32caa49c | ||
|
13eb51913e | ||
|
95273697f4 | ||
|
dfa14c9c92 | ||
|
ab285644a0 | ||
|
d3f1e40010 | ||
|
7e3eb9a87b | ||
|
7fa78c2255 | ||
|
c16414843a | ||
|
b2382b5422 | ||
|
731d4e8efe | ||
|
fd3a6399b3 | ||
|
8b243e119f | ||
|
0c8b779afb | ||
|
5f52c23a65 | ||
|
e37bec6aa1 | ||
|
121d83d94d | ||
|
9b614bc922 | ||
|
09e70e07f8 | ||
|
7eeba4dc9e | ||
|
849e5b8503 | ||
|
fee44e7c8d | ||
|
a32fc44153 | ||
|
bd387c6dec | ||
|
cd6ccd257b | ||
|
9c835a2b9d | ||
|
55ffb86ac2 | ||
|
78e0d98589 | ||
|
6af24293d1 | ||
|
9d7783f80d | ||
|
2eaf7493d7 | ||
|
ab6d4bfb9d | ||
|
2c45307107 | ||
|
ad2e46cb40 | ||
|
e796331de8 | ||
|
9ff54f9944 | ||
|
aa170d0c5a | ||
|
42c6f3ca03 | ||
|
191eac77ab | ||
|
00c47fa62f | ||
|
29dc53aacd | ||
|
2f06b0b487 | ||
|
5897ef7cab | ||
|
99b2f41aa1 | ||
|
94094ed16d | ||
|
f278eccd14 | ||
|
267c9df1c4 | ||
|
b569144624 | ||
|
3d80b46bdc | ||
|
cfaaffdc6c | ||
|
3294087abd | ||
|
48e85b7c63 | ||
|
4c5f45f462 | ||
|
6dbc753ecb | ||
|
1451fc1369 | ||
|
dbe36861c3 | ||
|
24fa3d6863 | ||
|
94fee2e19e | ||
|
ede68d86a7 | ||
|
504a2a42aa | ||
|
b98903cff8 | ||
|
beef2b15a7 | ||
|
94bcb91220 | ||
|
a25664bb97 | ||
|
591bb0866c | ||
|
f6aa99e591 | ||
|
9624c4db00 | ||
|
507acc7e1c | ||
|
cceea03076 | ||
|
724ab34006 | ||
|
fe7ff38bd8 | ||
|
cd01a4459d | ||
|
405a2602ce | ||
|
92d822b5c6 | ||
|
211d441090 | ||
|
7b71d455a8 | ||
|
630ac1f7c0 | ||
|
badaffcd5c | ||
|
cfd2165442 | ||
|
75ca5cd417 | ||
|
ee1ca48800 | ||
|
89f7946cb0 | ||
|
6174987c6a | ||
|
5c94d23466 | ||
|
2aa154d85c | ||
|
53cb5c3837 | ||
|
9d854c17c1 | ||
|
037fc40fb3 | ||
|
5fe1dd1731 | ||
|
b9c467558c | ||
|
a0e517c224 | ||
|
dc7b5df90e | ||
|
8675eb0f95 | ||
|
99d86a7489 | ||
|
8e16bac12c | ||
|
7420039770 | ||
|
f15acf3880 | ||
|
308b1a7282 | ||
|
fd97539f85 | ||
|
cf3d5588c2 | ||
|
6fc166174b | ||
|
0c6d3e45e4 | ||
|
b97038e696 | ||
|
37ccf69d81 | ||
|
0127e38ed0 | ||
|
7b7df5535e | ||
|
5400f416c0 | ||
|
ca4a576c31 | ||
|
93c2773412 | ||
|
0e1459c6b2 | ||
|
658310bc24 | ||
|
ddd519f6b7 | ||
|
671c7e99a5 | ||
|
5e4ed5d9bc | ||
|
1c5a0099b6 | ||
|
5de4d2086b | ||
|
e51e58386e | ||
|
9f1dd7a138 | ||
|
c798a44f69 | ||
|
d6cb178eb6 | ||
|
c2417399a4 | ||
|
8cc793142e | ||
|
599e7669d0 | ||
|
dbd7eff7ea | ||
|
07debec8d5 | ||
|
8ad04c5187 | ||
|
f11e6ed843 | ||
|
540d716d29 | ||
|
1d25b38eb7 | ||
|
c3400242f0 | ||
|
9c93e55e0a | ||
|
0acc630af5 | ||
|
491a1148ee | ||
|
5d01f49ce9 | ||
|
d7d4cd907e | ||
|
b25e80bb1b | ||
|
9dbba9d8c7 | ||
|
048e8a5e13 | ||
|
f9cd87ae3a | ||
|
cf4f08b264 | ||
|
75a9df82ab | ||
|
9e25979e37 | ||
|
0285a9b0bd | ||
|
79a968f425 | ||
|
ac522ed600 | ||
|
97aec9c158 | ||
|
471a9e0602 | ||
|
a9bed9fea9 | ||
|
f4c106beaf | ||
|
3e1019f29d | ||
|
06054a2cd7 | ||
|
da0455198d | ||
|
5b6e008118 | ||
|
26b6ed5f4f | ||
|
f126ac624a | ||
|
c292512b9d | ||
|
f76bfebfde | ||
|
4b0833435f | ||
|
9780f0bbb9 | ||
|
d277e283d5 | ||
|
7bccb3d7f1 | ||
|
b3a541ab09 | ||
|
ee712bbfaa | ||
|
cb1553d67e | ||
|
58f27717be | ||
|
f1f5dbb128 | ||
|
bad970c60a | ||
|
2aeb994b04 | ||
|
172a6dba25 | ||
|
eda267e30a | ||
|
32f3fcb859 | ||
|
61ddcff2c0 | ||
|
83b2c5a21b | ||
|
471ef4d403 | ||
|
bb5da1d3f5 | ||
|
f1ffcf96ec | ||
|
5b2612af54 | ||
|
793380c1d9 | ||
|
2db6c33a41 | ||
|
151ec71163 | ||
|
7aef706977 | ||
|
c71d020e86 | ||
|
2550804d93 | ||
|
b6044120ef | ||
|
6aa8de3a4b | ||
|
fca864c94a | ||
|
7283b17400 | ||
|
4595d480ae | ||
|
cd2e725746 | ||
|
e140fe139f | ||
|
6027f7cfdc | ||
|
b42760abab | ||
|
f903388a28 | ||
|
9fe528bf47 | ||
|
303144fd24 | ||
|
46dbb10433 | ||
|
d17e82d34c | ||
|
05aad04b21 | ||
|
8933076296 | ||
|
6f3b502e65 | ||
|
e6e8cb5944 | ||
|
563ea5b25b | ||
|
34d196376e | ||
|
987c74c93a | ||
|
37b7755c08 | ||
|
c2ece926e0 | ||
|
389dc8b9db | ||
|
a06bb457de | ||
|
48ca695c46 | ||
|
68e992a55e | ||
|
602cd80020 | ||
|
0d79057bae | ||
|
84ab41697b | ||
|
b58464addb | ||
|
92da069ce4 | ||
|
71224d68a2 | ||
|
33474cb1f1 | ||
|
7fe281df69 | ||
|
b0f0de3dde | ||
|
6173405794 | ||
|
f846cada4b | ||
|
f406f894c5 | ||
|
d6c0026644 | ||
|
5d834c1cd2 | ||
|
c0317b4e93 | ||
|
5259c4fcdf | ||
|
fc553d277f | ||
|
482e632ca9 | ||
|
638059a26b | ||
|
8404f0896c | ||
|
dfa98bcfc8 | ||
|
25fe5285da | ||
|
4d150fe831 | ||
|
25145296b3 | ||
|
84d7ac35d3 | ||
|
aad4768aed | ||
|
38c1bf9cab |
206 changed files with 26969 additions and 1734 deletions
|
@ -1,2 +1 @@
|
|||
Dockerfile
|
||||
.git
|
||||
|
|
10
.editorconfig
Normal file
10
.editorconfig
Normal file
|
@ -0,0 +1,10 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
charset = utf-8
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
github: writefreely
|
||||
open_collective: writefreely
|
15
.github/dependabot.yml
vendored
Normal file
15
.github/dependabot.yml
vendored
Normal file
|
@ -0,0 +1,15 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gomod" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
open-pull-requests-limit: 50
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/Dockerfile"
|
||||
schedule:
|
||||
interval: "daily"
|
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
|
@ -2,4 +2,4 @@
|
|||
|
||||
---
|
||||
|
||||
- [ ] I have signed the [CLA](https://phabricator.write.as/L1)
|
||||
- [ ] I have signed the [CLA](https://todo.musing.studio/L1)
|
||||
|
|
70
.github/workflows/docker-publish.yml
vendored
Normal file
70
.github/workflows/docker-publish.yml
vendored
Normal file
|
@ -0,0 +1,70 @@
|
|||
name: Build container image, publish as GitHub-package
|
||||
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
# Publish semver tags as releases.
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Set up QEMU for cross-building
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
|
||||
# Set up Docker Buildx
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4.6.0
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
flavor: latest=true
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5.0.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,7 +1,9 @@
|
|||
node_modules
|
||||
*~
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
static/local/custom.css
|
||||
build
|
||||
tmp
|
||||
*.ini
|
||||
|
|
3
.gitmodules
vendored
3
.gitmodules
vendored
|
@ -1,3 +0,0 @@
|
|||
[submodule "static/js/mathjax"]
|
||||
path = static/js/mathjax
|
||||
url = https://github.com/mathjax/MathJax.git
|
101
CONTRIBUTING.md
101
CONTRIBUTING.md
|
@ -1,26 +1,99 @@
|
|||
# Contributing to WriteFreely
|
||||
|
||||
Welcome! We're glad you're interested in contributing to the WriteFreely project.
|
||||
Welcome! We're glad you're interested in contributing to WriteFreely.
|
||||
|
||||
To start, we'd suggest checking out [our Phabricator board](https://phabricator.write.as/tag/write_freely/) to see where the project is at and where it's going. You can also [join the WriteFreely forums](https://discuss.write.as/c/writefreely) to start talking about what you'd like to do or see.
|
||||
For **questions**, **help**, **feature requests**, and **general discussion**, please use [our forum](https://discuss.write.as).
|
||||
|
||||
## Asking Questions
|
||||
For **bug reports**, please [open a GitHub issue](https://github.com/writefreely/writefreely/issues/new). See our guide on [submitting bug reports](https://writefreely.org/contribute#bugs).
|
||||
|
||||
The best place to get answers to your questions is on [our forums](https://discuss.write.as/c/writefreely). You can quickly log in using your GitHub account and ask the community about anything. We're also there to answer your questions and discuss potential changes or features.
|
||||
## Getting Started
|
||||
|
||||
## Submitting Bugs
|
||||
There are many ways to contribute to WriteFreely, from code to documentation, to translations, to help in the community!
|
||||
|
||||
Please use the [GitHub issue tracker](https://github.com/writeas/writefreely/issues/new) to report any bugs you encounter. We're very responsive there and try to keep open issues to a minimum, so you can help by:
|
||||
See our [Contributing Guide](https://writefreely.org/contribute) on WriteFreely.org for ways to contribute without writing code. Otherwise, please read on.
|
||||
|
||||
* **Only reporting bugs in the issue tracker**
|
||||
* Providing as much information as possible to replicate the issue, including server logs around the incident
|
||||
* Including the `[app]` section of your configuration, if related
|
||||
* Breaking issues into smaller pieces if they're larger or have many parts
|
||||
## Working on WriteFreely
|
||||
|
||||
## Contributing code
|
||||
First, you'll want to clone the WriteFreely repo, install development dependencies, and build the application from source. Learn how to do this in our [Development Setup](https://writefreely.org/docs/latest/developer/setup) guide.
|
||||
|
||||
We gladly welcome development help, regardless of coding experience. We can also use help [translating the app](https://poeditor.com/join/project/TIZ6HFRFdE) and documenting it!
|
||||
### Starting development
|
||||
|
||||
**Before writing or submitting any code**, please sign our [contributor's agreement](https://phabricator.write.as/L1) so we can accept your contributions. It is substantially similar to the _Apache Individual Contributor License Agreement_. If you'd like to know about the rationale behind this requirement, you can [read more about that here](https://phabricator.write.as/w/writefreely/cla/).
|
||||
Next, [join our forum](https://discuss.write.as) so you can discuss development with the team. Then take a look at [our roadmap on Phabricator](https://todo.musing.studio/tag/writefreely/) to see where the project is today and where it's headed.
|
||||
|
||||
Once you've done that, please feel free to [submit a pull request](https://github.com/writeas/writefreely/pulls) for any small improvements. For larger projects, please [join our development discussions](https://discuss.write.as/c/writefreely) or [get in touch](https://write.as/contact) so we can talk about what you'd like to work on.
|
||||
When you find something you want to work on, start a new topic on the forum or jump into an existing discussion, if there is one. The team will respond and continue the conversation there.
|
||||
|
||||
Lastly, **before submitting any code**, please sign our [contributor's agreement](https://todo.musing.studio/L1) so we can accept your contributions. It is substantially similar to the _Apache Individual Contributor License Agreement_. If you'd like to know about the rationale behind this requirement, you can [read more about that here](https://todo.musing.studio/w/writefreely/cla/).
|
||||
|
||||
### Branching
|
||||
|
||||
All stable work lives on the `master` branch. We merge into it only when creating a release. Releases are tagged using semantic versioning.
|
||||
|
||||
While developing, we primarily work from the `develop` branch, creating _feature branches_ off of it for new features and fixes. When starting a new feature or fix, you should also create a new branch off of `develop`.
|
||||
|
||||
#### Branch naming
|
||||
|
||||
For fixes and modifications to existing behavior, branch names should follow a similar pattern to commit messages (see below), such as `fix-post-rendering` or `update-documentation`. You can optionally append a task number, e.g. `fix-post-rendering-T000`.
|
||||
|
||||
For new features, branches can be named after the new feature, e.g. `activitypub-mentions` or `import-zip`.
|
||||
|
||||
#### Pull request scope
|
||||
|
||||
The scope of work on each branch should be as small as possible -- one complete feature, one complete change, or one complete fix. This makes it easier for us to review and accept.
|
||||
|
||||
### Writing code
|
||||
|
||||
We value reliable, readable, and maintainable code over all else in our work. To help you write that kind of code, we offer a few guiding principles, as well as a few concrete guidelines.
|
||||
|
||||
#### Guiding principles
|
||||
|
||||
* Write code for other humans, not computers.
|
||||
* The less complexity, the better. The more someone can understand code just by looking at it, the better.
|
||||
* Functionality, readability, and maintainability over senseless elegance.
|
||||
* Only abstract when necessary.
|
||||
* Keep an eye to the future, but don't pre-optimize at the expense of today's simplicity.
|
||||
|
||||
#### Code guidelines
|
||||
|
||||
* Format all Go code with `go fmt` before committing (**important!**)
|
||||
* Follow whitespace conventions established within the project (tabs vs. spaces)
|
||||
* Add comments to exported Go functions and variables
|
||||
* Follow Go naming conventions, like using [`mixedCaps`](https://golang.org/doc/effective_go.html#mixed-caps)
|
||||
* Avoid new dependencies unless absolutely necessary
|
||||
|
||||
### Commit messages
|
||||
|
||||
We highly value commit messages that follow established form within the project. Generally speaking, we follow the practices [outlined](https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project#_commit_guidelines) in the Pro Git Book. A good commit message will look like the following:
|
||||
|
||||
* **Line 1**: A short summary written in the present imperative tense. For example:
|
||||
* ✔️ **Good**: "Fix post rendering bug"
|
||||
* ❌ No: ~~"Fixes post rendering bug"~~
|
||||
* ❌ No: ~~"Fixing post rendering bug"~~
|
||||
* ❌ No: ~~"Fixed post rendering bug"~~
|
||||
* ❌ No: ~~"Post rendering bug is fixed now"~~
|
||||
* **Line 2**: _[left blank]_
|
||||
* **Line 3**: An added description of what changed, any rationale, etc. -- if necessary
|
||||
* **Last line**: A mention of any applicable task or issue
|
||||
* For Phabricator tasks: `Ref T000` or `Closes T000`
|
||||
* For GitHub issues: `Ref #000` or `Fixes #000`
|
||||
|
||||
#### Good examples
|
||||
|
||||
When in doubt, look to our existing git history for examples of good commit messages. Here are a few:
|
||||
|
||||
* [Rename Suspend status to Silence](https://github.com/writefreely/writefreely/commit/7e014ca65958750ab703e317b1ce8cfc4aad2d6e)
|
||||
* [Show 404 when remote user not found](https://github.com/writefreely/writefreely/commit/867eb53b3596bd7b3f2be3c53a3faf857f4cd36d)
|
||||
* [Fix post deletion on Pleroma](https://github.com/writefreely/writefreely/commit/fe82cbb96e3d5c57cfde0db76c28c4ea6dabfe50)
|
||||
|
||||
### Submitting pull requests
|
||||
|
||||
Like our GitHub issues, we aim to keep our number of open pull requests to a minimum. You can follow a few guidelines to ensure changes are merged quickly.
|
||||
|
||||
First, make sure your changes follow the established practices and good form outlined in this guide. This is crucial to our project, and ignoring our practices can delay otherwise important fixes.
|
||||
|
||||
Beyond that, we prioritize pull requests in this order:
|
||||
|
||||
1. Fixes to open GitHub issues
|
||||
2. Superficial changes and improvements that don't adversely impact users
|
||||
3. New features and changes that have been discussed before with the team
|
||||
|
||||
Any pull requests that haven't previously been discussed with the team may be extensively delayed or closed, especially if they require a wider consideration before integrating into the project. When in doubt, please reach out [on the forum](https://discuss.write.as) before submitting a pull request.
|
||||
|
|
45
Dockerfile
45
Dockerfile
|
@ -1,30 +1,40 @@
|
|||
# Build image
|
||||
FROM golang:1.13-alpine as build
|
||||
FROM golang:1.21-alpine3.18 as build
|
||||
|
||||
RUN apk add --update nodejs nodejs-npm make g++ git sqlite-dev
|
||||
RUN npm install -g less less-plugin-clean-css
|
||||
RUN go get -u github.com/jteeuwen/go-bindata/...
|
||||
LABEL org.opencontainers.image.source="https://github.com/writefreely/writefreely"
|
||||
LABEL org.opencontainers.image.description="WriteFreely is a clean, minimalist publishing platform made for writers. Start a blog, share knowledge within your organization, or build a community around the shared act of writing."
|
||||
|
||||
RUN apk -U upgrade \
|
||||
&& apk add --no-cache nodejs npm make g++ git \
|
||||
&& npm install -g less less-plugin-clean-css \
|
||||
&& mkdir -p /go/src/github.com/writefreely/writefreely
|
||||
|
||||
WORKDIR /go/src/github.com/writefreely/writefreely
|
||||
|
||||
RUN mkdir -p /go/src/github.com/writeas/writefreely
|
||||
WORKDIR /go/src/github.com/writeas/writefreely
|
||||
COPY . .
|
||||
|
||||
RUN cat ossl_legacy.cnf > /etc/ssl/openssl.cnf
|
||||
|
||||
ENV GO111MODULE=on
|
||||
ENV NODE_OPTIONS=--openssl-legacy-provider
|
||||
|
||||
RUN make build \
|
||||
&& make ui
|
||||
RUN mkdir /stage && \
|
||||
cp -R /go/bin \
|
||||
/go/src/github.com/writeas/writefreely/templates \
|
||||
/go/src/github.com/writeas/writefreely/static \
|
||||
/go/src/github.com/writeas/writefreely/pages \
|
||||
/go/src/github.com/writeas/writefreely/keys \
|
||||
/go/src/github.com/writeas/writefreely/cmd \
|
||||
&& make ui \
|
||||
&& mkdir /stage \
|
||||
&& cp -R /go/bin \
|
||||
/go/src/github.com/writefreely/writefreely/templates \
|
||||
/go/src/github.com/writefreely/writefreely/static \
|
||||
/go/src/github.com/writefreely/writefreely/pages \
|
||||
/go/src/github.com/writefreely/writefreely/keys \
|
||||
/go/src/github.com/writefreely/writefreely/cmd \
|
||||
/stage
|
||||
|
||||
# Final image
|
||||
FROM alpine:3.11
|
||||
FROM alpine:3.18.4
|
||||
|
||||
RUN apk -U upgrade \
|
||||
&& apk add --no-cache openssl ca-certificates
|
||||
|
||||
RUN apk add --no-cache openssl ca-certificates
|
||||
COPY --from=build --chown=daemon:daemon /stage /go
|
||||
|
||||
WORKDIR /go
|
||||
|
@ -33,3 +43,6 @@ EXPOSE 8080
|
|||
USER daemon
|
||||
|
||||
ENTRYPOINT ["cmd/writefreely/writefreely"]
|
||||
|
||||
HEALTHCHECK --start-period=5s --interval=15s --timeout=5s \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/ || exit 1
|
34
Dockerfile.prod
Normal file
34
Dockerfile.prod
Normal file
|
@ -0,0 +1,34 @@
|
|||
FROM golang:alpine AS build
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/writefreely/writefreely"
|
||||
LABEL org.opencontainers.image.description="WriteFreely is a clean, minimalist publishing platform made for writers. Start a blog, share knowledge within your organization, or build a community around the shared act of writing."
|
||||
|
||||
RUN apk update --no-cache && \
|
||||
apk upgrade --no-cache && \
|
||||
apk add --no-cache nodejs npm make g++ git sqlite-dev patch && \
|
||||
npm install -g less less-plugin-clean-css && \
|
||||
mkdir -p /go/src/github.com/writefreely/writefreely
|
||||
|
||||
COPY . /go/src/github.com/writefreely/writefreely
|
||||
WORKDIR /go/src/github.com/writefreely/writefreely
|
||||
ENV NODE_OPTIONS=--openssl-legacy-provider
|
||||
RUN cat ossl_legacy.cnf >> /etc/ssl/openssl.cnf && \
|
||||
make build && \
|
||||
make ui
|
||||
|
||||
FROM alpine
|
||||
|
||||
RUN apk update --no-cache && \
|
||||
apk upgrade --no-cache && \
|
||||
apk add --no-cache openssl ca-certificates && \
|
||||
mkdir /usr/share/writefreely
|
||||
|
||||
COPY --from=build /go/src/github.com/writefreely/writefreely/cmd/writefreely/writefreely /usr/bin
|
||||
COPY --from=build /go/src/github.com/writefreely/writefreely/pages /usr/share/writefreely/pages
|
||||
COPY --from=build /go/src/github.com/writefreely/writefreely/static /usr/share/writefreely/static
|
||||
COPY --from=build /go/src/github.com/writefreely/writefreely/templates /usr/share/writefreely/templates
|
||||
|
||||
ENV WRITEFREELY_DOCKER=True
|
||||
ENV HOME=/data
|
||||
WORKDIR /data
|
||||
CMD ["/usr/bin/writefreely"]
|
88
Makefile
88
Makefile
|
@ -1,5 +1,5 @@
|
|||
GITREV=`git describe | cut -c 2-`
|
||||
LDFLAGS=-ldflags="-X 'github.com/writeas/writefreely.softwareVer=$(GITREV)'"
|
||||
LDFLAGS=-ldflags="-s -w -X 'github.com/writefreely/writefreely.softwareVer=$(GITREV)' -extldflags '-static'"
|
||||
|
||||
GOCMD=go
|
||||
GOINSTALL=$(GOCMD) install $(LDFLAGS)
|
||||
|
@ -14,50 +14,56 @@ TMPBIN=./tmp
|
|||
|
||||
all : build
|
||||
|
||||
ci: ci-assets deps
|
||||
ci: deps
|
||||
cd cmd/writefreely; $(GOBUILD) -v
|
||||
|
||||
build: assets deps
|
||||
cd cmd/writefreely; $(GOBUILD) -v -tags='sqlite'
|
||||
build: deps
|
||||
cd cmd/writefreely; $(GOBUILD) -v -tags='netgo sqlite'
|
||||
|
||||
build-no-sqlite: assets-no-sqlite deps-no-sqlite
|
||||
cd cmd/writefreely; $(GOBUILD) -v -o $(BINARY_NAME)
|
||||
build-no-sqlite: deps-no-sqlite
|
||||
cd cmd/writefreely; $(GOBUILD) -v -tags='netgo' -o $(BINARY_NAME)
|
||||
|
||||
build-linux: deps
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||
xgo --targets=linux/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.21.x -out writefreely -pkg ./cmd/writefreely .
|
||||
|
||||
build-windows: deps
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||
xgo --targets=windows/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.21.x -out writefreely -pkg ./cmd/writefreely .
|
||||
|
||||
build-darwin: deps
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=darwin/amd64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||
xgo --targets=darwin/amd64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.21.x -out writefreely -pkg ./cmd/writefreely .
|
||||
|
||||
build-darwin-arm64: deps
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=darwin/arm64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.21.x -out writefreely -pkg ./cmd/writefreely .
|
||||
|
||||
build-arm6: deps
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=linux/arm-6, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||
xgo --targets=linux/arm-6, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.21.x -out writefreely -pkg ./cmd/writefreely .
|
||||
|
||||
build-arm7: deps
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=linux/arm-7, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||
xgo --targets=linux/arm-7, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.21.x -out writefreely -pkg ./cmd/writefreely .
|
||||
|
||||
build-arm64: deps
|
||||
@hash xgo > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOGET) -u src.techknowlogick.com/xgo; \
|
||||
$(GOCMD) install src.techknowlogick.com/xgo@latest; \
|
||||
fi
|
||||
xgo --targets=linux/arm64, -dest build/ $(LDFLAGS) -tags='sqlite' -out writefreely ./cmd/writefreely
|
||||
xgo --targets=linux/arm64, -dest build/ $(LDFLAGS) -tags='netgo sqlite' -go go-1.21.x -out writefreely -pkg ./cmd/writefreely .
|
||||
|
||||
build-docker :
|
||||
$(DOCKERCMD) build -t $(IMAGE_NAME):latest -t $(IMAGE_NAME):$(GITREV) .
|
||||
|
@ -65,8 +71,8 @@ build-docker :
|
|||
test:
|
||||
$(GOTEST) -v ./...
|
||||
|
||||
run: dev-assets
|
||||
$(GOINSTALL) -tags='sqlite' ./...
|
||||
run:
|
||||
$(GOINSTALL) -tags='netgo sqlite' ./...
|
||||
$(BINARY_NAME) --debug
|
||||
|
||||
deps :
|
||||
|
@ -81,11 +87,13 @@ install : build
|
|||
cmd/writefreely/$(BINARY_NAME) --init-db
|
||||
cd less/; $(MAKE) install $(MFLAGS)
|
||||
|
||||
release : clean ui assets
|
||||
release : clean ui
|
||||
mkdir -p $(BUILDPATH)
|
||||
cp -r templates $(BUILDPATH)
|
||||
cp -r pages $(BUILDPATH)
|
||||
cp -r static $(BUILDPATH)
|
||||
rsync -av --exclude=".*" templates $(BUILDPATH)
|
||||
rsync -av --exclude=".*" pages $(BUILDPATH)
|
||||
rsync -av --exclude=".*" static $(BUILDPATH)
|
||||
rm -r $(BUILDPATH)/static/local
|
||||
scripts/invalidate-css.sh $(BUILDPATH)
|
||||
mkdir $(BUILDPATH)/keys
|
||||
$(MAKE) build-linux
|
||||
mv build/$(BINARY_NAME)-linux-amd64 $(BUILDPATH)/$(BINARY_NAME)
|
||||
|
@ -104,13 +112,17 @@ release : clean ui assets
|
|||
tar -cvzf $(BINARY_NAME)_$(GITREV)_linux_arm64.tar.gz -C build $(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
$(MAKE) build-darwin
|
||||
mv build/$(BINARY_NAME)-darwin-10.6-amd64 $(BUILDPATH)/$(BINARY_NAME)
|
||||
mv build/$(BINARY_NAME)-darwin-10.12-amd64 $(BUILDPATH)/$(BINARY_NAME)
|
||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_macos_amd64.tar.gz -C build $(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
$(MAKE) build-darwin-arm64
|
||||
mv build/$(BINARY_NAME)-darwin-arm64 $(BUILDPATH)/$(BINARY_NAME)
|
||||
tar -cvzf $(BINARY_NAME)_$(GITREV)_macos_arm64.tar.gz -C build $(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
$(MAKE) build-windows
|
||||
mv build/$(BINARY_NAME)-windows-4.0-amd64.exe $(BUILDPATH)/$(BINARY_NAME).exe
|
||||
cd build; zip -r ../$(BINARY_NAME)_$(GITREV)_windows_amd64.zip ./$(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME)
|
||||
rm $(BUILDPATH)/$(BINARY_NAME).exe
|
||||
$(MAKE) build-docker
|
||||
$(MAKE) release-docker
|
||||
|
||||
|
@ -130,36 +142,14 @@ release-docker :
|
|||
|
||||
ui : force_look
|
||||
cd less/; $(MAKE) $(MFLAGS)
|
||||
|
||||
assets : generate
|
||||
go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql
|
||||
|
||||
assets-no-sqlite: generate
|
||||
go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql
|
||||
|
||||
dev-assets : generate
|
||||
go-bindata -pkg writefreely -ignore=\\.gitignore -debug -tags="!wflib" schema.sql sqlite.sql
|
||||
|
||||
lib-assets : generate
|
||||
go-bindata -pkg writefreely -ignore=\\.gitignore -o bindata-lib.go -tags="wflib" schema.sql
|
||||
|
||||
generate :
|
||||
@hash go-bindata > /dev/null 2>&1; if [ $$? -ne 0 ]; then \
|
||||
$(GOGET) -u github.com/jteeuwen/go-bindata/go-bindata; \
|
||||
fi
|
||||
cd prose/; $(MAKE) $(MFLAGS)
|
||||
|
||||
$(TMPBIN):
|
||||
mkdir -p $(TMPBIN)
|
||||
|
||||
$(TMPBIN)/go-bindata: deps $(TMPBIN)
|
||||
$(GOBUILD) -o $(TMPBIN)/go-bindata github.com/jteeuwen/go-bindata/go-bindata
|
||||
|
||||
$(TMPBIN)/xgo: deps $(TMPBIN)
|
||||
$(GOBUILD) -o $(TMPBIN)/xgo src.techknowlogick.com/xgo
|
||||
|
||||
ci-assets : $(TMPBIN)/go-bindata
|
||||
$(TMPBIN)/go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql
|
||||
|
||||
clean :
|
||||
-rm -rf build
|
||||
-rm -rf tmp
|
||||
|
|
84
README.md
84
README.md
|
@ -4,17 +4,17 @@
|
|||
</p>
|
||||
<hr />
|
||||
<p align="center">
|
||||
<a href="https://github.com/writeas/writefreely/releases/">
|
||||
<img src="https://img.shields.io/github/release/writeas/writefreely.svg" alt="Latest release" />
|
||||
</a>
|
||||
<a href="https://goreportcard.com/report/github.com/writeas/writefreely">
|
||||
<img src="https://goreportcard.com/badge/github.com/writeas/writefreely" alt="Go Report Card" />
|
||||
<a href="https://github.com/writefreely/writefreely/releases/">
|
||||
<img src="https://img.shields.io/github/release/writefreely/writefreely.svg" alt="Latest release" />
|
||||
</a>
|
||||
<a href="https://travis-ci.org/writeas/writefreely">
|
||||
<img src="https://travis-ci.org/writeas/writefreely.svg" alt="Build status" />
|
||||
<img src="https://travis-ci.org/writefreely/writefreely.svg" alt="Build status" />
|
||||
</a>
|
||||
<a href="https://github.com/writeas/writefreely/releases/latest">
|
||||
<img src="https://img.shields.io/github/downloads/writeas/writefreely/total.svg" />
|
||||
<a href="https://github.com/writefreely/writefreely/releases/latest">
|
||||
<img src="https://img.shields.io/github/downloads/writefreely/writefreely/total.svg" />
|
||||
</a>
|
||||
<a href="https://goreportcard.com/report/github.com/writefreely/writefreely">
|
||||
<img src="https://goreportcard.com/badge/github.com/writefreely/writefreely" alt="Go Report Card" />
|
||||
</a>
|
||||
<a href="https://hub.docker.com/r/writeas/writefreely/">
|
||||
<img src="https://img.shields.io/docker/pulls/writeas/writefreely.svg" />
|
||||
|
@ -22,73 +22,69 @@
|
|||
</p>
|
||||
|
||||
|
||||
WriteFreely is a beautifully pared-down blogging platform that's simple on the surface, yet powerful underneath.
|
||||
WriteFreely is a clean, minimalist publishing platform made for writers. Start a blog, share knowledge within your organization, or build a community around the shared act of writing.
|
||||
|
||||
It's designed to be flexible and share your writing widely, so it's built around plain text and can publish to the _fediverse_ via ActivityPub. It's easy to install and light enough to run on a Raspberry Pi.
|
||||

|
||||
|
||||
[Try the editor](https://write.as/new)
|
||||
[Try the writing experience](https://write.as/new)
|
||||
|
||||
[Find an instance](https://writefreely.org/instances)
|
||||
|
||||
## Features
|
||||
|
||||
* Start a blog for yourself, or host a community of writers
|
||||
* Form larger federated networks, and interact over modern protocols like ActivityPub
|
||||
* Write on a fast, dead-simple, and distraction-free editor
|
||||
* [Format text](https://howto.write.as/getting-started) with Markdown
|
||||
* [Organize posts](https://howto.write.as/organization) with hashtags
|
||||
* Create [static pages](https://howto.write.as/creating-a-static-page)
|
||||
* Publish drafts and let others proofread them by sharing a private link
|
||||
* Create multiple lightweight blogs under a single account
|
||||
* Export all data in plain text files
|
||||
* Read a stream of other posts in your writing community
|
||||
* Build more advanced apps and extensions with the [well-documented API](https://developers.write.as/docs/api/)
|
||||
* Designed around user privacy and consent
|
||||
### Made for writing
|
||||
|
||||
## Hosting
|
||||
Built on a plain, auto-saving editor, WriteFreely gives you a distraction-free writing environment. Once published, your words are front and center, and easy to read.
|
||||
|
||||
We offer two kinds of hosting services that make WriteFreely deployment painless: [Write.as Pro](https://write.as/pro) for individuals, and [Write.as for Teams](https://write.as/for/teams) for businesses. Besides saving you time and effort, both services directly fund WriteFreely development and ensure the long-term sustainability of our open source work.
|
||||
### A connected community
|
||||
|
||||
### [](https://write.as/pro)
|
||||
Start writing together, publicly or privately. Connect with other communities, whether running WriteFreely, [Plume](https://joinplu.me/), or other ActivityPub-powered software. And bring members on board from your existing platforms, thanks to our OAuth 2.0 support.
|
||||
|
||||
Start a personal blog on [Write.as](https://write.as), our flagship instance. Built to eliminate setup friction and preserve your privacy, Write.as helps you start a blog in seconds. It supports custom domains (with SSL) and multiple blogs / pen names per account. [Read more here](https://write.as/pro).
|
||||
### Intuitive organization
|
||||
|
||||
### [](https://write.as/for/teams)
|
||||
Categorize articles [with hashtags](https://writefreely.org/docs/latest/writer/hashtags), and create static pages from normal posts by [_pinning_ them](https://writefreely.org/docs/latest/writer/static) to your blog. Create draft posts and publish to multiple blogs from one account.
|
||||
|
||||
[Write.as for Teams](https://write.as/for/teams) gives your organization, business, or [open source project](https://write.as/for/open-source) a clutter-free space to share updates or proposals and build your collective knowledge. We take care of hosting, upgrades, backups, and maintenance so your team can focus on writing.
|
||||
### International
|
||||
|
||||
Blog elements are localized in 20+ languages, and WriteFreely includes first-class support for non-Latin and right-to-left (RTL) script languages.
|
||||
|
||||
### Private by default
|
||||
|
||||
WriteFreely collects minimal data, and never publicizes more than a writer consents to. Writers can seamlessly create multiple blogs from a single account for different pen names or purposes without publicly revealing their association.
|
||||
|
||||
<h2><a href="https://write.as/writefreely"><img src="https://writefreely.org/img/writeas-readme.png" height="32px" alt="Write.as" /></a></h2>
|
||||
|
||||
The quickest way to deploy WriteFreely is with [Write.as](https://write.as/writefreely), a hosted service from the team behind WriteFreely. You'll get fully-managed installation, backup, upgrades, and maintenance — and directly fund our free software work ❤️
|
||||
|
||||
[**Learn more on Write.as**](https://write.as/writefreely).
|
||||
|
||||
## Quick start
|
||||
|
||||
WriteFreely has minimal requirements to get up and running — you only need to be able to run an executable.
|
||||
WriteFreely deploys as a static binary on any platform and architecture that Go supports. Just use our built-in SQLite support, or add a MySQL database, and you'll be up and running!
|
||||
|
||||
> **Note** this is currently alpha software. We're quickly moving out of this v0.x stage, but while we're in it, there are no guarantees that this is ready for production use.
|
||||
For common platforms, start with our [pre-built binaries](https://github.com/writefreely/writefreely/releases/) and head over to our [installation guide](https://writefreely.org/start) to get started.
|
||||
|
||||
To get started, head over to our [Getting Started guide](https://writefreely.org/start). For production use, jump to the [Running in Production](https://writefreely.org/start#production) section.
|
||||
### Packages
|
||||
|
||||
## Packages
|
||||
|
||||
WriteFreely is available in these package repositories:
|
||||
You can also find WriteFreely in these package repositories, thanks to our wonderful community!
|
||||
|
||||
* [Arch User Repository](https://aur.archlinux.org/packages/writefreely/)
|
||||
* [Nanos Repository](https://repo.ops.city/v2/packages/eyberg/writefreely/show)
|
||||
|
||||
## Documentation
|
||||
|
||||
Read our full [documentation on WriteFreely.org](https://writefreely.org/docs). Help us improve by contributing to the [writefreely/documentation](https://github.com/writefreely/documentation) repo.
|
||||
Read our full [documentation on WriteFreely.org](https://writefreely.org/docs) —️ and help us improve by contributing to the [writefreely/documentation](https://github.com/writefreely/documentation) repo.
|
||||
|
||||
## Development
|
||||
|
||||
Ready to hack on your site? Get started with our [developer guide](https://writefreely.org/docs/latest/developer/setup).
|
||||
|
||||
## Docker
|
||||
|
||||
Read about using Docker in the [documentation](https://writefreely.org/docs/latest/admin/docker).
|
||||
Start hacking on WriteFreely with our [developer setup guide](https://writefreely.org/docs/latest/developer/setup). For Docker support, see our [Docker guide](https://writefreely.org/docs/latest/admin/docker).
|
||||
|
||||
## Contributing
|
||||
|
||||
We gladly welcome contributions to WriteFreely, whether in the form of [code](https://github.com/writeas/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely), [bug reports](https://github.com/writeas/writefreely/issues/new?template=bug_report.md), [feature requests](https://discuss.write.as/c/feedback/feature-requests), [translations](https://poeditor.com/join/project/TIZ6HFRFdE), or [documentation](https://github.com/writefreely/documentation) improvements.
|
||||
We gladly welcome contributions to WriteFreely, whether in the form of [code](https://github.com/writefreely/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely), [bug reports](https://github.com/writefreely/writefreely/issues/new?template=bug_report.md), [feature requests](https://discuss.write.as/c/feedback/feature-requests), [translations](https://poeditor.com/join/project/TIZ6HFRFdE), or [documentation](https://github.com/writefreely/documentation) improvements.
|
||||
|
||||
Before contributing anything, please read our [Contributing Guide](https://github.com/writeas/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely). It describes the correct channels for submitting contributions and any potential requirements.
|
||||
Before contributing anything, please read our [Contributing Guide](https://github.com/writefreely/writefreely/blob/master/CONTRIBUTING.md#contributing-to-writefreely). It describes the correct channels for submitting contributions and any potential requirements.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the AGPL.
|
||||
Copyright © 2018-2025 [Musing Studio LLC](https://musing.studio) and contributing authors. Licensed under the [AGPL](https://github.com/writefreely/writefreely/blob/develop/LICENSE).
|
||||
|
|
5
SECURITY.md
Normal file
5
SECURITY.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
To report a vulnerability, send an email to security@writefreely.org.
|
497
account.go
497
account.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -13,13 +13,17 @@ package writefreely
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/writefreely/writefreely/mailer"
|
||||
"github.com/writefreely/writefreely/spam"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/guregu/null/zero"
|
||||
|
@ -27,9 +31,9 @@ import (
|
|||
"github.com/writeas/web-core/auth"
|
||||
"github.com/writeas/web-core/data"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/author"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writeas/writefreely/page"
|
||||
"github.com/writefreely/writefreely/author"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
"github.com/writefreely/writefreely/page"
|
||||
)
|
||||
|
||||
type (
|
||||
|
@ -48,6 +52,7 @@ type (
|
|||
Separator template.HTML
|
||||
IsAdmin bool
|
||||
CanInvite bool
|
||||
CollAlias string
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -85,6 +90,11 @@ func apiSignup(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
}
|
||||
|
||||
func signup(app *App, w http.ResponseWriter, r *http.Request) (*AuthUser, error) {
|
||||
if app.cfg.App.DisablePasswordAuth {
|
||||
err := ErrDisabledPasswordAuth
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reqJSON := IsJSON(r)
|
||||
|
||||
// Get params
|
||||
|
@ -144,8 +154,6 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
|
|||
}
|
||||
|
||||
// Handle empty optional params
|
||||
// TODO: remove this var
|
||||
createdWithPass := true
|
||||
hashedPass, err := auth.HashPass([]byte(signup.Pass))
|
||||
if err != nil {
|
||||
return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
|
||||
|
@ -155,23 +163,19 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
|
|||
u := &User{
|
||||
Username: signup.Alias,
|
||||
HashedPass: hashedPass,
|
||||
HasPass: createdWithPass,
|
||||
HasPass: true,
|
||||
Email: prepareUserEmail(signup.Email, app.keys.EmailKey),
|
||||
Created: time.Now().Truncate(time.Second).UTC(),
|
||||
}
|
||||
|
||||
// Create actual user
|
||||
if err := app.db.CreateUser(app.cfg, u, desiredUsername); err != nil {
|
||||
if err := app.db.CreateUser(app.cfg, u, desiredUsername, signup.Description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Log invite if needed
|
||||
if signup.InviteCode != "" {
|
||||
cu, err := app.db.GetUserForAuth(signup.Alias)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = app.db.CreateInvitedUser(signup.InviteCode, cu.ID)
|
||||
err = app.db.CreateInvitedUser(signup.InviteCode, u.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -185,9 +189,6 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
|
|||
resUser := &AuthUser{
|
||||
User: u,
|
||||
}
|
||||
if !createdWithPass {
|
||||
resUser.Password = signup.Pass
|
||||
}
|
||||
title := signup.Alias
|
||||
if signup.Normalize {
|
||||
title = desiredUsername
|
||||
|
@ -196,9 +197,27 @@ func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWr
|
|||
{
|
||||
Alias: signup.Alias,
|
||||
Title: title,
|
||||
Description: signup.Description,
|
||||
},
|
||||
}
|
||||
|
||||
var coll *Collection
|
||||
if signup.Monetization != "" {
|
||||
if coll == nil {
|
||||
coll, err = app.db.GetCollection(signup.Alias)
|
||||
if err != nil {
|
||||
log.Error("Unable to get new collection '%s' for monetization on signup: %v", signup.Alias, err)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
err = app.db.SetCollectionAttribute(coll.ID, "monetization_pointer", signup.Monetization)
|
||||
if err != nil {
|
||||
log.Error("Unable to add monetization on signup: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
coll.Monetization = signup.Monetization
|
||||
}
|
||||
|
||||
var token string
|
||||
if reqJSON && !signup.Web {
|
||||
token, err = app.db.GetAccessToken(u.ID)
|
||||
|
@ -302,20 +321,20 @@ func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
|
||||
p := &struct {
|
||||
page.StaticPage
|
||||
*OAuthButtons
|
||||
To string
|
||||
Message template.HTML
|
||||
Flashes []template.HTML
|
||||
EmailEnabled bool
|
||||
LoginUsername string
|
||||
OauthSlack bool
|
||||
OauthWriteAs bool
|
||||
}{
|
||||
pageForReq(app, r),
|
||||
r.FormValue("to"),
|
||||
template.HTML(""),
|
||||
[]template.HTML{},
|
||||
getTempInfo(app, "login-user", r, w),
|
||||
app.Config().SlackOauth.ClientID != "",
|
||||
app.Config().WriteAsOauth.ClientID != "",
|
||||
StaticPage: pageForReq(app, r),
|
||||
OAuthButtons: NewOAuthButtons(app.Config()),
|
||||
To: r.FormValue("to"),
|
||||
Message: template.HTML(""),
|
||||
Flashes: []template.HTML{},
|
||||
EmailEnabled: app.cfg.Email.Enabled(),
|
||||
LoginUsername: getTempInfo(app, "login-user", r, w),
|
||||
}
|
||||
|
||||
if earlyError != "" {
|
||||
|
@ -390,6 +409,11 @@ func login(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
var err error
|
||||
var signin userCredentials
|
||||
|
||||
if app.cfg.App.DisablePasswordAuth {
|
||||
err := ErrDisabledPasswordAuth
|
||||
return err
|
||||
}
|
||||
|
||||
// Log in with one-time token if one is given
|
||||
if oneTimeToken != "" {
|
||||
log.Info("Login: Logging user in via token.")
|
||||
|
@ -488,6 +512,9 @@ func login(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
return impart.HTTPError{http.StatusPreconditionFailed, "This user never added a password or email address. Please contact us for help."}
|
||||
}
|
||||
}
|
||||
if len(u.HashedPass) == 0 {
|
||||
return impart.HTTPError{http.StatusUnauthorized, "This user never set a password. Perhaps try logging in via OAuth?"}
|
||||
}
|
||||
if !auth.Authenticated(u.HashedPass, []byte(signin.Pass)) {
|
||||
return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
|
||||
}
|
||||
|
@ -554,7 +581,7 @@ func getVerboseAuthUser(app *App, token string, u *User, verbose bool) *AuthUser
|
|||
}
|
||||
passIsSet, err := app.db.IsUserPassSet(u.ID)
|
||||
if err != nil {
|
||||
// TODO: correct error meesage
|
||||
// TODO: correct error message
|
||||
log.Error("Login: Unable to get user collections: %v", err)
|
||||
}
|
||||
|
||||
|
@ -687,6 +714,22 @@ func viewMyPostsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) e
|
|||
return ErrBadRequestedType
|
||||
}
|
||||
|
||||
isAnonPosts := r.FormValue("anonymous") == "1"
|
||||
if isAnonPosts {
|
||||
pageStr := r.FormValue("page")
|
||||
pg, err := strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
log.Error("Error parsing page parameter '%s': %s", pageStr, err)
|
||||
pg = 1
|
||||
}
|
||||
|
||||
p, err := app.db.GetAnonymousPosts(u, pg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return impart.WriteSuccess(w, p, http.StatusOK)
|
||||
}
|
||||
|
||||
var err error
|
||||
p := GetPostsCache(u.ID)
|
||||
if p == nil {
|
||||
|
@ -727,7 +770,7 @@ func viewMyCollectionsAPI(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
|||
}
|
||||
|
||||
func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
p, err := app.db.GetAnonymousPosts(u)
|
||||
p, err := app.db.GetAnonymousPosts(u, 1)
|
||||
if err != nil {
|
||||
log.Error("unable to fetch anon posts: %v", err)
|
||||
}
|
||||
|
@ -748,6 +791,9 @@ func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) err
|
|||
|
||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||
if err != nil {
|
||||
if err == ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
log.Error("view articles: %v", err)
|
||||
}
|
||||
d := struct {
|
||||
|
@ -783,7 +829,10 @@ func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request)
|
|||
|
||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||
if err != nil {
|
||||
log.Error("view collections %v", err)
|
||||
if err == ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
log.Error("view collections: %v", err)
|
||||
return fmt.Errorf("view collections: %v", err)
|
||||
}
|
||||
d := struct {
|
||||
|
@ -819,6 +868,9 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
|
|||
|
||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||
if err != nil {
|
||||
if err == ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
log.Error("view edit collection %v", err)
|
||||
return fmt.Errorf("view edit collection: %v", err)
|
||||
}
|
||||
|
@ -827,10 +879,18 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
|
|||
*UserPage
|
||||
*Collection
|
||||
Silenced bool
|
||||
|
||||
config.EmailCfg
|
||||
LetterReplyTo string
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
|
||||
Collection: c,
|
||||
Silenced: silenced,
|
||||
EmailCfg: app.cfg.Email,
|
||||
}
|
||||
obj.UserPage.CollAlias = c.Alias
|
||||
if obj.EmailCfg.Enabled() {
|
||||
obj.LetterReplyTo = app.db.GetCollectionAttribute(c.ID, collAttrLetterReplyTo)
|
||||
}
|
||||
|
||||
showUserPage(w, "collection", obj)
|
||||
|
@ -978,9 +1038,10 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
|||
if c.OwnerID != u.ID {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
}
|
||||
|
||||
topPosts, err := app.db.GetTopPosts(u, alias)
|
||||
topPosts, err := app.db.GetTopPosts(u, alias, c.hostName)
|
||||
if err != nil {
|
||||
log.Error("Unable to get top posts: %v", err)
|
||||
return err
|
||||
|
@ -994,6 +1055,9 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
|||
|
||||
silenced, err := app.db.IsUserSilenced(u.ID)
|
||||
if err != nil {
|
||||
if err == ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
log.Error("view stats: %v", err)
|
||||
return err
|
||||
}
|
||||
|
@ -1003,14 +1067,18 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
|||
Collection *Collection
|
||||
TopPosts *[]PublicPost
|
||||
APFollowers int
|
||||
EmailEnabled bool
|
||||
EmailSubscribers int
|
||||
Silenced bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, titleStats+"Stats", flashes),
|
||||
VisitsBlog: alias,
|
||||
Collection: c,
|
||||
TopPosts: topPosts,
|
||||
EmailEnabled: app.cfg.Email.Enabled(),
|
||||
Silenced: silenced,
|
||||
}
|
||||
obj.UserPage.CollAlias = c.Alias
|
||||
if app.cfg.App.Federation {
|
||||
folls, err := app.db.GetAPFollowers(c)
|
||||
if err != nil {
|
||||
|
@ -1018,14 +1086,79 @@ func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
|||
}
|
||||
obj.APFollowers = len(*folls)
|
||||
}
|
||||
if obj.EmailEnabled {
|
||||
subs, err := app.db.GetEmailSubscribers(c.ID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
obj.EmailSubscribers = len(subs)
|
||||
}
|
||||
|
||||
showUserPage(w, "stats", obj)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleViewSubscribers(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
c, err := app.db.GetCollection(vars["collection"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filter := r.FormValue("filter")
|
||||
|
||||
flashes, _ := getSessionFlashes(app, w, r, nil)
|
||||
obj := struct {
|
||||
*UserPage
|
||||
Collection CollectionNav
|
||||
EmailSubs []*EmailSubscriber
|
||||
Followers *[]RemoteUser
|
||||
Silenced bool
|
||||
|
||||
Filter string
|
||||
FederationEnabled bool
|
||||
CanEmailSub bool
|
||||
CanAddSubs bool
|
||||
EmailSubsEnabled bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, c.DisplayTitle()+" Subscribers", flashes),
|
||||
Collection: CollectionNav{
|
||||
Collection: c,
|
||||
Path: r.URL.Path,
|
||||
SingleUser: app.cfg.App.SingleUser,
|
||||
},
|
||||
Silenced: u.IsSilenced(),
|
||||
Filter: filter,
|
||||
FederationEnabled: app.cfg.App.Federation,
|
||||
CanEmailSub: app.cfg.Email.Enabled(),
|
||||
EmailSubsEnabled: c.EmailSubsEnabled(),
|
||||
}
|
||||
|
||||
obj.Followers, err = app.db.GetAPFollowers(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
obj.EmailSubs, err = app.db.GetEmailSubscribers(c.ID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if obj.Filter == "" {
|
||||
// Set permission to add email subscribers
|
||||
//obj.CanAddSubs = app.db.GetUserAttribute(c.OwnerID, userAttrCanAddEmailSubs) == "1"
|
||||
}
|
||||
|
||||
showUserPage(w, "subscribers", obj)
|
||||
return nil
|
||||
}
|
||||
|
||||
func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
fullUser, err := app.db.GetUserByID(u.ID)
|
||||
if err != nil {
|
||||
if err == ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
log.Error("Unable to get user for settings: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
|
||||
}
|
||||
|
@ -1038,24 +1171,290 @@ func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) err
|
|||
|
||||
flashes, _ := getSessionFlashes(app, w, r, nil)
|
||||
|
||||
enableOauthSlack := app.Config().SlackOauth.ClientID != ""
|
||||
enableOauthWriteAs := app.Config().WriteAsOauth.ClientID != ""
|
||||
enableOauthGitLab := app.Config().GitlabOauth.ClientID != ""
|
||||
enableOauthGeneric := app.Config().GenericOauth.ClientID != ""
|
||||
enableOauthGitea := app.Config().GiteaOauth.ClientID != ""
|
||||
|
||||
oauthAccounts, err := app.db.GetOauthAccounts(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
log.Error("Unable to get oauth accounts for settings: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
|
||||
}
|
||||
for idx, oauthAccount := range oauthAccounts {
|
||||
switch oauthAccount.Provider {
|
||||
case "slack":
|
||||
enableOauthSlack = false
|
||||
case "write.as":
|
||||
enableOauthWriteAs = false
|
||||
case "gitlab":
|
||||
enableOauthGitLab = false
|
||||
case "generic":
|
||||
oauthAccounts[idx].DisplayName = app.Config().GenericOauth.DisplayName
|
||||
oauthAccounts[idx].AllowDisconnect = app.Config().GenericOauth.AllowDisconnect
|
||||
enableOauthGeneric = false
|
||||
case "gitea":
|
||||
enableOauthGitea = false
|
||||
}
|
||||
}
|
||||
|
||||
displayOauthSection := enableOauthSlack || enableOauthWriteAs || enableOauthGitLab || enableOauthGeneric || enableOauthGitea || len(oauthAccounts) > 0
|
||||
|
||||
obj := struct {
|
||||
*UserPage
|
||||
Email string
|
||||
HasPass bool
|
||||
IsLogOut bool
|
||||
Silenced bool
|
||||
CSRFField template.HTML
|
||||
OauthSection bool
|
||||
OauthAccounts []oauthAccountInfo
|
||||
OauthSlack bool
|
||||
OauthWriteAs bool
|
||||
OauthGitLab bool
|
||||
GitLabDisplayName string
|
||||
OauthGeneric bool
|
||||
OauthGenericDisplayName string
|
||||
OauthGitea bool
|
||||
GiteaDisplayName string
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Account Settings", flashes),
|
||||
Email: fullUser.EmailClear(app.keys),
|
||||
HasPass: passIsSet,
|
||||
IsLogOut: r.FormValue("logout") == "1",
|
||||
Silenced: fullUser.IsSilenced(),
|
||||
CSRFField: csrf.TemplateField(r),
|
||||
OauthSection: displayOauthSection,
|
||||
OauthAccounts: oauthAccounts,
|
||||
OauthSlack: enableOauthSlack,
|
||||
OauthWriteAs: enableOauthWriteAs,
|
||||
OauthGitLab: enableOauthGitLab,
|
||||
GitLabDisplayName: config.OrDefaultString(app.Config().GitlabOauth.DisplayName, gitlabDisplayName),
|
||||
OauthGeneric: enableOauthGeneric,
|
||||
OauthGenericDisplayName: config.OrDefaultString(app.Config().GenericOauth.DisplayName, genericOauthDisplayName),
|
||||
OauthGitea: enableOauthGitea,
|
||||
GiteaDisplayName: config.OrDefaultString(app.Config().GiteaOauth.DisplayName, giteaDisplayName),
|
||||
}
|
||||
|
||||
showUserPage(w, "settings", obj)
|
||||
return nil
|
||||
}
|
||||
|
||||
func viewResetPassword(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
token := r.FormValue("t")
|
||||
resetting := false
|
||||
var userID int64 = 0
|
||||
if token != "" {
|
||||
// Show new password page
|
||||
userID = app.db.GetUserFromPasswordReset(token)
|
||||
if userID == 0 {
|
||||
return impart.HTTPError{http.StatusNotFound, ""}
|
||||
}
|
||||
resetting = true
|
||||
}
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
newPass := r.FormValue("new-pass")
|
||||
if newPass == "" {
|
||||
// Send password reset email
|
||||
return handleResetPasswordInit(app, w, r)
|
||||
}
|
||||
|
||||
// Do actual password reset
|
||||
// Assumes token has been validated above
|
||||
err := doAutomatedPasswordChange(app, userID, newPass)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = app.db.ConsumePasswordResetToken(token)
|
||||
if err != nil {
|
||||
log.Error("Couldn't consume token %s for user %d!!! %s", token, userID, err)
|
||||
}
|
||||
addSessionFlash(app, w, r, "Your password was reset. Now you can log in below.", nil)
|
||||
return impart.HTTPError{http.StatusFound, "/login"}
|
||||
}
|
||||
|
||||
f, _ := getSessionFlashes(app, w, r, nil)
|
||||
|
||||
// Show reset password page
|
||||
d := struct {
|
||||
page.StaticPage
|
||||
Flashes []string
|
||||
EmailEnabled bool
|
||||
CSRFField template.HTML
|
||||
Token string
|
||||
IsResetting bool
|
||||
IsSent bool
|
||||
}{
|
||||
StaticPage: pageForReq(app, r),
|
||||
Flashes: f,
|
||||
EmailEnabled: app.cfg.Email.Enabled(),
|
||||
CSRFField: csrf.TemplateField(r),
|
||||
Token: token,
|
||||
IsResetting: resetting,
|
||||
IsSent: r.FormValue("sent") == "1",
|
||||
}
|
||||
err := pages["reset.tmpl"].ExecuteTemplate(w, "base", d)
|
||||
if err != nil {
|
||||
log.Error("Unable to render password reset page: %v", err)
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func doAutomatedPasswordChange(app *App, userID int64, newPass string) error {
|
||||
// Do password reset
|
||||
hashedPass, err := auth.HashPass([]byte(newPass))
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
|
||||
}
|
||||
|
||||
// Do update
|
||||
err = app.db.ChangePassphrase(userID, true, "", hashedPass)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleResetPasswordInit(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
returnLoc := impart.HTTPError{http.StatusFound, "/reset"}
|
||||
|
||||
if !app.cfg.Email.Enabled() {
|
||||
// Email isn't configured, so there's nothing to do; send back to the reset form, where they'll get an explanation
|
||||
return returnLoc
|
||||
}
|
||||
|
||||
ip := spam.GetIP(r)
|
||||
alias := r.FormValue("alias")
|
||||
|
||||
u, err := app.db.GetUserForAuth(alias)
|
||||
if err != nil {
|
||||
if strings.IndexAny(alias, "@") > 0 {
|
||||
addSessionFlash(app, w, r, ErrUserNotFoundEmail.Message, nil)
|
||||
return returnLoc
|
||||
}
|
||||
addSessionFlash(app, w, r, ErrUserNotFound.Message, nil)
|
||||
return returnLoc
|
||||
}
|
||||
if u.IsAdmin() {
|
||||
// Prevent any reset emails on admin accounts
|
||||
log.Error("Admin reset attempt", `Someone just tried to reset the password for an admin (ID %d - %s). IP address: %s`, u.ID, u.Username, ip)
|
||||
return returnLoc
|
||||
}
|
||||
if u.Email.String == "" {
|
||||
err := impart.HTTPError{http.StatusPreconditionFailed, "User doesn't have an email address. Please contact us (" + app.cfg.App.Host + "/contact) to reset your password."}
|
||||
addSessionFlash(app, w, r, err.Message, nil)
|
||||
return returnLoc
|
||||
}
|
||||
if isSet, _ := app.db.IsUserPassSet(u.ID); !isSet {
|
||||
err = loginViaEmail(app, u.Username, "/me/settings")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addSessionFlash(app, w, r, "We've emailed you a link to log in with.", nil)
|
||||
return returnLoc
|
||||
}
|
||||
|
||||
token, err := app.db.CreatePasswordResetToken(u.ID)
|
||||
if err != nil {
|
||||
log.Error("Error resetting password: %s", err)
|
||||
addSessionFlash(app, w, r, ErrInternalGeneral.Message, nil)
|
||||
return returnLoc
|
||||
}
|
||||
|
||||
err = emailPasswordReset(app, u.EmailClear(app.keys), token)
|
||||
if err != nil {
|
||||
log.Error("Error emailing password reset: %s", err)
|
||||
addSessionFlash(app, w, r, ErrInternalGeneral.Message, nil)
|
||||
return returnLoc
|
||||
}
|
||||
|
||||
addSessionFlash(app, w, r, "We sent an email to the address associated with this account.", nil)
|
||||
returnLoc.Message += "?sent=1"
|
||||
return returnLoc
|
||||
}
|
||||
|
||||
func emailPasswordReset(app *App, toEmail, token string) error {
|
||||
// Send email
|
||||
mlr, err := mailer.New(app.cfg.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
footerPara := "Didn't request this password reset? Your account is still safe, and you can safely ignore this email."
|
||||
|
||||
plainMsg := fmt.Sprintf("We received a request to reset your password on %s. Please click the following link to continue (or copy and paste it into your browser): %s/reset?t=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara)
|
||||
m, err := mlr.NewMessage(app.cfg.App.SiteName+" <noreply-password@"+app.cfg.Email.Domain+">", "Reset Your "+app.cfg.App.SiteName+" Password", plainMsg, fmt.Sprintf("<%s>", toEmail))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.AddTag("Password Reset")
|
||||
m.SetHTML(fmt.Sprintf(`<html>
|
||||
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
|
||||
<div style="margin:0 auto; max-width: 40em; font-size: 1.2em;">
|
||||
<h1 style="font-size:1.75em"><a style="text-decoration:none;color:#000;" href="%s">%s</a></h1>
|
||||
<p>We received a request to reset your password on %s. Please click the following link to continue:</p>
|
||||
<p style="font-size:1.2em;margin-bottom:1.5em;"><a href="%s/reset?t=%s">Reset your password</a></p>
|
||||
<p style="font-size: 0.86em;margin:1em auto">%s</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.SiteName, app.cfg.App.Host, token, footerPara))
|
||||
return mlr.Send(m)
|
||||
}
|
||||
|
||||
func loginViaEmail(app *App, alias, redirectTo string) error {
|
||||
if !app.cfg.Email.Enabled() {
|
||||
return fmt.Errorf("EMAIL ISN'T CONFIGURED on this server")
|
||||
}
|
||||
|
||||
// Make sure user has added an email
|
||||
// TODO: create a new func to just get user's email; "ForAuth" doesn't match here
|
||||
u, _ := app.db.GetUserForAuth(alias)
|
||||
if u == nil {
|
||||
if strings.IndexAny(alias, "@") > 0 {
|
||||
return ErrUserNotFoundEmail
|
||||
}
|
||||
return ErrUserNotFound
|
||||
}
|
||||
if u.Email.String == "" {
|
||||
return impart.HTTPError{http.StatusPreconditionFailed, "User doesn't have an email address. Log in with password, instead."}
|
||||
}
|
||||
|
||||
// Generate one-time login token
|
||||
t, err := app.db.GetTemporaryOneTimeAccessToken(u.ID, 60*15, true)
|
||||
if err != nil {
|
||||
log.Error("Unable to generate token for email login: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "Unable to generate token."}
|
||||
}
|
||||
|
||||
// Send email
|
||||
mlr, err := mailer.New(app.cfg.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toEmail := u.EmailClear(app.keys)
|
||||
footerPara := "This link will only work once and expires in 15 minutes. Didn't ask us to log in? You can safely ignore this email."
|
||||
|
||||
plainMsg := fmt.Sprintf("Log in to %s here: %s/login?to=%s&with=%s\n\n%s", app.cfg.App.SiteName, app.cfg.App.Host, redirectTo, t, footerPara)
|
||||
m, err := mlr.NewMessage(app.cfg.App.SiteName+" <noreply-login@"+app.cfg.Email.Domain+">", "Log in to "+app.cfg.App.SiteName, plainMsg, fmt.Sprintf("<%s>", toEmail))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.AddTag("Email Login")
|
||||
|
||||
m.SetHTML(fmt.Sprintf(`<html>
|
||||
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
|
||||
<div style="margin:0 auto; max-width: 40em; font-size: 1.2em;">
|
||||
<h1 style="font-size:1.75em"><a style="text-decoration:none;color:#000;" href="%s">%s</a></h1>
|
||||
<p style="font-size:1.2em;margin-bottom:1.5em;text-align:center"><a href="%s/login?to=%s&with=%s">Log in to %s here</a>.</p>
|
||||
<p style="font-size: 0.86em;color:#666;text-align:center;max-width:35em;margin:1em auto">%s</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, app.cfg.App.Host, app.cfg.App.SiteName, app.cfg.App.Host, redirectTo, t, app.cfg.App.SiteName, footerPara))
|
||||
return mlr.Send(m)
|
||||
}
|
||||
|
||||
func saveTempInfo(app *App, key, val string, r *http.Request, w http.ResponseWriter) error {
|
||||
session, err := app.sessionStore.Get(r, "t")
|
||||
if err != nil {
|
||||
|
@ -1094,6 +1493,45 @@ func getTempInfo(app *App, key string, r *http.Request, w http.ResponseWriter) s
|
|||
return s
|
||||
}
|
||||
|
||||
func handleUserDelete(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
if !app.cfg.App.OpenDeletion {
|
||||
return impart.HTTPError{http.StatusForbidden, "Open account deletion is disabled on this instance."}
|
||||
}
|
||||
|
||||
confirmUsername := r.PostFormValue("confirm-username")
|
||||
if u.Username != confirmUsername {
|
||||
return impart.HTTPError{http.StatusBadRequest, "Confirmation username must match your username exactly."}
|
||||
}
|
||||
|
||||
// Check for account deletion safeguards in place
|
||||
if u.IsAdmin() {
|
||||
return impart.HTTPError{http.StatusForbidden, "Cannot delete admin."}
|
||||
}
|
||||
|
||||
err := app.db.DeleteAccount(u.ID)
|
||||
if err != nil {
|
||||
log.Error("user delete account: %v", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not delete account: %v", err)}
|
||||
}
|
||||
|
||||
// FIXME: This doesn't ever appear to the user, as (I believe) the value is erased when the session cookie is reset
|
||||
_ = addSessionFlash(app, w, r, "Thanks for writing with us! You account was deleted successfully.", nil)
|
||||
return impart.HTTPError{http.StatusFound, "/me/logout"}
|
||||
}
|
||||
|
||||
func removeOauth(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
provider := r.FormValue("provider")
|
||||
clientID := r.FormValue("client_id")
|
||||
remoteUserID := r.FormValue("remote_user_id")
|
||||
|
||||
err := app.db.RemoveOauth(r.Context(), u.ID, provider, clientID, remoteUserID)
|
||||
if err != nil {
|
||||
return impart.HTTPError{Status: http.StatusInternalServerError, Message: err.Error()}
|
||||
}
|
||||
|
||||
return impart.HTTPError{Status: http.StatusFound, Message: "/me/settings"}
|
||||
}
|
||||
|
||||
func prepareUserEmail(input string, emailKey []byte) zero.String {
|
||||
email := zero.NewString("", input != "")
|
||||
if len(input) > 0 {
|
||||
|
@ -1102,6 +1540,7 @@ func prepareUserEmail(input string, emailKey []byte) zero.String {
|
|||
log.Error("Unable to encrypt email: %s\n", err)
|
||||
} else {
|
||||
email.String = string(encEmail)
|
||||
|
||||
}
|
||||
}
|
||||
return email
|
||||
|
|
|
@ -5,7 +5,6 @@ import (
|
|||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
@ -100,7 +99,7 @@ func handleImport(app *App, u *User, w http.ResponseWriter, r *http.Request) err
|
|||
}
|
||||
defer file.Close()
|
||||
|
||||
tempFile, err := ioutil.TempFile("", "post-upload-*.txt")
|
||||
tempFile, err := os.CreateTemp("", "post-upload-*.txt")
|
||||
if err != nil {
|
||||
fileErrs = append(fileErrs, fmt.Errorf("Internal error for %s", formFile.Filename))
|
||||
log.Error("import file: create temp file %s: %v", formFile.Filename, err)
|
||||
|
|
407
activitypub.go
407
activitypub.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -17,21 +17,26 @@ import (
|
|||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/writeas/activity/streams"
|
||||
"github.com/writeas/activityserve"
|
||||
"github.com/writeas/httpsig"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/nerds/store"
|
||||
"github.com/writeas/web-core/activitypub"
|
||||
"github.com/writeas/web-core/activitystreams"
|
||||
"github.com/writeas/web-core/id"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/web-core/silobridge"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -41,12 +46,45 @@ const (
|
|||
apCacheTime = time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
apCollectionPostIRIRegex = regexp.MustCompile("/api/collections/([a-z0-9\\-]+)/posts/([a-z0-9\\-]+)$")
|
||||
apDraftPostIRIRegex = regexp.MustCompile("/api/posts/([a-z0-9\\-]+)$")
|
||||
)
|
||||
|
||||
var instanceColl *Collection
|
||||
|
||||
func initActivityPub(app *App) {
|
||||
ur, _ := url.Parse(app.cfg.App.Host)
|
||||
instanceColl = &Collection{
|
||||
ID: 0,
|
||||
Alias: ur.Host,
|
||||
Title: ur.Host,
|
||||
db: app.db,
|
||||
hostName: app.cfg.App.Host,
|
||||
}
|
||||
}
|
||||
|
||||
type RemoteUser struct {
|
||||
ID int64
|
||||
ActorID string
|
||||
Inbox string
|
||||
SharedInbox string
|
||||
URL string
|
||||
Handle string
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
func (ru *RemoteUser) CreatedFriendly() string {
|
||||
return ru.Created.Format("January 2, 2006")
|
||||
}
|
||||
|
||||
func (ru *RemoteUser) EstimatedHandle() string {
|
||||
if ru.Handle != "" {
|
||||
return ru.Handle
|
||||
}
|
||||
username := filepath.Base(ru.ActorID)
|
||||
host, _ := url.Parse(ru.ActorID)
|
||||
return username + "@" + host.Host
|
||||
}
|
||||
|
||||
func (ru *RemoteUser) AsPerson() *activitystreams.Person {
|
||||
|
@ -76,12 +114,17 @@ func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Re
|
|||
|
||||
vars := mux.Vars(r)
|
||||
alias := vars["alias"]
|
||||
if alias == "" {
|
||||
alias = filepath.Base(r.RequestURI)
|
||||
}
|
||||
|
||||
// TODO: enforce visibility
|
||||
// Get base Collection data
|
||||
var c *Collection
|
||||
var err error
|
||||
if app.cfg.App.SingleUser {
|
||||
if alias == r.Host {
|
||||
c = instanceColl
|
||||
} else if app.cfg.App.SingleUser {
|
||||
c, err = app.db.GetCollectionByID(1)
|
||||
} else {
|
||||
c, err = app.db.GetCollection(alias)
|
||||
|
@ -89,6 +132,9 @@ func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Re
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
if !c.IsInstanceColl() {
|
||||
silenced, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if err != nil {
|
||||
log.Error("fetch collection activities: %v", err)
|
||||
|
@ -97,7 +143,7 @@ func handleFetchCollectionActivities(app *App, w http.ResponseWriter, r *http.Re
|
|||
if silenced {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
}
|
||||
|
||||
p := c.PersonObject()
|
||||
|
||||
|
@ -155,11 +201,12 @@ func handleFetchCollectionOutbox(app *App, w http.ResponseWriter, r *http.Reques
|
|||
ocp := activitystreams.NewOrderedCollectionPage(accountRoot, "outbox", res.TotalPosts, p)
|
||||
ocp.OrderedItems = []interface{}{}
|
||||
|
||||
posts, err := app.db.GetPosts(app.cfg, c, p, false, true, false)
|
||||
posts, err := app.db.GetPosts(app.cfg, c, p, false, true, false, "")
|
||||
for _, pp := range *posts {
|
||||
pp.Collection = res
|
||||
o := pp.ActivityObject(app)
|
||||
a := activitystreams.NewCreateActivity(o)
|
||||
a.Context = nil
|
||||
ocp.OrderedItems = append(ocp.OrderedItems, *a)
|
||||
}
|
||||
|
||||
|
@ -310,11 +357,60 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
a := streams.NewAccept()
|
||||
p := c.PersonObject()
|
||||
var to *url.URL
|
||||
var isFollow, isUnfollow bool
|
||||
var isFollow, isUnfollow, isLike, isUnlike bool
|
||||
var likePostID, unlikePostID string
|
||||
fullActor := &activitystreams.Person{}
|
||||
var remoteUser *RemoteUser
|
||||
|
||||
res := &streams.Resolver{
|
||||
LikeCallback: func(l *streams.Like) error {
|
||||
isLike = true
|
||||
|
||||
// 1) Use the Like concrete type here
|
||||
// 2) Errors are propagated to res.Deserialize call below
|
||||
m["@context"] = []string{activitystreams.Namespace}
|
||||
b, _ := json.Marshal(m)
|
||||
if debugging {
|
||||
log.Info("Like: %s", b)
|
||||
}
|
||||
|
||||
_, likeID := l.GetId()
|
||||
if likeID == nil {
|
||||
log.Error("Didn't resolve Like ID")
|
||||
}
|
||||
if p := l.HasObject(0); p == streams.NoPresence {
|
||||
return fmt.Errorf("no object for Like activity at index 0")
|
||||
}
|
||||
|
||||
obj := l.Raw().GetObjectIRI(0)
|
||||
/*
|
||||
// TODO: handle this more robustly
|
||||
l.ResolveObject(&streams.Resolver{
|
||||
LinkCallback: func(link *streams.Link) error {
|
||||
return nil
|
||||
},
|
||||
}, 0)
|
||||
*/
|
||||
|
||||
if obj == nil {
|
||||
return fmt.Errorf("didn't get ObjectIRI to Like")
|
||||
}
|
||||
likePostID, err = parsePostIDFromURL(app, obj)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Finally, get actor information
|
||||
_, from := l.GetActor(0)
|
||||
if from == nil {
|
||||
return fmt.Errorf("No valid actor string")
|
||||
}
|
||||
fullActor, remoteUser, err = getActor(app, from.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
FollowCallback: func(f *streams.Follow) error {
|
||||
isFollow = true
|
||||
|
||||
|
@ -330,7 +426,7 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
if followID == nil {
|
||||
log.Error("Didn't resolve follow ID")
|
||||
} else {
|
||||
aID := c.FederatedAccount() + "#accept-" + store.GenerateFriendlyRandomString(20)
|
||||
aID := c.FederatedAccount() + "#accept-" + id.GenerateFriendlyRandomString(20)
|
||||
acceptID, err := url.Parse(aID)
|
||||
if err != nil {
|
||||
log.Error("Couldn't parse generated Accept URL '%s': %v", aID, err)
|
||||
|
@ -340,6 +436,17 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
a.AppendObject(f.Raw())
|
||||
_, to = f.GetActor(0)
|
||||
obj := f.Raw().GetObjectIRI(0)
|
||||
if obj == nil {
|
||||
if debugging {
|
||||
log.Error("GetObjectIRI on Follow for actor is empty; trying object")
|
||||
}
|
||||
ao := f.Raw().GetObject(0)
|
||||
if ao == nil {
|
||||
log.Error("Fell back to GetObject and none parsed, so no actor ID! Follow request probably FAILED!")
|
||||
} else {
|
||||
obj = ao.GetId()
|
||||
}
|
||||
}
|
||||
a.AppendActor(obj)
|
||||
|
||||
// First get actor information
|
||||
|
@ -353,8 +460,6 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
return impart.RenderActivityJSON(w, m, http.StatusOK)
|
||||
},
|
||||
UndoCallback: func(u *streams.Undo) error {
|
||||
isUnfollow = true
|
||||
|
||||
m["@context"] = []string{activitystreams.Namespace}
|
||||
b, _ := json.Marshal(m)
|
||||
if debugging {
|
||||
|
@ -362,6 +467,37 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
}
|
||||
|
||||
a.AppendObject(u.Raw())
|
||||
|
||||
// Check type -- we handle Undo:Like and Undo:Follow
|
||||
_, err := u.ResolveObject(&streams.Resolver{
|
||||
LikeCallback: func(like *streams.Like) error {
|
||||
isUnlike = true
|
||||
|
||||
_, from := like.GetActor(0)
|
||||
obj := like.Raw().GetObjectIRI(0)
|
||||
if obj == nil {
|
||||
return fmt.Errorf("didn't get ObjectIRI for Undo Like")
|
||||
}
|
||||
unlikePostID, err = parsePostIDFromURL(app, obj)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fullActor, remoteUser, err = getActor(app, from.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
// TODO: add FollowCallback for more robust handling
|
||||
}, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isUnlike {
|
||||
return nil
|
||||
}
|
||||
|
||||
isUnfollow = true
|
||||
_, to = u.GetActor(0)
|
||||
// TODO: get actor from object.object, not object
|
||||
obj := u.Raw().GetObjectIRI(0)
|
||||
|
@ -394,9 +530,86 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
return err
|
||||
}
|
||||
|
||||
// Handle synchronous activities
|
||||
if isLike {
|
||||
t, err := app.db.Begin()
|
||||
if err != nil {
|
||||
log.Error("Unable to start transaction: %v", err)
|
||||
return fmt.Errorf("unable to start transaction: %v", err)
|
||||
}
|
||||
|
||||
var remoteUserID int64
|
||||
if remoteUser != nil {
|
||||
remoteUserID = remoteUser.ID
|
||||
} else {
|
||||
remoteUserID, err = apAddRemoteUser(app, t, fullActor)
|
||||
}
|
||||
|
||||
// Add like
|
||||
_, err = t.Exec("INSERT INTO remote_likes (post_id, remote_user_id, created) VALUES (?, ?, "+app.db.now()+")", likePostID, remoteUserID)
|
||||
if err != nil {
|
||||
if !app.db.isDuplicateKeyErr(err) {
|
||||
t.Rollback()
|
||||
log.Error("Couldn't add like in DB: %v\n", err)
|
||||
return fmt.Errorf("Couldn't add like in DB: %v", err)
|
||||
} else {
|
||||
t.Rollback()
|
||||
log.Error("Couldn't add like in DB: %v\n", err)
|
||||
return fmt.Errorf("Couldn't add like in DB: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
log.Error("Rolling back after Commit(): %v\n", err)
|
||||
return fmt.Errorf("Rolling back after Commit(): %v\n", err)
|
||||
}
|
||||
|
||||
if debugging {
|
||||
log.Info("Successfully liked post %s by remote user %s", likePostID, remoteUser.URL)
|
||||
}
|
||||
return impart.RenderActivityJSON(w, "", http.StatusOK)
|
||||
} else if isUnlike {
|
||||
t, err := app.db.Begin()
|
||||
if err != nil {
|
||||
log.Error("Unable to start transaction: %v", err)
|
||||
return fmt.Errorf("unable to start transaction: %v", err)
|
||||
}
|
||||
|
||||
var remoteUserID int64
|
||||
if remoteUser != nil {
|
||||
remoteUserID = remoteUser.ID
|
||||
} else {
|
||||
remoteUserID, err = apAddRemoteUser(app, t, fullActor)
|
||||
}
|
||||
|
||||
// Remove like
|
||||
_, err = t.Exec("DELETE FROM remote_likes WHERE post_id = ? AND remote_user_id = ?", unlikePostID, remoteUserID)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
log.Error("Couldn't delete Like from DB: %v\n", err)
|
||||
return fmt.Errorf("Couldn't delete Like from DB: %v", err)
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
log.Error("Rolling back after Commit(): %v\n", err)
|
||||
return fmt.Errorf("Rolling back after Commit(): %v\n", err)
|
||||
}
|
||||
|
||||
if debugging {
|
||||
log.Info("Successfully un-liked post %s by remote user %s", unlikePostID, remoteUser.URL)
|
||||
}
|
||||
return impart.RenderActivityJSON(w, "", http.StatusOK)
|
||||
}
|
||||
|
||||
go func() {
|
||||
if to == nil {
|
||||
log.Error("No to! %v", err)
|
||||
if debugging {
|
||||
log.Error("No `to` value!")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -426,8 +639,9 @@ func handleFetchCollectionInbox(app *App, w http.ResponseWriter, r *http.Request
|
|||
if remoteUser != nil {
|
||||
followerID = remoteUser.ID
|
||||
} else {
|
||||
// TODO: use apAddRemoteUser() here, instead!
|
||||
// Add follower locally, since it wasn't found before
|
||||
res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox) VALUES (?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox)
|
||||
res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url) VALUES (?, ?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox, fullActor.URL)
|
||||
if err != nil {
|
||||
// if duplicate key, res will be nil and panic on
|
||||
// res.LastInsertId below
|
||||
|
@ -491,7 +705,7 @@ func makeActivityPost(hostName string, p *activitystreams.Person, url string, m
|
|||
|
||||
r, _ := http.NewRequest("POST", url, bytes.NewBuffer(b))
|
||||
r.Header.Add("Content-Type", "application/activity+json")
|
||||
r.Header.Set("User-Agent", "Go ("+serverSoftware+"/"+softwareVer+"; +"+hostName+")")
|
||||
r.Header.Set("User-Agent", ServerUserAgent(hostName))
|
||||
h := sha256.New()
|
||||
h.Write(b)
|
||||
r.Header.Add("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(h.Sum(nil)))
|
||||
|
@ -524,7 +738,7 @@ func makeActivityPost(hostName string, p *activitystreams.Person, url string, m
|
|||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -541,7 +755,23 @@ func resolveIRI(hostName, url string) ([]byte, error) {
|
|||
|
||||
r, _ := http.NewRequest("GET", url, nil)
|
||||
r.Header.Add("Accept", "application/activity+json")
|
||||
r.Header.Set("User-Agent", "Go ("+serverSoftware+"/"+softwareVer+"; +"+hostName+")")
|
||||
r.Header.Set("User-Agent", ServerUserAgent(hostName))
|
||||
|
||||
p := instanceColl.PersonObject()
|
||||
h := sha256.New()
|
||||
h.Write([]byte{})
|
||||
r.Header.Add("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(h.Sum(nil)))
|
||||
|
||||
// Sign using the 'Signature' header
|
||||
privKey, err := activitypub.DecodePrivateKey(p.GetPrivKey())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signer := httpsig.NewSigner(p.PublicKey.ID, privKey, httpsig.RSASHA256, []string{"(request-target)", "date", "host", "digest"})
|
||||
err = signer.SignSigHeader(r)
|
||||
if err != nil {
|
||||
log.Error("Can't sign: %v", err)
|
||||
}
|
||||
|
||||
if debugging {
|
||||
dump, err := httputil.DumpRequestOut(r, true)
|
||||
|
@ -560,7 +790,7 @@ func resolveIRI(hostName, url string) ([]byte, error) {
|
|||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -603,10 +833,7 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error {
|
|||
|
||||
for si, instFolls := range inboxes {
|
||||
na.CC = []string{}
|
||||
for _, f := range instFolls {
|
||||
na.CC = append(na.CC, f)
|
||||
}
|
||||
|
||||
na.CC = append(na.CC, instFolls...)
|
||||
da := activitystreams.NewDeleteActivity(na)
|
||||
// Make the ID unique to ensure it works in Pleroma
|
||||
// See: https://git.pleroma.social/pleroma/pleroma/issues/1481
|
||||
|
@ -621,6 +848,16 @@ func deleteFederatedPost(app *App, p *PublicPost, collID int64) error {
|
|||
}
|
||||
|
||||
func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
||||
// If app is private, do not federate
|
||||
if app.cfg.App.Private {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Do not federate posts from private or protected blogs
|
||||
if p.Collection.Visibility == CollPrivate || p.Collection.Visibility == CollProtected {
|
||||
return nil
|
||||
}
|
||||
|
||||
if debugging {
|
||||
if isUpdate {
|
||||
log.Info("Federating updated post!")
|
||||
|
@ -628,6 +865,7 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
|||
log.Info("Federating new post!")
|
||||
}
|
||||
}
|
||||
|
||||
actor := p.Collection.PersonObject(collID)
|
||||
na := p.ActivityObject(app)
|
||||
|
||||
|
@ -661,12 +899,11 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
|||
// add all followers from that instance
|
||||
// to the CC field
|
||||
na.CC = []string{}
|
||||
for _, f := range instFolls {
|
||||
na.CC = append(na.CC, f)
|
||||
}
|
||||
na.CC = append(na.CC, instFolls...)
|
||||
// create a new "Create" activity
|
||||
// with our article as object
|
||||
if isUpdate {
|
||||
na.Updated = &p.Updated
|
||||
activity = activitystreams.NewUpdateActivity(na)
|
||||
} else {
|
||||
activity = activitystreams.NewCreateActivity(na)
|
||||
|
@ -696,6 +933,10 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
|||
// I don't believe we'd ever have too many mentions in a single post that this
|
||||
// could become a burden.
|
||||
remoteUser, err := getRemoteUser(app, tag.HRef)
|
||||
if err != nil {
|
||||
log.Error("Unable to find remote user %s. Skipping: %v", tag.HRef, err)
|
||||
continue
|
||||
}
|
||||
err = makeActivityPost(app.cfg.App.Host, actor, remoteUser.Inbox, activity)
|
||||
if err != nil {
|
||||
log.Error("Couldn't post! %v", err)
|
||||
|
@ -708,7 +949,8 @@ func federatePost(app *App, p *PublicPost, collID int64, isUpdate bool) error {
|
|||
|
||||
func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
|
||||
u := RemoteUser{ActorID: actorID}
|
||||
err := app.db.QueryRow("SELECT id, inbox, shared_inbox, handle FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox, &u.Handle)
|
||||
var urlVal, handle sql.NullString
|
||||
err := app.db.QueryRow("SELECT id, inbox, shared_inbox, url, handle FROM remoteusers WHERE actor_id = ?", actorID).Scan(&u.ID, &u.Inbox, &u.SharedInbox, &urlVal, &handle)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
return nil, impart.HTTPError{http.StatusNotFound, "No remote user with that ID."}
|
||||
|
@ -717,6 +959,9 @@ func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
u.URL = urlVal.String
|
||||
u.Handle = handle.String
|
||||
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
|
@ -724,7 +969,8 @@ func getRemoteUser(app *App, actorID string) (*RemoteUser, error) {
|
|||
// from the @user@server.tld handle
|
||||
func getRemoteUserFromHandle(app *App, handle string) (*RemoteUser, error) {
|
||||
u := RemoteUser{Handle: handle}
|
||||
err := app.db.QueryRow("SELECT id, actor_id, inbox, shared_inbox FROM remoteusers WHERE handle = ?", handle).Scan(&u.ID, &u.ActorID, &u.Inbox, &u.SharedInbox)
|
||||
var urlVal sql.NullString
|
||||
err := app.db.QueryRow("SELECT id, actor_id, inbox, shared_inbox, url FROM remoteusers WHERE handle = ?", handle).Scan(&u.ID, &u.ActorID, &u.Inbox, &u.SharedInbox, &urlVal)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
return nil, ErrRemoteUserNotFound
|
||||
|
@ -732,6 +978,7 @@ func getRemoteUserFromHandle(app *App, handle string) (*RemoteUser, error) {
|
|||
log.Error("Couldn't get remote user %s: %v", handle, err)
|
||||
return nil, err
|
||||
}
|
||||
u.URL = urlVal.String
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
|
@ -746,13 +993,28 @@ func getActor(app *App, actorIRI string) (*activitystreams.Person, *RemoteUser,
|
|||
log.Info("Not found; fetching actor %s remotely", actorIRI)
|
||||
actorResp, err := resolveIRI(app.cfg.App.Host, actorIRI)
|
||||
if err != nil {
|
||||
log.Error("Unable to get actor! %v", err)
|
||||
log.Error("Unable to get base actor! %v", err)
|
||||
return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't fetch actor."}
|
||||
}
|
||||
if err := unmarshalActor(actorResp, actor); err != nil {
|
||||
log.Error("Unable to unmarshal actor! %v", err)
|
||||
log.Error("Unable to unmarshal base actor! %v", err)
|
||||
return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't parse actor."}
|
||||
}
|
||||
baseActor := &activitystreams.Person{}
|
||||
if err := unmarshalActor(actorResp, baseActor); err != nil {
|
||||
log.Error("Unable to unmarshal actual actor! %v", err)
|
||||
return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't parse actual actor."}
|
||||
}
|
||||
// Fetch the actual actor using the owner field from the publicKey object
|
||||
actualActorResp, err := resolveIRI(app.cfg.App.Host, baseActor.PublicKey.Owner)
|
||||
if err != nil {
|
||||
log.Error("Unable to get actual actor! %v", err)
|
||||
return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't fetch actual actor."}
|
||||
}
|
||||
if err := unmarshalActor(actualActorResp, actor); err != nil {
|
||||
log.Error("Unable to unmarshal actual actor! %v", err)
|
||||
return nil, nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't parse actual actor."}
|
||||
}
|
||||
} else {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
@ -765,6 +1027,69 @@ func getActor(app *App, actorIRI string) (*activitystreams.Person, *RemoteUser,
|
|||
return actor, remoteUser, nil
|
||||
}
|
||||
|
||||
func GetProfileURLFromHandle(app *App, handle string) (string, error) {
|
||||
handle = strings.TrimLeft(handle, "@")
|
||||
actorIRI := ""
|
||||
parts := strings.Split(handle, "@")
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid handle format")
|
||||
}
|
||||
domain := parts[1]
|
||||
|
||||
// Check non-AP instances
|
||||
if siloProfileURL := silobridge.Profile(parts[0], domain); siloProfileURL != "" {
|
||||
return siloProfileURL, nil
|
||||
}
|
||||
|
||||
remoteUser, err := getRemoteUserFromHandle(app, handle)
|
||||
if err != nil {
|
||||
// can't find using handle in the table but the table may already have this user without
|
||||
// handle from a previous version
|
||||
// TODO: Make this determination. We should know whether a user exists without a handle, or doesn't exist at all
|
||||
actorIRI = RemoteLookup(handle)
|
||||
_, errRemoteUser := getRemoteUser(app, actorIRI)
|
||||
// if it exists then we need to update the handle
|
||||
if errRemoteUser == nil {
|
||||
_, err := app.db.Exec("UPDATE remoteusers SET handle = ? WHERE actor_id = ?", handle, actorIRI)
|
||||
if err != nil {
|
||||
log.Error("Couldn't update handle '%s' for user %s", handle, actorIRI)
|
||||
}
|
||||
} else {
|
||||
// this probably means we don't have the user in the table so let's try to insert it
|
||||
// here we need to ask the server for the inboxes
|
||||
remoteActor, err := activityserve.NewRemoteActor(actorIRI)
|
||||
if err != nil {
|
||||
log.Error("Couldn't fetch remote actor: %v", err)
|
||||
}
|
||||
if debugging {
|
||||
log.Info("Got remote actor: %s %s %s %s %s", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), remoteActor.URL(), handle)
|
||||
}
|
||||
_, err = app.db.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url, handle) VALUES(?, ?, ?, ?, ?)", actorIRI, remoteActor.GetInbox(), remoteActor.GetSharedInbox(), remoteActor.URL(), handle)
|
||||
if err != nil {
|
||||
log.Error("Couldn't insert remote user: %v", err)
|
||||
return "", err
|
||||
}
|
||||
actorIRI = remoteActor.URL()
|
||||
}
|
||||
} else if remoteUser.URL == "" {
|
||||
log.Info("Remote user %s URL empty, fetching", remoteUser.ActorID)
|
||||
newRemoteActor, err := activityserve.NewRemoteActor(remoteUser.ActorID)
|
||||
if err != nil {
|
||||
log.Error("Couldn't fetch remote actor: %v", err)
|
||||
} else {
|
||||
_, err := app.db.Exec("UPDATE remoteusers SET url = ? WHERE actor_id = ?", newRemoteActor.URL(), remoteUser.ActorID)
|
||||
if err != nil {
|
||||
log.Error("Couldn't update handle '%s' for user %s", handle, actorIRI)
|
||||
} else {
|
||||
actorIRI = newRemoteActor.URL()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
actorIRI = remoteUser.URL
|
||||
}
|
||||
return actorIRI, nil
|
||||
}
|
||||
|
||||
// unmarshal actor normalizes the actor response to conform to
|
||||
// the type Person from github.com/writeas/web-core/activitysteams
|
||||
//
|
||||
|
@ -810,6 +1135,34 @@ func unmarshalActor(actorResp []byte, actor *activitystreams.Person) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func parsePostIDFromURL(app *App, u *url.URL) (string, error) {
|
||||
// Get post ID from URL
|
||||
var collAlias, slug, postID string
|
||||
if m := apCollectionPostIRIRegex.FindStringSubmatch(u.String()); len(m) == 3 {
|
||||
collAlias = m[1]
|
||||
slug = m[2]
|
||||
} else if m = apDraftPostIRIRegex.FindStringSubmatch(u.String()); len(m) == 2 {
|
||||
postID = m[1]
|
||||
} else {
|
||||
return "", fmt.Errorf("unable to match objectIRI: %s", u)
|
||||
}
|
||||
|
||||
// Get postID if all we have is collection and slug
|
||||
if collAlias != "" && slug != "" {
|
||||
c, err := app.db.GetCollection(collAlias)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p, err := app.db.GetPost(slug, c.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
postID = p.ID
|
||||
}
|
||||
|
||||
return postID, nil
|
||||
}
|
||||
|
||||
func setCacheControl(w http.ResponseWriter, ttl time.Duration) {
|
||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", ttl.Seconds()))
|
||||
}
|
||||
|
|
161
admin.go
161
admin.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -13,6 +13,7 @@ package writefreely
|
|||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strconv"
|
||||
|
@ -24,8 +25,8 @@ import (
|
|||
"github.com/writeas/web-core/auth"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/web-core/passgen"
|
||||
"github.com/writeas/writefreely/appstats"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writefreely/writefreely/appstats"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -90,40 +91,109 @@ type instanceContent struct {
|
|||
Updated time.Time
|
||||
}
|
||||
|
||||
func (c instanceContent) UpdatedFriendly() string {
|
||||
type AdminPage struct {
|
||||
UpdateAvailable bool
|
||||
}
|
||||
|
||||
func NewAdminPage(app *App) *AdminPage {
|
||||
ap := &AdminPage{}
|
||||
if app.updates != nil {
|
||||
ap.UpdateAvailable = app.updates.AreAvailableNoCheck()
|
||||
}
|
||||
return ap
|
||||
}
|
||||
|
||||
func (c instanceContent) UpdatedFriendly() template.HTML {
|
||||
/*
|
||||
// TODO: accept a locale in this method and use that for the format
|
||||
var loc monday.Locale = monday.LocaleEnUS
|
||||
return monday.Format(u.Created, monday.DateTimeFormatsByLocale[loc], loc)
|
||||
*/
|
||||
return c.Updated.Format("January 2, 2006, 3:04 PM")
|
||||
if c.Updated.IsZero() {
|
||||
return "<em>Never</em>"
|
||||
}
|
||||
return template.HTML(c.Updated.Format("January 2, 2006, 3:04 PM"))
|
||||
}
|
||||
|
||||
func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
Message string
|
||||
|
||||
UsersCount, CollectionsCount, PostsCount int64
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Admin", nil),
|
||||
AdminPage: NewAdminPage(app),
|
||||
Message: r.FormValue("m"),
|
||||
}
|
||||
|
||||
// Get user stats
|
||||
p.UsersCount = app.db.GetAllUsersCount()
|
||||
var err error
|
||||
p.CollectionsCount, err = app.db.GetTotalCollections()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.PostsCount, err = app.db.GetTotalPosts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
showUserPage(w, "admin", p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleViewAdminMonitor(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
updateAppStats()
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
SysStatus systemStatus
|
||||
Config config.AppCfg
|
||||
|
||||
Message, ConfigMessage string
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Admin", nil),
|
||||
AdminPage: NewAdminPage(app),
|
||||
SysStatus: sysStatus,
|
||||
Config: app.cfg.App,
|
||||
|
||||
Message: r.FormValue("m"),
|
||||
ConfigMessage: r.FormValue("cm"),
|
||||
}
|
||||
showUserPage(w, "admin", p)
|
||||
|
||||
showUserPage(w, "monitor", p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleViewAdminSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
Config config.AppCfg
|
||||
|
||||
Message, ConfigMessage string
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Admin", nil),
|
||||
AdminPage: NewAdminPage(app),
|
||||
Config: app.cfg.App,
|
||||
|
||||
Message: r.FormValue("m"),
|
||||
ConfigMessage: r.FormValue("cm"),
|
||||
}
|
||||
|
||||
showUserPage(w, "app-settings", p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
Config config.AppCfg
|
||||
Message string
|
||||
Flashes []string
|
||||
|
||||
Users *[]User
|
||||
CurPage int
|
||||
|
@ -131,12 +201,14 @@ func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
|||
TotalPages []int
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Users", nil),
|
||||
AdminPage: NewAdminPage(app),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
}
|
||||
|
||||
p.Flashes, _ = getSessionFlashes(app, w, r, nil)
|
||||
p.TotalUsers = app.db.GetAllUsersCount()
|
||||
ttlPages := p.TotalUsers / adminUsersPerPage
|
||||
ttlPages := (p.TotalUsers - 1) / adminUsersPerPage + 1
|
||||
p.TotalPages = []int{}
|
||||
for i := 1; i <= int(ttlPages); i++ {
|
||||
p.TotalPages = append(p.TotalPages, i)
|
||||
|
@ -168,6 +240,7 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
|||
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
Config config.AppCfg
|
||||
Message string
|
||||
|
||||
|
@ -178,6 +251,7 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
|||
TotalPosts int64
|
||||
ClearEmail string
|
||||
}{
|
||||
AdminPage: NewAdminPage(app),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
Colls: []inspectedCollection{},
|
||||
|
@ -244,6 +318,37 @@ func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
|||
return nil
|
||||
}
|
||||
|
||||
func handleAdminDeleteUser(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
if !u.IsAdmin() {
|
||||
return impart.HTTPError{http.StatusForbidden, "Administrator privileges required for this action"}
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
username := vars["username"]
|
||||
confirmUsername := r.PostFormValue("confirm-username")
|
||||
|
||||
if confirmUsername != username {
|
||||
return impart.HTTPError{http.StatusBadRequest, "Username was not confirmed"}
|
||||
}
|
||||
|
||||
user, err := app.db.GetUserForAuth(username)
|
||||
if err == ErrUserNotFound {
|
||||
return impart.HTTPError{http.StatusNotFound, fmt.Sprintf("User '%s' was not found", username)}
|
||||
} else if err != nil {
|
||||
log.Error("get user for deletion: %v", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user with username '%s': %v", username, err)}
|
||||
}
|
||||
|
||||
err = app.db.DeleteAccount(user.ID)
|
||||
if err != nil {
|
||||
log.Error("delete user %s: %v", user.Username, err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not delete user account for '%s': %v", username, err)}
|
||||
}
|
||||
|
||||
_ = addSessionFlash(app, w, r, fmt.Sprintf("User \"%s\" was deleted successfully.", username), nil)
|
||||
return impart.HTTPError{http.StatusFound, "/admin/users"}
|
||||
}
|
||||
|
||||
func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
username := vars["username"]
|
||||
|
@ -260,6 +365,9 @@ func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *ht
|
|||
err = app.db.SetUserStatus(user.ID, UserActive)
|
||||
} else {
|
||||
err = app.db.SetUserStatus(user.ID, UserSilenced)
|
||||
|
||||
// reset the cache to removed silence user posts
|
||||
updateTimelineCache(app.timeline, true)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("toggle user silenced: %v", err)
|
||||
|
@ -303,12 +411,14 @@ func handleAdminResetUserPass(app *App, u *User, w http.ResponseWriter, r *http.
|
|||
func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
Config config.AppCfg
|
||||
Message string
|
||||
|
||||
Pages []*instanceContent
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Pages", nil),
|
||||
AdminPage: NewAdminPage(app),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
}
|
||||
|
@ -320,9 +430,9 @@ func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
|||
}
|
||||
|
||||
// Add in default pages
|
||||
var hasAbout, hasPrivacy bool
|
||||
var hasAbout, hasContact, hasPrivacy bool
|
||||
for i, c := range p.Pages {
|
||||
if hasAbout && hasPrivacy {
|
||||
if hasAbout && hasContact && hasPrivacy {
|
||||
break
|
||||
}
|
||||
if c.ID == "about" {
|
||||
|
@ -330,6 +440,11 @@ func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
|||
if !c.Title.Valid {
|
||||
p.Pages[i].Title = defaultAboutTitle(app.cfg)
|
||||
}
|
||||
} else if c.ID == "contact" {
|
||||
hasContact = true
|
||||
if !c.Title.Valid {
|
||||
p.Pages[i].Title = defaultContactTitle()
|
||||
}
|
||||
} else if c.ID == "privacy" {
|
||||
hasPrivacy = true
|
||||
if !c.Title.Valid {
|
||||
|
@ -345,6 +460,13 @@ func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Requ
|
|||
Updated: defaultPageUpdatedTime,
|
||||
})
|
||||
}
|
||||
if !hasContact {
|
||||
p.Pages = append(p.Pages, &instanceContent{
|
||||
ID: "contact",
|
||||
Title: defaultContactTitle(),
|
||||
Content: defaultContactPage(app),
|
||||
})
|
||||
}
|
||||
if !hasPrivacy {
|
||||
p.Pages = append(p.Pages, &instanceContent{
|
||||
ID: "privacy",
|
||||
|
@ -367,12 +489,14 @@ func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
|||
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
Config config.AppCfg
|
||||
Message string
|
||||
|
||||
Banner *instanceContent
|
||||
Content *instanceContent
|
||||
}{
|
||||
AdminPage: NewAdminPage(app),
|
||||
Config: app.cfg.App,
|
||||
Message: r.FormValue("m"),
|
||||
}
|
||||
|
@ -381,6 +505,8 @@ func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Reque
|
|||
// Get pre-defined pages, or select slug
|
||||
if slug == "about" {
|
||||
p.Content, err = getAboutPage(app)
|
||||
} else if slug == "contact" {
|
||||
p.Content, err = getContactPage(app)
|
||||
} else if slug == "privacy" {
|
||||
p.Content, err = getPrivacyPage(app)
|
||||
} else if slug == "landing" {
|
||||
|
@ -415,7 +541,7 @@ func handleAdminUpdateSite(app *App, u *User, w http.ResponseWriter, r *http.Req
|
|||
id := vars["page"]
|
||||
|
||||
// Validate
|
||||
if id != "about" && id != "privacy" && id != "landing" && id != "reader" {
|
||||
if id != "about" && id != "contact" && id != "privacy" && id != "landing" && id != "reader" {
|
||||
return impart.HTTPError{http.StatusNotFound, "No such page."}
|
||||
}
|
||||
|
||||
|
@ -447,6 +573,7 @@ func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *htt
|
|||
apper.App().cfg.App.SiteDesc = r.FormValue("site_desc")
|
||||
apper.App().cfg.App.Landing = r.FormValue("landing")
|
||||
apper.App().cfg.App.OpenRegistration = r.FormValue("open_registration") == "on"
|
||||
apper.App().cfg.App.OpenDeletion = r.FormValue("open_deletion") == "on"
|
||||
mul, err := strconv.Atoi(r.FormValue("min_username_len"))
|
||||
if err == nil {
|
||||
apper.App().cfg.App.MinUsernameLen = mul
|
||||
|
@ -457,6 +584,7 @@ func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *htt
|
|||
}
|
||||
apper.App().cfg.App.Federation = r.FormValue("federation") == "on"
|
||||
apper.App().cfg.App.PublicStats = r.FormValue("public_stats") == "on"
|
||||
apper.App().cfg.App.Monetization = r.FormValue("monetization") == "on"
|
||||
apper.App().cfg.App.Private = r.FormValue("private") == "on"
|
||||
apper.App().cfg.App.LocalTimeline = r.FormValue("local_timeline") == "on"
|
||||
if apper.App().cfg.App.LocalTimeline && apper.App().timeline == nil {
|
||||
|
@ -474,7 +602,7 @@ func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *htt
|
|||
if err != nil {
|
||||
m = "?cm=" + err.Error()
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, "/admin" + m + "#config"}
|
||||
return impart.HTTPError{http.StatusFound, "/admin/settings" + m + "#config"}
|
||||
}
|
||||
|
||||
func updateAppStats() {
|
||||
|
@ -537,18 +665,27 @@ func handleViewAdminUpdates(app *App, u *User, w http.ResponseWriter, r *http.Re
|
|||
|
||||
p := struct {
|
||||
*UserPage
|
||||
*AdminPage
|
||||
CurReleaseNotesURL string
|
||||
LastChecked string
|
||||
LastChecked8601 string
|
||||
LatestVersion string
|
||||
LatestReleaseURL string
|
||||
UpdateAvailable bool
|
||||
LatestReleaseNotesURL string
|
||||
CheckFailed bool
|
||||
}{
|
||||
UserPage: NewUserPage(app, r, u, "Updates", nil),
|
||||
AdminPage: NewAdminPage(app),
|
||||
}
|
||||
p.CurReleaseNotesURL = wfReleaseNotesURL(p.Version)
|
||||
if app.cfg.App.UpdateChecks {
|
||||
p.LastChecked = app.updates.lastCheck.Format("January 2, 2006, 3:04 PM")
|
||||
p.LastChecked8601 = app.updates.lastCheck.Format("2006-01-02T15:04:05Z")
|
||||
p.LatestVersion = app.updates.LatestVersion()
|
||||
p.LatestReleaseURL = app.updates.ReleaseURL()
|
||||
p.LatestReleaseNotesURL = app.updates.ReleaseNotesURL()
|
||||
p.UpdateAvailable = app.updates.AreAvailable()
|
||||
p.CheckFailed = app.updates.checkError != nil
|
||||
}
|
||||
|
||||
showUserPage(w, "app-updates", p)
|
||||
|
|
209
app.go
209
app.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -11,12 +11,12 @@
|
|||
package writefreely
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
|
@ -31,24 +31,25 @@ import (
|
|||
"github.com/gorilla/schema"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/writeas/go-mysqldump"
|
||||
stripmd "github.com/writeas/go-strip-markdown"
|
||||
stripmd "github.com/writeas/go-strip-markdown/v2"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/auth"
|
||||
"github.com/writeas/web-core/converter"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/author"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writeas/writefreely/key"
|
||||
"github.com/writeas/writefreely/migrations"
|
||||
"github.com/writeas/writefreely/page"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
|
||||
"github.com/writefreely/writefreely/author"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
"github.com/writefreely/writefreely/key"
|
||||
"github.com/writefreely/writefreely/migrations"
|
||||
"github.com/writefreely/writefreely/page"
|
||||
)
|
||||
|
||||
const (
|
||||
staticDir = "static"
|
||||
assumedTitleLen = 80
|
||||
postsPerPage = 10
|
||||
postsPerArchPage = 40
|
||||
|
||||
serverSoftware = "WriteFreely"
|
||||
softwareURL = "https://writefreely.org"
|
||||
|
@ -58,7 +59,7 @@ var (
|
|||
debugging bool
|
||||
|
||||
// Software version can be set from git env using -ldflags
|
||||
softwareVer = "0.11.2"
|
||||
softwareVer = "0.15.1"
|
||||
|
||||
// DEPRECATED VARS
|
||||
isSingleUser bool
|
||||
|
@ -168,7 +169,15 @@ func (app *App) LoadKeys() error {
|
|||
if debugging {
|
||||
log.Info(" %s", emailKeyPath)
|
||||
}
|
||||
app.keys.EmailKey, err = ioutil.ReadFile(emailKeyPath)
|
||||
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
executable = "writefreely"
|
||||
} else {
|
||||
executable = filepath.Base(executable)
|
||||
}
|
||||
|
||||
app.keys.EmailKey, err = os.ReadFile(emailKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -176,7 +185,7 @@ func (app *App) LoadKeys() error {
|
|||
if debugging {
|
||||
log.Info(" %s", cookieAuthKeyPath)
|
||||
}
|
||||
app.keys.CookieAuthKey, err = ioutil.ReadFile(cookieAuthKeyPath)
|
||||
app.keys.CookieAuthKey, err = os.ReadFile(cookieAuthKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -184,11 +193,27 @@ func (app *App) LoadKeys() error {
|
|||
if debugging {
|
||||
log.Info(" %s", cookieKeyPath)
|
||||
}
|
||||
app.keys.CookieKey, err = ioutil.ReadFile(cookieKeyPath)
|
||||
app.keys.CookieKey, err = os.ReadFile(cookieKeyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if debugging {
|
||||
log.Info(" %s", csrfKeyPath)
|
||||
}
|
||||
app.keys.CSRFKey, err = os.ReadFile(csrfKeyPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Error(`Missing key: %s.
|
||||
|
||||
Run this command to generate missing keys:
|
||||
%s keys generate
|
||||
|
||||
`, csrfKeyPath, executable)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -223,6 +248,10 @@ func handleViewHome(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
return handleViewPad(app, w, r)
|
||||
}
|
||||
|
||||
if app.cfg.App.Private {
|
||||
return viewLogin(app, w, r)
|
||||
}
|
||||
|
||||
if land := app.cfg.App.LandingPath(); land != "/" {
|
||||
return impart.HTTPError{http.StatusFound, land}
|
||||
}
|
||||
|
@ -236,6 +265,7 @@ func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
|
||||
p := struct {
|
||||
page.StaticPage
|
||||
*OAuthButtons
|
||||
Flashes []template.HTML
|
||||
Banner template.HTML
|
||||
Content template.HTML
|
||||
|
@ -243,6 +273,7 @@ func handleViewLanding(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
ForcedLanding bool
|
||||
}{
|
||||
StaticPage: pageForReq(app, r),
|
||||
OAuthButtons: NewOAuthButtons(app.Config()),
|
||||
ForcedLanding: forceLanding,
|
||||
}
|
||||
|
||||
|
@ -287,7 +318,7 @@ func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *te
|
|||
}{
|
||||
StaticPage: pageForReq(app, r),
|
||||
}
|
||||
if r.URL.Path == "/about" || r.URL.Path == "/privacy" {
|
||||
if r.URL.Path == "/about" || r.URL.Path == "/contact" || r.URL.Path == "/privacy" {
|
||||
var c *instanceContent
|
||||
var err error
|
||||
|
||||
|
@ -298,6 +329,12 @@ func handleTemplatedPage(app *App, w http.ResponseWriter, r *http.Request, t *te
|
|||
p.AboutStats = &InstanceStats{}
|
||||
p.AboutStats.NumPosts, _ = app.db.GetTotalPosts()
|
||||
p.AboutStats.NumBlogs, _ = app.db.GetTotalCollections()
|
||||
} else if r.URL.Path == "/contact" {
|
||||
c, err = getContactPage(app)
|
||||
if c.Updated.IsZero() {
|
||||
// Page was never set up, so return 404
|
||||
return ErrPostNotFound
|
||||
}
|
||||
} else {
|
||||
c, err = getPrivacyPage(app)
|
||||
}
|
||||
|
@ -328,6 +365,11 @@ func pageForReq(app *App, r *http.Request) page.StaticPage {
|
|||
Version: "v" + softwareVer,
|
||||
}
|
||||
|
||||
// Use custom style, if file exists
|
||||
if _, err := os.Stat(filepath.Join(app.cfg.Server.StaticParentDir, staticDir, "local", "custom.css")); err == nil {
|
||||
p.CustomCSS = true
|
||||
}
|
||||
|
||||
// Add user information, if given
|
||||
var u *User
|
||||
accessToken := r.FormValue("t")
|
||||
|
@ -385,6 +427,15 @@ func Initialize(apper Apper, debug bool) (*App, error) {
|
|||
return nil, fmt.Errorf("connect to DB: %s", err)
|
||||
}
|
||||
|
||||
initActivityPub(apper.App())
|
||||
|
||||
if apper.App().cfg.Email.Enabled() {
|
||||
log.Info("Starting publish jobs queue...")
|
||||
go startPublishJobsQueue(apper.App())
|
||||
} else {
|
||||
log.Error("[FAILED] Starting publish jobs queue: no email provider is configured.")
|
||||
}
|
||||
|
||||
// Handle local timeline, if enabled
|
||||
if apper.App().cfg.App.LocalTimeline {
|
||||
log.Info("Initializing local timeline...")
|
||||
|
@ -411,6 +462,11 @@ func Serve(app *App, r *mux.Router) {
|
|||
os.Exit(0)
|
||||
}()
|
||||
|
||||
// Start gopher server
|
||||
if app.cfg.Server.GopherPort > 0 && !app.cfg.App.Private {
|
||||
go initGopher(app)
|
||||
}
|
||||
|
||||
// Start web application server
|
||||
var bindAddress = app.cfg.Server.Bind
|
||||
if bindAddress == "" {
|
||||
|
@ -468,9 +524,41 @@ requests. We recommend supplying a valid host name.`)
|
|||
err = http.ListenAndServeTLS(fmt.Sprintf("%s:443", bindAddress), app.cfg.Server.TLSCertPath, app.cfg.Server.TLSKeyPath, r)
|
||||
}
|
||||
} else {
|
||||
log.Info("Serving on http://%s:%d\n", bindAddress, app.cfg.Server.Port)
|
||||
network := "tcp"
|
||||
protocol := "http"
|
||||
if strings.HasPrefix(bindAddress, "/") {
|
||||
network = "unix"
|
||||
protocol = "http+unix"
|
||||
|
||||
// old sockets will remain after server closes;
|
||||
// we need to delete them in order to open new ones
|
||||
err = os.Remove(bindAddress)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
log.Error("%s already exists but could not be removed: %v", bindAddress, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
bindAddress = fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port)
|
||||
}
|
||||
|
||||
log.Info("Serving on %s://%s", protocol, bindAddress)
|
||||
log.Info("---")
|
||||
err = http.ListenAndServe(fmt.Sprintf("%s:%d", bindAddress, app.cfg.Server.Port), r)
|
||||
listener, err := net.Listen(network, bindAddress)
|
||||
if err != nil {
|
||||
log.Error("Could not bind to address: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if network == "unix" {
|
||||
err = os.Chmod(bindAddress, 0o666)
|
||||
if err != nil {
|
||||
log.Error("Could not update socket permissions: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
defer listener.Close()
|
||||
err = http.Serve(listener, r)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("Unable to start: %v", err)
|
||||
|
@ -494,8 +582,8 @@ func (app *App) InitDecoder() {
|
|||
// tests the connection.
|
||||
func ConnectToDatabase(app *App) error {
|
||||
// Check database configuration
|
||||
if app.cfg.Database.Type == driverMySQL && (app.cfg.Database.User == "" || app.cfg.Database.Password == "") {
|
||||
return fmt.Errorf("Database user or password not set.")
|
||||
if app.cfg.Database.Type == driverMySQL && app.cfg.Database.User == "" {
|
||||
return fmt.Errorf("Database user not set.")
|
||||
}
|
||||
if app.cfg.Database.Host == "" {
|
||||
app.cfg.Database.Host = "localhost"
|
||||
|
@ -586,7 +674,7 @@ func DoConfig(app *App, configSections string) {
|
|||
|
||||
// Create blog
|
||||
log.Info("Creating user %s...\n", u.Username)
|
||||
err = app.db.CreateUser(app.cfg, u, app.cfg.App.SiteName)
|
||||
err = app.db.CreateUser(app.cfg, u, app.cfg.App.SiteName, "")
|
||||
if err != nil {
|
||||
log.Error("Unable to create user: %s", err)
|
||||
os.Exit(1)
|
||||
|
@ -626,6 +714,10 @@ func GenerateKeyFiles(app *App) error {
|
|||
if err != nil {
|
||||
keyErrs = err
|
||||
}
|
||||
err = generateKey(csrfKeyPath)
|
||||
if err != nil {
|
||||
keyErrs = err
|
||||
}
|
||||
|
||||
return keyErrs
|
||||
}
|
||||
|
@ -642,17 +734,6 @@ func CreateSchema(apper Apper) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func Dump(apper Apper) error {
|
||||
apper.LoadConfig()
|
||||
connectToDatabase(apper.App())
|
||||
defer shutdown(apper.App())
|
||||
err := adminDumpDatabase(apper.App())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Migrate runs all necessary database migrations.
|
||||
func Migrate(apper Apper) error {
|
||||
apper.LoadConfig()
|
||||
|
@ -757,7 +838,7 @@ func connectToDatabase(app *App) {
|
|||
var db *sql.DB
|
||||
var err error
|
||||
if app.cfg.Database.Type == driverMySQL {
|
||||
db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database, url.QueryEscape(time.Local.String())))
|
||||
db, err = sql.Open(app.cfg.Database.Type, fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=%s&tls=%t", app.cfg.Database.User, app.cfg.Database.Password, app.cfg.Database.Host, app.cfg.Database.Port, app.cfg.Database.Database, url.QueryEscape(time.Local.String()), app.cfg.Database.TLS))
|
||||
db.SetMaxOpenConns(50)
|
||||
} else if app.cfg.Database.Type == driverSQLite {
|
||||
if !SQLiteEnabled {
|
||||
|
@ -769,7 +850,7 @@ func connectToDatabase(app *App) {
|
|||
os.Exit(1)
|
||||
}
|
||||
db, err = sql.Open("sqlite3_with_regex", app.cfg.Database.FileName+"?parseTime=true&cached=shared")
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxOpenConns(2)
|
||||
} else {
|
||||
log.Error("Invalid database type '%s'. Only 'mysql' and 'sqlite3' are supported right now.", app.cfg.Database.Type)
|
||||
os.Exit(1)
|
||||
|
@ -784,6 +865,16 @@ func connectToDatabase(app *App) {
|
|||
func shutdown(app *App) {
|
||||
log.Info("Closing database connection...")
|
||||
app.db.Close()
|
||||
if strings.HasPrefix(app.cfg.Server.Bind, "/") {
|
||||
// Clean up socket
|
||||
log.Info("Removing socket file...")
|
||||
err := os.Remove(app.cfg.Server.Bind)
|
||||
if err != nil {
|
||||
log.Error("Unable to remove socket: %s", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Info("Success.")
|
||||
}
|
||||
}
|
||||
|
||||
// CreateUser creates a new admin or normal user from the given credentials.
|
||||
|
@ -798,12 +889,12 @@ func CreateUser(apper Apper, username, password string, isAdmin bool) error {
|
|||
if isAdmin {
|
||||
// Abort if trying to create admin user, but one already exists
|
||||
if firstUser != nil {
|
||||
return fmt.Errorf("Admin user already exists (%s). Create a regular user with: writefreely --create-user", firstUser.Username)
|
||||
return fmt.Errorf("Admin user already exists (%s). Create a regular user with: writefreely user create [USER]:[PASSWORD]", firstUser.Username)
|
||||
}
|
||||
} else {
|
||||
// Abort if trying to create regular user, but no admin exists yet
|
||||
if firstUser == nil {
|
||||
return fmt.Errorf("No admin user exists yet. Create an admin first with: writefreely --create-admin")
|
||||
return fmt.Errorf("No admin user exists yet. Create an admin first with: writefreely user create --admin [USER]:[PASSWORD]")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -838,7 +929,7 @@ func CreateUser(apper Apper, username, password string, isAdmin bool) error {
|
|||
userType = "admin"
|
||||
}
|
||||
log.Info("Creating %s %s...", userType, usernameDesc)
|
||||
err = apper.App().db.CreateUser(apper.App().Config(), u, desiredUsername)
|
||||
err = apper.App().db.CreateUser(apper.App().Config(), u, desiredUsername, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to create user: %s", err)
|
||||
}
|
||||
|
@ -846,15 +937,18 @@ func CreateUser(apper Apper, username, password string, isAdmin bool) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func adminInitDatabase(app *App) error {
|
||||
schemaFileName := "schema.sql"
|
||||
if app.cfg.Database.Type == driverSQLite {
|
||||
schemaFileName = "sqlite.sql"
|
||||
}
|
||||
//go:embed schema.sql
|
||||
var schemaSql string
|
||||
|
||||
schema, err := Asset(schemaFileName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to load schema file: %v", err)
|
||||
//go:embed sqlite.sql
|
||||
var sqliteSql string
|
||||
|
||||
func adminInitDatabase(app *App) error {
|
||||
var schema string
|
||||
if app.cfg.Database.Type == driverSQLite {
|
||||
schema = sqliteSql
|
||||
} else {
|
||||
schema = schemaSql
|
||||
}
|
||||
|
||||
tblReg := regexp.MustCompile("CREATE TABLE (IF NOT EXISTS )?`([a-z_]+)`")
|
||||
|
@ -870,7 +964,7 @@ func adminInitDatabase(app *App) error {
|
|||
} else {
|
||||
log.Info("Creating table ??? (Weird query) No match in: %v", parts)
|
||||
}
|
||||
_, err = app.db.Exec(q)
|
||||
_, err := app.db.Exec(q)
|
||||
if err != nil {
|
||||
log.Error("%s", err)
|
||||
} else {
|
||||
|
@ -880,7 +974,7 @@ func adminInitDatabase(app *App) error {
|
|||
|
||||
// Set up migrations table
|
||||
log.Info("Initializing appmigrations table...")
|
||||
err = migrations.SetInitialMigrations(migrations.NewDatastore(app.db.DB, app.db.driverName))
|
||||
err := migrations.SetInitialMigrations(migrations.NewDatastore(app.db.DB, app.db.driverName))
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to set initial migrations: %v", err)
|
||||
}
|
||||
|
@ -895,21 +989,12 @@ func adminInitDatabase(app *App) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func adminDumpDatabase(app *App) error {
|
||||
if app.db.driverName != driverMySQL {
|
||||
return fmt.Errorf("database dump only supports %s driver right now", driverMySQL)
|
||||
// ServerUserAgent returns a User-Agent string to use in external requests. The
|
||||
// hostName parameter may be left empty.
|
||||
func ServerUserAgent(hostName string) string {
|
||||
hostUAStr := ""
|
||||
if hostName != "" {
|
||||
hostUAStr = "; +" + hostName
|
||||
}
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
dumper, err := mysqldump.Register(app.db.DB, out)
|
||||
if err != nil {
|
||||
fmt.Println("Error registering database:", err)
|
||||
return err
|
||||
}
|
||||
err = dumper.Dump()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s", out)
|
||||
return nil
|
||||
return "Go (" + serverSoftware + "/" + softwareVer + hostUAStr + ")"
|
||||
}
|
||||
|
|
2
auth.go
2
auth.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
* Copyright © 2018 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -11,7 +11,8 @@
|
|||
package author
|
||||
|
||||
import (
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
@ -113,10 +114,17 @@ func IsValidUsername(cfg *config.Config, username string) bool {
|
|||
// Username is invalid if page with the same name exists. So traverse
|
||||
// available pages, adding them to reservedUsernames map that'll be checked
|
||||
// later.
|
||||
filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, "pages"), func(path string, i os.FileInfo, err error) error {
|
||||
err := filepath.Walk(filepath.Join(cfg.Server.PagesParentDir, "pages"), func(path string, i os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reservedUsernames[i.Name()] = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("[IMPORTANT WARNING]: Could not determine IsValidUsername! %s", err)
|
||||
return false
|
||||
}
|
||||
|
||||
// Username is invalid if it is reserved!
|
||||
if _, reserved := reservedUsernames[username]; reserved {
|
||||
|
|
105
bindata-lib.go
105
bindata-lib.go
|
@ -1,105 +0,0 @@
|
|||
// +build wflib
|
||||
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func bindata_read(data []byte, name string) ([]byte, error) {
|
||||
gz, err := gzip.NewReader(bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Read %q: %v", name, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = io.Copy(&buf, gz)
|
||||
gz.Close()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Read %q: %v", name, err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
var _schema_sql = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xd4\x59\x5f\x6f\xa3\x38\x10\x7f\xef\xa7\xf0\xdb\xa6\x52\x23\x6d\x7a\xdd\xaa\xba\xd3\x3e\x64\x53\x76\x2f\xba\x94\xee\x25\x44\xba\x7d\x02\x03\x93\xd4\xaa\xb1\x91\x6d\x92\xe6\xdb\x9f\x8c\x49\x08\x86\x24\xd0\xdb\x3b\x71\x7d\x2a\xcc\x6f\x8c\xfd\x9b\x3f\x9e\x99\x0c\x87\x57\xc3\x21\x7a\xc4\x0a\x87\x58\xc2\xaf\x28\xd8\x0a\xa2\x60\x25\x00\xe8\x2e\xb8\x1a\x0e\xaf\xb4\x78\xf8\xce\x3f\xad\xac\xf5\x3d\x1c\x52\x40\x52\x89\x2c\x52\x99\x00\xb4\xe2\x02\xa9\xfc\x5d\x80\xa3\x08\xa4\x54\xfc\x15\x98\x34\xdf\x9b\xcc\x9d\xb1\xe7\x20\x6f\xfc\x65\xe6\xa0\xe9\x57\xe4\x3e\x7b\xc8\xf9\x6b\xba\xf0\x16\x16\x1a\x0d\xae\x10\x0a\xf2\x87\x00\x85\x84\x61\xb1\x1b\x8c\xee\xaf\x73\x05\x77\x39\x9b\xdd\x68\x71\x26\x41\xf8\x24\x0e\x10\x61\x6a\x60\x0b\x65\x16\xf3\x00\x29\xc2\x76\x5a\x3a\x2a\xa5\xe8\xd1\xf9\x3a\x5e\xce\x3c\xf4\xe1\xe3\x87\x1c\xc9\x19\xf8\x8a\x24\xd0\x0e\x1d\x09\xc0\x0a\xe2\x00\xc5\x58\x81\x56\xab\x43\x27\xcb\xf9\xdc\x71\x3d\xdf\x9b\x3e\x39\x0b\x6f\xfc\xf4\x3d\x57\x84\xb7\x94\x08\x90\x47\x8a\x7b\x7c\xf5\x40\x78\x0d\x4c\x05\x68\x83\x45\xf4\x82\xc5\xe0\xf6\xd3\xa7\xeb\x1a\xf2\xfb\x7c\xfa\x34\x9e\xff\x40\x7f\x38\x3f\xd0\xa0\xa0\xe9\xfa\xea\x1a\x39\xee\xb7\xa9\xeb\x7c\x9e\x32\xc6\x1f\xbf\x94\xfb\xf9\x7d\x3c\x5f\x38\xde\x67\x8a\x15\x61\xa3\xdf\xfe\x75\xb3\xa7\x69\xc4\x99\xd2\xa7\xb8\x6c\xf4\x12\x6b\x4c\xae\xcd\xb9\x3f\xfa\x2f\xb6\x4d\x0f\xd0\x04\x62\x92\x25\x0a\xde\x54\x7e\xb8\xf1\xc4\x73\xe6\x68\xe1\x78\x28\x53\xab\x07\x34\x79\x9e\xcd\xf4\x17\xf5\x83\x1f\x12\x66\x79\x4d\x1a\xbf\xcb\x80\x55\xce\x49\xdc\x2b\xc2\x13\xb2\x16\x58\x11\xde\x18\x68\x16\xc0\x10\xbd\x01\x21\x09\x67\x26\x78\x46\x23\x8b\x69\x03\x6f\x64\x29\x97\x0b\x90\x19\x55\x01\xca\x4d\xb0\x97\xf4\x85\x8f\x88\x53\x0a\x91\x3e\x2c\x56\x4a\x90\x30\x53\xd0\x22\xff\x34\x6a\x19\xae\x4a\xd1\xc9\x74\x73\xd0\x29\xdd\x77\x74\xfb\x60\x81\x36\x98\x66\x60\x85\x76\xdd\x7f\x93\xf0\xae\xe2\xc2\x49\x78\x57\xf3\xe2\xaa\x33\x56\xf7\x77\x73\xb4\x99\xde\xf8\x68\xb9\xc5\x57\xd8\x75\xb2\x46\x8e\x6f\x6d\x87\x34\x0b\x29\x89\xfc\x57\xd8\x05\x28\xa4\x3c\xb4\xa4\x82\x6c\xb0\x82\x13\xe2\x73\xa4\xf6\x90\xc8\x14\x4b\xb9\xe5\x22\xee\xc4\x66\xa9\xd4\x9e\xd2\x42\x25\x40\xb9\xd7\xde\x7f\xbc\xfe\x3f\xb3\x26\x20\x26\x02\x22\xd5\x89\xb5\x52\xc9\xb0\x96\x0a\xd8\xf8\x98\x12\x2c\x8f\xc2\xfd\xa3\x45\x4c\xc0\x60\x7b\x11\x54\x65\xef\x68\xdd\x1e\x52\xd7\x89\x32\x79\x74\xa1\x5b\x5e\x85\xc6\x4b\xef\xd9\x9f\xba\x93\xb9\xf3\xe4\xb8\x9e\xc9\x9f\x0d\x3c\xb5\x4f\x8d\xb5\x4a\x4a\x11\x45\x7f\x4e\xa6\x0d\x62\x90\x91\x20\xa9\xca\x2f\xcb\xc3\xfe\xee\x3b\xed\xaf\x5a\x99\xaa\x1d\x05\x5f\xbe\x00\x14\x17\xa8\x79\x9b\x7f\xa4\xb8\x51\x5b\xaf\x9c\xab\xae\xb8\x48\xf0\x51\xc9\xf8\x50\x2f\x18\x4d\xe6\x8b\x76\x8d\x35\xae\xa9\x82\xb7\xec\x4c\x35\xbd\x21\xb0\xf5\x23\x9e\xe9\xe2\xab\x41\x5e\xaf\x8d\xf4\xdb\xa5\x3b\xfd\x73\xe9\xe4\x2f\xf7\xf6\x1d\x04\x3d\xf3\xee\x94\xcb\x36\xa9\xc0\xc0\x4a\x8f\x2e\x9c\xc0\xee\x39\x68\xb6\xb6\x7c\xb8\x66\x88\x84\xc7\x64\xb5\xf3\x8b\xd6\xc6\xd4\xb9\xb7\x0d\x38\xed\x07\x3e\x4e\x53\xc0\x02\xb3\x08\x0a\xe8\x5d\x53\x67\xc2\xb8\x48\x4c\x73\x42\x31\x5b\x67\x78\xbd\x47\x37\xad\x2b\x14\xad\x38\xc1\x4f\xf0\x94\xda\x12\xcd\x97\x4a\xfd\x4b\x84\x31\x88\xfd\x94\x4b\x62\xa2\xeb\xe8\x8b\x4b\x77\x31\xfd\xe6\x3a\x8f\x0d\x8b\xef\x1b\x30\x5d\x95\x4a\x85\x93\xb4\x6d\x07\x76\xa8\xfc\x3b\x6b\x5e\x70\x7f\x3b\xdd\xfc\x93\xec\x70\xe8\x71\xba\x25\x82\x8e\xe1\x48\x62\xdf\x38\x6b\xbd\x78\xcc\xdf\xd7\x14\x4a\xa3\x0f\xca\xff\x6f\x0e\x6b\xe7\x98\xc2\x73\x0a\xd4\xde\x8f\x6e\x7a\xd5\x2b\x09\x48\xb8\x82\x15\xa7\x94\x6f\x5b\xc4\x7d\x15\x7e\xb2\x64\xaa\xf5\x4f\x46\xcf\xaf\x4c\x28\x6a\xa0\xd3\xa3\x84\xcb\x25\xbe\xf5\x81\x9e\xf1\xab\xb7\xd5\xae\xce\xb7\xf0\xf5\x21\x40\x7e\x75\x77\xe7\xf6\x6c\x1f\x70\x39\x3e\x8c\xc5\x0f\x1e\xdf\x7f\xb6\x3b\x51\x6d\xd7\x66\xc7\xec\x35\x16\x67\x91\xe2\x86\x8a\xd3\x56\x21\x2c\xe4\x6f\xe7\x00\xf2\x05\x0b\x88\xfd\x4b\xb8\xcb\xb6\xb1\xe2\x6f\x50\x6e\xaf\x37\x76\xd1\x24\x77\x99\x3d\x58\x78\x63\x9d\xb3\xe3\xcd\x86\x79\xc3\xfd\xdd\x7f\x34\x6e\xd8\x6f\xac\x97\x83\x06\xbd\x39\xc2\x36\xa4\x99\xf7\x8a\xd8\x2a\xe7\x6c\x8a\xab\x75\x4e\x7d\x44\x86\xdf\x74\x42\x90\x01\x92\x09\xa6\xf4\x64\x2d\x74\x36\xc9\xb7\x99\x0a\x13\x86\x23\x45\x36\xcd\xf3\xe9\x3e\xd1\xde\xd2\xd1\x3b\x76\x86\x5a\x85\xe1\x04\xde\xdd\x1c\x5e\x1a\x66\x54\x57\x32\x7c\x1d\x16\x32\x8f\xf5\x75\x20\xc1\x84\xe6\x5b\x2a\x7e\x9d\x68\x9c\xd3\xbf\xfb\xd7\x82\xcb\x59\xb0\xa4\x65\x50\xfe\xdf\xab\x28\x94\x26\xce\xe2\x53\x61\x78\x90\x17\xee\x90\x3f\xf9\x27\xc3\xf1\xe4\x7d\xdf\xfa\xcc\x7f\x07\x00\x00\xff\xff\xbe\x79\x68\xa8\x10\x1b\x00\x00")
|
||||
|
||||
func schema_sql() ([]byte, error) {
|
||||
return bindata_read(
|
||||
_schema_sql,
|
||||
"schema.sql",
|
||||
)
|
||||
}
|
||||
|
||||
// Asset loads and returns the asset for the given name.
|
||||
// It returns an error if the asset could not be found or
|
||||
// could not be loaded.
|
||||
func Asset(name string) ([]byte, error) {
|
||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
||||
if f, ok := _bindata[cannonicalName]; ok {
|
||||
return f()
|
||||
}
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
|
||||
// AssetNames returns the names of the assets.
|
||||
func AssetNames() []string {
|
||||
names := make([]string, 0, len(_bindata))
|
||||
for name := range _bindata {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// _bindata is a table, holding each asset generator, mapped to its name.
|
||||
var _bindata = map[string]func() ([]byte, error){
|
||||
"schema.sql": schema_sql,
|
||||
}
|
||||
// AssetDir returns the file names below a certain
|
||||
// directory embedded in the file by go-bindata.
|
||||
// For example if you run go-bindata on data/... and data contains the
|
||||
// following hierarchy:
|
||||
// data/
|
||||
// foo.txt
|
||||
// img/
|
||||
// a.png
|
||||
// b.png
|
||||
// then AssetDir("data") would return []string{"foo.txt", "img"}
|
||||
// AssetDir("data/img") would return []string{"a.png", "b.png"}
|
||||
// AssetDir("foo.txt") and AssetDir("notexist") would return an error
|
||||
// AssetDir("") will return []string{"data"}.
|
||||
func AssetDir(name string) ([]string, error) {
|
||||
node := _bintree
|
||||
if len(name) != 0 {
|
||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
||||
pathList := strings.Split(cannonicalName, "/")
|
||||
for _, p := range pathList {
|
||||
node = node.Children[p]
|
||||
if node == nil {
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if node.Func != nil {
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
rv := make([]string, 0, len(node.Children))
|
||||
for name := range node.Children {
|
||||
rv = append(rv, name)
|
||||
}
|
||||
return rv, nil
|
||||
}
|
||||
|
||||
type _bintree_t struct {
|
||||
Func func() ([]byte, error)
|
||||
Children map[string]*_bintree_t
|
||||
}
|
||||
var _bintree = &_bintree_t{nil, map[string]*_bintree_t{
|
||||
"schema.sql": &_bintree_t{schema_sql, map[string]*_bintree_t{
|
||||
}},
|
||||
}}
|
2
cache.go
2
cache.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
* Copyright © 2018 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
60
cmd/writefreely/config.go
Normal file
60
cmd/writefreely/config.go
Normal file
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright © 2020-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/writefreely/writefreely"
|
||||
)
|
||||
|
||||
var (
|
||||
cmdConfig cli.Command = cli.Command{
|
||||
Name: "config",
|
||||
Usage: "config management tools",
|
||||
Subcommands: []*cli.Command{
|
||||
&cmdConfigGenerate,
|
||||
&cmdConfigInteractive,
|
||||
},
|
||||
}
|
||||
|
||||
cmdConfigGenerate cli.Command = cli.Command{
|
||||
Name: "generate",
|
||||
Aliases: []string{"gen"},
|
||||
Usage: "Generate a basic configuration",
|
||||
Action: genConfigAction,
|
||||
}
|
||||
|
||||
cmdConfigInteractive cli.Command = cli.Command{
|
||||
Name: "start",
|
||||
Usage: "Interactive configuration process",
|
||||
Action: interactiveConfigAction,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "sections",
|
||||
Value: "server db app",
|
||||
Usage: "Which sections of the configuration to go through\n" +
|
||||
"valid values of sections flag are any combination of 'server', 'db' and 'app' \n" +
|
||||
"example: writefreely config start --sections \"db app\"",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func genConfigAction(c *cli.Context) error {
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
return writefreely.CreateConfig(app)
|
||||
}
|
||||
|
||||
func interactiveConfigAction(c *cli.Context) error {
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
writefreely.DoConfig(app, c.String("sections"))
|
||||
return nil
|
||||
}
|
49
cmd/writefreely/db.go
Normal file
49
cmd/writefreely/db.go
Normal file
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright © 2020-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/writefreely/writefreely"
|
||||
)
|
||||
|
||||
var (
|
||||
cmdDB cli.Command = cli.Command{
|
||||
Name: "db",
|
||||
Usage: "db management tools",
|
||||
Subcommands: []*cli.Command{
|
||||
&cmdDBInit,
|
||||
&cmdDBMigrate,
|
||||
},
|
||||
}
|
||||
|
||||
cmdDBInit cli.Command = cli.Command{
|
||||
Name: "init",
|
||||
Usage: "Initialize Database",
|
||||
Action: initDBAction,
|
||||
}
|
||||
|
||||
cmdDBMigrate cli.Command = cli.Command{
|
||||
Name: "migrate",
|
||||
Usage: "Migrate Database",
|
||||
Action: migrateDBAction,
|
||||
}
|
||||
)
|
||||
|
||||
func initDBAction(c *cli.Context) error {
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
return writefreely.CreateSchema(app)
|
||||
}
|
||||
|
||||
func migrateDBAction(c *cli.Context) error {
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
return writefreely.Migrate(app)
|
||||
}
|
38
cmd/writefreely/keys.go
Normal file
38
cmd/writefreely/keys.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright © 2020-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/writefreely/writefreely"
|
||||
)
|
||||
|
||||
var (
|
||||
cmdKeys cli.Command = cli.Command{
|
||||
Name: "keys",
|
||||
Usage: "key management tools",
|
||||
Subcommands: []*cli.Command{
|
||||
&cmdGenerateKeys,
|
||||
},
|
||||
}
|
||||
|
||||
cmdGenerateKeys cli.Command = cli.Command{
|
||||
Name: "generate",
|
||||
Aliases: []string{"gen"},
|
||||
Usage: "Generate encryption and authentication keys",
|
||||
Action: genKeysAction,
|
||||
}
|
||||
)
|
||||
|
||||
func genKeysAction(c *cli.Context) error {
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
return writefreely.GenerateKeyFiles(app)
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -11,130 +11,156 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely"
|
||||
"github.com/writefreely/writefreely"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// General options usable with other commands
|
||||
debugPtr := flag.Bool("debug", false, "Enables debug logging.")
|
||||
configFile := flag.String("c", "config.ini", "The configuration file to use")
|
||||
cli.VersionPrinter = func(c *cli.Context) {
|
||||
fmt.Printf("%s\n", c.App.Version)
|
||||
}
|
||||
app := &cli.App{
|
||||
Name: "WriteFreely",
|
||||
Usage: "A beautifully pared-down blogging platform",
|
||||
Version: writefreely.FormatVersion(),
|
||||
Action: legacyActions, // legacy due to use of flags for switching actions
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "create-config",
|
||||
Value: false,
|
||||
Usage: "Generate a basic configuration",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "config",
|
||||
Value: false,
|
||||
Usage: "Interactive configuration process",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "sections",
|
||||
Value: "server db app",
|
||||
Usage: "Which sections of the configuration to go through (requires --config)\n" +
|
||||
"valid values are any combination of 'server', 'db' and 'app' \n" +
|
||||
"example: writefreely --config --sections \"db app\"",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "gen-keys",
|
||||
Value: false,
|
||||
Usage: "Generate encryption and authentication keys",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "init-db",
|
||||
Value: false,
|
||||
Usage: "Initialize app database",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "migrate",
|
||||
Value: false,
|
||||
Usage: "Migrate the database",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "create-admin",
|
||||
Usage: "Create an admin with the given username:password",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "create-user",
|
||||
Usage: "Create a regular user with the given username:password",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "delete-user",
|
||||
Usage: "Delete a user with the given username",
|
||||
Hidden: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "reset-pass",
|
||||
Usage: "Reset the given user's password",
|
||||
Hidden: true,
|
||||
},
|
||||
}, // legacy flags (set to hidden to eventually switch to bash-complete compatible format)
|
||||
}
|
||||
|
||||
// Setup actions
|
||||
createConfig := flag.Bool("create-config", false, "Creates a basic configuration and exits")
|
||||
doConfig := flag.Bool("config", false, "Run the configuration process")
|
||||
configSections := flag.String("sections", "server db app", "Which sections of the configuration to go through (requires --config), "+
|
||||
"valid values are any combination of 'server', 'db' and 'app' "+
|
||||
"example: writefreely --config --sections \"db app\"")
|
||||
genKeys := flag.Bool("gen-keys", false, "Generate encryption and authentication keys")
|
||||
createSchema := flag.Bool("init-db", false, "Initialize app database")
|
||||
migrate := flag.Bool("migrate", false, "Migrate the database")
|
||||
defaultFlags := []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "c",
|
||||
Value: "config.ini",
|
||||
Usage: "Load configuration from `FILE`",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "debug",
|
||||
Value: false,
|
||||
Usage: "Enables debug logging",
|
||||
},
|
||||
}
|
||||
|
||||
// Admin actions
|
||||
createAdmin := flag.String("create-admin", "", "Create an admin with the given username:password")
|
||||
createUser := flag.String("create-user", "", "Create a regular user with the given username:password")
|
||||
deleteUsername := flag.String("delete-user", "", "Delete a user with the given username")
|
||||
resetPassUser := flag.String("reset-pass", "", "Reset the given user's password")
|
||||
outputVersion := flag.Bool("v", false, "Output the current version")
|
||||
dump := flag.Bool("dump", false, "Dump all database data (MySQL-only)")
|
||||
flag.Parse()
|
||||
app.Flags = append(app.Flags, defaultFlags...)
|
||||
|
||||
app := writefreely.NewApp(*configFile)
|
||||
app.Commands = []*cli.Command{
|
||||
&cmdUser,
|
||||
&cmdDB,
|
||||
&cmdConfig,
|
||||
&cmdKeys,
|
||||
&cmdServe,
|
||||
}
|
||||
|
||||
if *outputVersion {
|
||||
writefreely.OutputVersion()
|
||||
os.Exit(0)
|
||||
} else if *dump {
|
||||
err := writefreely.Dump(app)
|
||||
err := app.Run(os.Args)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *createConfig {
|
||||
err := writefreely.CreateConfig(app)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *doConfig {
|
||||
writefreely.DoConfig(app, *configSections)
|
||||
os.Exit(0)
|
||||
} else if *genKeys {
|
||||
err := writefreely.GenerateKeyFiles(app)
|
||||
|
||||
func legacyActions(c *cli.Context) error {
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
|
||||
switch true {
|
||||
case c.IsSet("create-config"):
|
||||
return writefreely.CreateConfig(app)
|
||||
case c.IsSet("config"):
|
||||
writefreely.DoConfig(app, c.String("sections"))
|
||||
return nil
|
||||
case c.IsSet("gen-keys"):
|
||||
return writefreely.GenerateKeyFiles(app)
|
||||
case c.IsSet("init-db"):
|
||||
return writefreely.CreateSchema(app)
|
||||
case c.IsSet("migrate"):
|
||||
return writefreely.Migrate(app)
|
||||
case c.IsSet("create-admin"):
|
||||
username, password, err := parseCredentials(c.String("create-admin"))
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *createSchema {
|
||||
err := writefreely.CreateSchema(app)
|
||||
return writefreely.CreateUser(app, username, password, true)
|
||||
case c.IsSet("create-user"):
|
||||
username, password, err := parseCredentials(c.String("create-user"))
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *createAdmin != "" {
|
||||
username, password, err := userPass(*createAdmin, true)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
err = writefreely.CreateUser(app, username, password, true)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *createUser != "" {
|
||||
username, password, err := userPass(*createUser, false)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
err = writefreely.CreateUser(app, username, password, false)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *resetPassUser != "" {
|
||||
err := writefreely.ResetPassword(app, *resetPassUser)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *deleteUsername != "" {
|
||||
err := writefreely.DoDeleteAccount(app, *deleteUsername)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
} else if *migrate {
|
||||
err := writefreely.Migrate(app)
|
||||
if err != nil {
|
||||
log.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
return writefreely.CreateUser(app, username, password, false)
|
||||
case c.IsSet("delete-user"):
|
||||
return writefreely.DoDeleteAccount(app, c.String("delete-user"))
|
||||
case c.IsSet("reset-pass"):
|
||||
return writefreely.ResetPassword(app, c.String("reset-pass"))
|
||||
}
|
||||
|
||||
// Initialize the application
|
||||
var err error
|
||||
log.Info("Starting %s...", writefreely.FormatVersion())
|
||||
app, err = writefreely.Initialize(app, *debugPtr)
|
||||
app, err = writefreely.Initialize(app, c.Bool("debug"))
|
||||
if err != nil {
|
||||
log.Error("%s", err)
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
|
||||
// Set app routes
|
||||
|
@ -144,20 +170,14 @@ func main() {
|
|||
|
||||
// Serve the application
|
||||
writefreely.Serve(app, r)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func userPass(credStr string, isAdmin bool) (user string, pass string, err error) {
|
||||
creds := strings.Split(credStr, ":")
|
||||
func parseCredentials(credentialString string) (string, string, error) {
|
||||
creds := strings.Split(credentialString, ":")
|
||||
if len(creds) != 2 {
|
||||
c := "user"
|
||||
if isAdmin {
|
||||
c = "admin"
|
||||
return "", "", fmt.Errorf("invalid format for passed credentials, must be username:password")
|
||||
}
|
||||
err = fmt.Errorf("usage: writefreely --create-%s username:password", c)
|
||||
return
|
||||
}
|
||||
|
||||
user = creds[0]
|
||||
pass = creds[1]
|
||||
return
|
||||
return creds[0], creds[1], nil
|
||||
}
|
||||
|
|
96
cmd/writefreely/user.go
Normal file
96
cmd/writefreely/user.go
Normal file
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* Copyright © 2020-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/writefreely/writefreely"
|
||||
)
|
||||
|
||||
var (
|
||||
cmdUser cli.Command = cli.Command{
|
||||
Name: "user",
|
||||
Usage: "user management tools",
|
||||
Subcommands: []*cli.Command{
|
||||
&cmdAddUser,
|
||||
&cmdDelUser,
|
||||
&cmdResetPass,
|
||||
// TODO: possibly add a user list command
|
||||
},
|
||||
}
|
||||
|
||||
cmdAddUser cli.Command = cli.Command{
|
||||
Name: "create",
|
||||
Usage: "Add new user",
|
||||
Aliases: []string{"a", "add"},
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "admin",
|
||||
Value: false,
|
||||
Usage: "Create admin user",
|
||||
},
|
||||
},
|
||||
Action: addUserAction,
|
||||
}
|
||||
|
||||
cmdDelUser cli.Command = cli.Command{
|
||||
Name: "delete",
|
||||
Usage: "Delete user",
|
||||
Aliases: []string{"del", "d"},
|
||||
Action: delUserAction,
|
||||
}
|
||||
|
||||
cmdResetPass cli.Command = cli.Command{
|
||||
Name: "reset-pass",
|
||||
Usage: "Reset user's password",
|
||||
Aliases: []string{"resetpass", "reset"},
|
||||
Action: resetPassAction,
|
||||
}
|
||||
)
|
||||
|
||||
func addUserAction(c *cli.Context) error {
|
||||
credentials := ""
|
||||
if c.NArg() > 0 {
|
||||
credentials = c.Args().Get(0)
|
||||
} else {
|
||||
return fmt.Errorf("No user passed. Example: writefreely user add [USER]:[PASSWORD]")
|
||||
}
|
||||
username, password, err := parseCredentials(credentials)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
return writefreely.CreateUser(app, username, password, c.Bool("admin"))
|
||||
}
|
||||
|
||||
func delUserAction(c *cli.Context) error {
|
||||
username := ""
|
||||
if c.NArg() > 0 {
|
||||
username = c.Args().Get(0)
|
||||
} else {
|
||||
return fmt.Errorf("No user passed. Example: writefreely user delete [USER]")
|
||||
}
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
return writefreely.DoDeleteAccount(app, username)
|
||||
}
|
||||
|
||||
func resetPassAction(c *cli.Context) error {
|
||||
username := ""
|
||||
if c.NArg() > 0 {
|
||||
username = c.Args().Get(0)
|
||||
} else {
|
||||
return fmt.Errorf("No user passed. Example: writefreely user reset-pass [USER]")
|
||||
}
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
return writefreely.ResetPassword(app, username)
|
||||
}
|
48
cmd/writefreely/web.go
Normal file
48
cmd/writefreely/web.go
Normal file
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright © 2020-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/writefreely"
|
||||
)
|
||||
|
||||
var (
|
||||
cmdServe cli.Command = cli.Command{
|
||||
Name: "serve",
|
||||
Aliases: []string{"web"},
|
||||
Usage: "Run web application",
|
||||
Action: serveAction,
|
||||
}
|
||||
)
|
||||
|
||||
func serveAction(c *cli.Context) error {
|
||||
// Initialize the application
|
||||
app := writefreely.NewApp(c.String("c"))
|
||||
var err error
|
||||
log.Info("Starting %s...", writefreely.FormatVersion())
|
||||
app, err = writefreely.Initialize(app, c.Bool("debug"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set app routes
|
||||
r := mux.NewRouter()
|
||||
writefreely.InitRoutes(app, r)
|
||||
app.InitStaticRoutes(r)
|
||||
|
||||
// Serve the application
|
||||
writefreely.Serve(app, r)
|
||||
|
||||
return nil
|
||||
}
|
393
collections.go
393
collections.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2022 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -24,15 +24,26 @@ import (
|
|||
"unicode"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
stripmd "github.com/writeas/go-strip-markdown/v2"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/activitystreams"
|
||||
"github.com/writeas/web-core/auth"
|
||||
"github.com/writeas/web-core/bots"
|
||||
"github.com/writeas/web-core/i18n"
|
||||
"github.com/writeas/web-core/log"
|
||||
waposts "github.com/writeas/web-core/posts"
|
||||
"github.com/writeas/writefreely/author"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writeas/writefreely/page"
|
||||
"github.com/writeas/web-core/posts"
|
||||
"github.com/writefreely/writefreely/author"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
"github.com/writefreely/writefreely/page"
|
||||
"github.com/writefreely/writefreely/spam"
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
const (
|
||||
collAttrLetterReplyTo = "letter_reply_to"
|
||||
|
||||
collMaxLengthTitle = 255
|
||||
collMaxLengthDescription = 160
|
||||
)
|
||||
|
||||
type (
|
||||
|
@ -47,6 +58,7 @@ type (
|
|||
Language string `schema:"lang" json:"lang,omitempty"`
|
||||
StyleSheet string `datastore:"style_sheet" schema:"style_sheet" json:"style_sheet"`
|
||||
Script string `datastore:"script" schema:"script" json:"script,omitempty"`
|
||||
Signature string `datastore:"post_signature" schema:"signature" json:"-"`
|
||||
Public bool `datastore:"public" json:"public"`
|
||||
Visibility collVisibility `datastore:"private" json:"-"`
|
||||
Format string `datastore:"format" json:"format,omitempty"`
|
||||
|
@ -55,6 +67,9 @@ type (
|
|||
PublicOwner bool `datastore:"public_owner" json:"-"`
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
Monetization string `json:"monetization_pointer,omitempty"`
|
||||
Verification string `json:"verification_link"`
|
||||
|
||||
db *datastore
|
||||
hostName string
|
||||
}
|
||||
|
@ -68,11 +83,20 @@ type (
|
|||
DisplayCollection struct {
|
||||
*CollectionObj
|
||||
Prefix string
|
||||
NavSuffix string
|
||||
IsTopLevel bool
|
||||
CurrentPage int
|
||||
TotalPages int
|
||||
Silenced bool
|
||||
}
|
||||
|
||||
CollectionNav struct {
|
||||
*Collection
|
||||
Path string
|
||||
SingleUser bool
|
||||
CanPost bool
|
||||
}
|
||||
|
||||
SubmittedCollection struct {
|
||||
// Data used for updating a given collection
|
||||
ID int64
|
||||
|
@ -83,14 +107,19 @@ type (
|
|||
Privacy int `schema:"privacy" json:"privacy"`
|
||||
Pass string `schema:"password" json:"password"`
|
||||
MathJax bool `schema:"mathjax" json:"mathjax"`
|
||||
EmailSubs bool `schema:"email_subs" json:"email_subs"`
|
||||
Handle string `schema:"handle" json:"handle"`
|
||||
|
||||
// Actual collection values updated in the DB
|
||||
Alias *string `schema:"alias" json:"alias"`
|
||||
Title *string `schema:"title" json:"title"`
|
||||
Description *string `schema:"description" json:"description"`
|
||||
StyleSheet *sql.NullString `schema:"style_sheet" json:"style_sheet"`
|
||||
Script *sql.NullString `schema:"script" json:"script"`
|
||||
StyleSheet *string `schema:"style_sheet" json:"style_sheet"`
|
||||
Script *string `schema:"script" json:"script"`
|
||||
Signature *string `schema:"signature" json:"signature"`
|
||||
Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"`
|
||||
Verification *string `schema:"verification_link" json:"verification_link"`
|
||||
LetterReply *string `schema:"letter_reply" json:"letter_reply"`
|
||||
Visibility *int `schema:"visibility" json:"public"`
|
||||
Format *sql.NullString `schema:"format" json:"format"`
|
||||
}
|
||||
|
@ -105,6 +134,8 @@ type (
|
|||
|
||||
// User-related fields
|
||||
isCollOwner bool
|
||||
|
||||
isAuthorized bool
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -175,6 +206,11 @@ func (c *Collection) NewFormat() *CollectionFormat {
|
|||
return cf
|
||||
}
|
||||
|
||||
func (c *Collection) IsInstanceColl() bool {
|
||||
ur, _ := url.Parse(c.hostName)
|
||||
return c.Alias == ur.Host
|
||||
}
|
||||
|
||||
func (c *Collection) IsUnlisted() bool {
|
||||
return c.Visibility == 0
|
||||
}
|
||||
|
@ -224,13 +260,17 @@ func (c *Collection) DisplayCanonicalURL() string {
|
|||
if p == "/" {
|
||||
p = ""
|
||||
}
|
||||
return u.Hostname() + p
|
||||
d := u.Hostname()
|
||||
d, _ = idna.ToUnicode(d)
|
||||
return d + p
|
||||
}
|
||||
|
||||
// RedirectingCanonicalURL returns the fully-qualified canonical URL for the Collection, with a trailing slash. The
|
||||
// hostName field needs to be populated for this to work correctly.
|
||||
func (c *Collection) RedirectingCanonicalURL(isRedir bool) string {
|
||||
if c.hostName == "" {
|
||||
// If this is true, the human programmers screwed up. So ask for a bug report and fail, fail, fail
|
||||
log.Error("[PROGRAMMER ERROR] WARNING: Collection.hostName is empty! Federation and many other things will fail! If you're seeing this in the wild, please report this bug and let us know what you were doing just before this: https://github.com/writeas/writefreely/issues/new?template=bug_report.md")
|
||||
log.Error("[PROGRAMMER ERROR] WARNING: Collection.hostName is empty! Federation and many other things will fail! If you're seeing this in the wild, please report this bug and let us know what you were doing just before this: https://github.com/writefreely/writefreely/issues/new?template=bug_report.md")
|
||||
}
|
||||
if isSingleUser {
|
||||
return c.hostName + "/"
|
||||
|
@ -241,16 +281,16 @@ func (c *Collection) RedirectingCanonicalURL(isRedir bool) string {
|
|||
|
||||
// PrevPageURL provides a full URL for the previous page of collection posts,
|
||||
// returning a /page/N result for pages >1
|
||||
func (c *Collection) PrevPageURL(prefix string, n int, tl bool) string {
|
||||
func (c *Collection) PrevPageURL(prefix, navSuffix string, n int, tl bool) string {
|
||||
u := ""
|
||||
if n == 2 {
|
||||
// Previous page is 1; no need for /page/ prefix
|
||||
if prefix == "" {
|
||||
u = "/"
|
||||
u = navSuffix + "/"
|
||||
}
|
||||
// Else leave off trailing slash
|
||||
} else {
|
||||
u = fmt.Sprintf("/page/%d", n-1)
|
||||
u = fmt.Sprintf("%s/page/%d", navSuffix, n-1)
|
||||
}
|
||||
|
||||
if tl {
|
||||
|
@ -260,11 +300,12 @@ func (c *Collection) PrevPageURL(prefix string, n int, tl bool) string {
|
|||
}
|
||||
|
||||
// NextPageURL provides a full URL for the next page of collection posts
|
||||
func (c *Collection) NextPageURL(prefix string, n int, tl bool) string {
|
||||
func (c *Collection) NextPageURL(prefix, navSuffix string, n int, tl bool) string {
|
||||
|
||||
if tl {
|
||||
return fmt.Sprintf("/page/%d", n+1)
|
||||
return fmt.Sprintf("%s/page/%d", navSuffix, n+1)
|
||||
}
|
||||
return fmt.Sprintf("/%s%s/page/%d", prefix, c.Alias, n+1)
|
||||
return fmt.Sprintf("/%s%s%s/page/%d", prefix, c.Alias, navSuffix, n+1)
|
||||
}
|
||||
|
||||
func (c *Collection) DisplayTitle() string {
|
||||
|
@ -338,6 +379,51 @@ func (c *Collection) RenderMathJax() bool {
|
|||
return c.db.CollectionHasAttribute(c.ID, "render_mathjax")
|
||||
}
|
||||
|
||||
func (c *Collection) EmailSubsEnabled() bool {
|
||||
return c.db.CollectionHasAttribute(c.ID, "email_subs")
|
||||
}
|
||||
|
||||
func (c *Collection) MonetizationURL() string {
|
||||
if c.Monetization == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.Replace(c.Monetization, "$", "https://", 1)
|
||||
}
|
||||
|
||||
// DisplayDescription returns the description with rendered Markdown and HTML.
|
||||
func (c *Collection) DisplayDescription() *template.HTML {
|
||||
if c.Description == "" {
|
||||
s := template.HTML("")
|
||||
return &s
|
||||
}
|
||||
t := template.HTML(posts.ApplyBasicAccessibleMarkdown([]byte(c.Description)))
|
||||
return &t
|
||||
}
|
||||
|
||||
// PlainDescription returns the description with all Markdown and HTML removed.
|
||||
func (c *Collection) PlainDescription() string {
|
||||
if c.Description == "" {
|
||||
return ""
|
||||
}
|
||||
desc := stripHTMLWithoutEscaping(c.Description)
|
||||
desc = stripmd.Strip(desc)
|
||||
return desc
|
||||
}
|
||||
|
||||
func (c CollectionPage) DisplayMonetization() string {
|
||||
return displayMonetization(c.Monetization, c.Alias)
|
||||
}
|
||||
|
||||
func (c *DisplayCollection) Direction() string {
|
||||
if c.Language == "" {
|
||||
return "auto"
|
||||
}
|
||||
if i18n.LangIsRTL(c.Language) {
|
||||
return "rtl"
|
||||
}
|
||||
return "ltr"
|
||||
}
|
||||
|
||||
func newCollection(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
reqJSON := IsJSON(r)
|
||||
alias := r.FormValue("alias")
|
||||
|
@ -447,8 +533,7 @@ func apiCheckCollectionPermissions(app *App, r *http.Request, c *Collection) (in
|
|||
|
||||
// fetchCollection handles the API endpoint for retrieving collection data.
|
||||
func fetchCollection(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
accept := r.Header.Get("Accept")
|
||||
if strings.Contains(accept, "application/activity+json") {
|
||||
if IsActivityPubRequest(r) {
|
||||
return handleFetchCollectionActivities(app, w, r)
|
||||
}
|
||||
|
||||
|
@ -523,11 +608,11 @@ func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
}
|
||||
}
|
||||
|
||||
posts, err := app.db.GetPosts(app.cfg, c, page, isCollOwner, false, false)
|
||||
ps, err := app.db.GetPosts(app.cfg, c, page, isCollOwner, false, false, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
coll := &CollectionObj{Collection: *c, Posts: posts}
|
||||
coll := &CollectionObj{Collection: *c, Posts: ps}
|
||||
app.db.GetPostsCount(coll, isCollOwner)
|
||||
// Strip non-public information
|
||||
coll.Collection.ForPublic()
|
||||
|
@ -535,7 +620,7 @@ func fetchCollectionPosts(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
// Transform post bodies if needed
|
||||
if r.FormValue("body") == "html" {
|
||||
for _, p := range *coll.Posts {
|
||||
p.Content = waposts.ApplyMarkdown([]byte(p.Content))
|
||||
p.Content = posts.ApplyMarkdown([]byte(p.Content))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -548,12 +633,45 @@ type CollectionPage struct {
|
|||
IsCustomDomain bool
|
||||
IsWelcome bool
|
||||
IsOwner bool
|
||||
IsCollLoggedIn bool
|
||||
Honeypot string
|
||||
IsSubscriber bool
|
||||
CanPin bool
|
||||
Username string
|
||||
Monetization string
|
||||
Flash template.HTML
|
||||
Collections *[]Collection
|
||||
PinnedPosts *[]PublicPost
|
||||
|
||||
IsAdmin bool
|
||||
CanInvite bool
|
||||
|
||||
// Helper field for Chorus mode
|
||||
CollAlias string
|
||||
}
|
||||
|
||||
type TagCollectionPage struct {
|
||||
CollectionPage
|
||||
Tag string
|
||||
}
|
||||
|
||||
func (tcp TagCollectionPage) PrevPageURL(prefix string, n int, tl bool) string {
|
||||
u := fmt.Sprintf("/tag:%s", tcp.Tag)
|
||||
if n > 2 {
|
||||
u += fmt.Sprintf("/page/%d", n-1)
|
||||
}
|
||||
if tl {
|
||||
return u
|
||||
}
|
||||
return "/" + prefix + tcp.Alias + u
|
||||
|
||||
}
|
||||
|
||||
func (tcp TagCollectionPage) NextPageURL(prefix string, n int, tl bool) string {
|
||||
if tl {
|
||||
return fmt.Sprintf("/tag:%s/page/%d", tcp.Tag, n+1)
|
||||
}
|
||||
return fmt.Sprintf("/%s%s/tag:%s/page/%d", prefix, tcp.Alias, tcp.Tag, n+1)
|
||||
}
|
||||
|
||||
func NewCollectionObj(c *Collection) *CollectionObj {
|
||||
|
@ -666,9 +784,9 @@ func processCollectionPermissions(app *App, cr *collectionReq, u *User, w http.R
|
|||
}
|
||||
|
||||
// See if we've authorized this collection
|
||||
authd := isAuthorizedForCollection(app, c.Alias, r)
|
||||
cr.isAuthorized = isAuthorizedForCollection(app, c.Alias, r)
|
||||
|
||||
if !authd {
|
||||
if !cr.isAuthorized {
|
||||
p := struct {
|
||||
page.StaticPage
|
||||
*CollectionObj
|
||||
|
@ -710,25 +828,28 @@ func checkUserForCollection(app *App, cr *collectionReq, r *http.Request, isPost
|
|||
return u, nil
|
||||
}
|
||||
|
||||
func newDisplayCollection(c *Collection, cr *collectionReq, page int) *DisplayCollection {
|
||||
func newDisplayCollection(c *Collection, cr *collectionReq, page int) (*DisplayCollection, error) {
|
||||
coll := &DisplayCollection{
|
||||
CollectionObj: NewCollectionObj(c),
|
||||
CurrentPage: page,
|
||||
Prefix: cr.prefix,
|
||||
IsTopLevel: isSingleUser,
|
||||
}
|
||||
c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
|
||||
return coll
|
||||
err := c.db.GetPostsCount(coll.CollectionObj, cr.isCollOwner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return coll, nil
|
||||
}
|
||||
|
||||
// getCollectionPage returns the collection page as an int. If the parsed page value is not
|
||||
// greater than 0 then the default value of 1 is returned.
|
||||
func getCollectionPage(vars map[string]string) int {
|
||||
page := 1
|
||||
var p int
|
||||
p, _ = strconv.Atoi(vars["page"])
|
||||
if p > 0 {
|
||||
page = p
|
||||
if p, _ := strconv.Atoi(vars["page"]); p > 0 {
|
||||
return p
|
||||
}
|
||||
return page
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
// handleViewCollection displays the requested Collection
|
||||
|
@ -761,18 +882,31 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
}
|
||||
|
||||
// Serve ActivityStreams data now, if requested
|
||||
if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
|
||||
if IsActivityPubRequest(r) {
|
||||
ac := c.PersonObject()
|
||||
ac.Context = []interface{}{activitystreams.Namespace}
|
||||
setCacheControl(w, apCacheTime)
|
||||
return impart.RenderActivityJSON(w, ac, http.StatusOK)
|
||||
}
|
||||
|
||||
// Fetch extra data about the Collection
|
||||
// TODO: refactor out this logic, shared in collection.go:fetchCollection()
|
||||
coll := newDisplayCollection(c, cr, page)
|
||||
coll, err := newDisplayCollection(c, cr, page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
coll.TotalPages = int(math.Ceil(float64(coll.TotalPosts) / float64(coll.Format.PostsPerPage())))
|
||||
var ct PostType
|
||||
if isArchiveView(r) {
|
||||
ct = postArch
|
||||
}
|
||||
|
||||
// FIXME: this number will be off when user has pinned posts but isn't a Pro user
|
||||
ppp := coll.Format.PostsPerPage()
|
||||
if ct == postArch {
|
||||
ppp = postsPerArchPage
|
||||
}
|
||||
|
||||
coll.TotalPages = int(math.Ceil(float64(coll.TotalPosts) / float64(ppp)))
|
||||
if coll.TotalPages > 0 && page > coll.TotalPages {
|
||||
redirURL := fmt.Sprintf("/page/%d", coll.TotalPages)
|
||||
if !app.cfg.App.SingleUser {
|
||||
|
@ -781,14 +915,21 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
return impart.HTTPError{http.StatusFound, redirURL}
|
||||
}
|
||||
|
||||
coll.Posts, _ = app.db.GetPosts(app.cfg, c, page, cr.isCollOwner, false, false)
|
||||
coll.Posts, _ = app.db.GetPosts(app.cfg, c, page, cr.isCollOwner, false, false, "")
|
||||
|
||||
// Serve collection
|
||||
displayPage := CollectionPage{
|
||||
DisplayCollection: coll,
|
||||
IsCollLoggedIn: cr.isAuthorized,
|
||||
StaticPage: pageForReq(app, r),
|
||||
IsCustomDomain: cr.isCustomDomain,
|
||||
IsWelcome: r.FormValue("greeting") != "",
|
||||
Honeypot: spam.HoneypotFieldName(),
|
||||
CollAlias: c.Alias,
|
||||
}
|
||||
flashes, _ := getSessionFlashes(app, w, r, nil)
|
||||
for _, f := range flashes {
|
||||
displayPage.Flash = template.HTML(f)
|
||||
}
|
||||
displayPage.IsAdmin = u != nil && u.IsAdmin()
|
||||
displayPage.CanInvite = canUserInvite(app.cfg, displayPage.IsAdmin)
|
||||
|
@ -796,6 +937,7 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
if u != nil {
|
||||
displayPage.Username = u.Username
|
||||
displayPage.IsOwner = u.ID == coll.OwnerID
|
||||
displayPage.IsSubscriber = u.IsEmailSubscriber(app, coll.ID)
|
||||
if displayPage.IsOwner {
|
||||
// Add in needed information for users viewing their own collection
|
||||
owner = u
|
||||
|
@ -827,10 +969,14 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
// Add more data
|
||||
// TODO: fix this mess of collections inside collections
|
||||
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
|
||||
displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
|
||||
|
||||
collTmpl := "collection"
|
||||
if app.cfg.App.Chorus {
|
||||
collTmpl = "chorus-collection"
|
||||
} else if isArchiveView(r) {
|
||||
displayPage.NavSuffix = "/archive/"
|
||||
collTmpl = "collection-archive"
|
||||
}
|
||||
err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage)
|
||||
if err != nil {
|
||||
|
@ -857,6 +1003,10 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
|||
return err
|
||||
}
|
||||
|
||||
func isArchiveView(r *http.Request) bool {
|
||||
return strings.HasSuffix(r.RequestURI, "/archive/") || mux.Vars(r)["archive"] == "archive"
|
||||
}
|
||||
|
||||
func handleViewMention(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
handle := vars["handle"]
|
||||
|
@ -892,7 +1042,23 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
|
|||
return err
|
||||
}
|
||||
|
||||
coll := newDisplayCollection(c, cr, page)
|
||||
coll, _ := newDisplayCollection(c, cr, page)
|
||||
|
||||
taggedPostIDs, err := app.db.GetAllPostsTaggedIDs(c, tag, cr.isCollOwner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ttlPosts := len(taggedPostIDs)
|
||||
pagePosts := coll.Format.PostsPerPage()
|
||||
coll.TotalPages = int(math.Ceil(float64(ttlPosts) / float64(pagePosts)))
|
||||
if coll.TotalPages > 0 && page > coll.TotalPages {
|
||||
redirURL := fmt.Sprintf("/page/%d", coll.TotalPages)
|
||||
if !app.cfg.App.SingleUser {
|
||||
redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL)
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, redirURL}
|
||||
}
|
||||
|
||||
coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, page, cr.isCollOwner)
|
||||
if coll.Posts != nil && len(*coll.Posts) == 0 {
|
||||
|
@ -900,10 +1066,7 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
|
|||
}
|
||||
|
||||
// Serve collection
|
||||
displayPage := struct {
|
||||
CollectionPage
|
||||
Tag string
|
||||
}{
|
||||
displayPage := TagCollectionPage{
|
||||
CollectionPage: CollectionPage{
|
||||
DisplayCollection: coll,
|
||||
StaticPage: pageForReq(app, r),
|
||||
|
@ -945,6 +1108,7 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
|
|||
// Add more data
|
||||
// TODO: fix this mess of collections inside collections
|
||||
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
|
||||
displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
|
||||
|
||||
err = templates["collection-tags"].ExecuteTemplate(w, "collection-tags", displayPage)
|
||||
if err != nil {
|
||||
|
@ -954,6 +1118,111 @@ func handleViewCollectionTag(app *App, w http.ResponseWriter, r *http.Request) e
|
|||
return nil
|
||||
}
|
||||
|
||||
func handleViewCollectionLang(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
lang := vars["lang"]
|
||||
|
||||
cr := &collectionReq{}
|
||||
err := processCollectionRequest(cr, vars, w, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u, err := checkUserForCollection(app, cr, r, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
page := getCollectionPage(vars)
|
||||
|
||||
c, err := processCollectionPermissions(app, cr, u, w, r)
|
||||
if c == nil || err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
coll, _ := newDisplayCollection(c, cr, page)
|
||||
coll.Language = lang
|
||||
coll.NavSuffix = fmt.Sprintf("/lang:%s", lang)
|
||||
|
||||
ttlPosts, err := app.db.GetCollLangTotalPosts(coll.ID, lang)
|
||||
if err != nil {
|
||||
log.Error("Unable to getCollLangTotalPosts: %s", err)
|
||||
}
|
||||
pagePosts := coll.Format.PostsPerPage()
|
||||
coll.TotalPages = int(math.Ceil(float64(ttlPosts) / float64(pagePosts)))
|
||||
if coll.TotalPages > 0 && page > coll.TotalPages {
|
||||
redirURL := fmt.Sprintf("/lang:%s/page/%d", lang, coll.TotalPages)
|
||||
if !app.cfg.App.SingleUser {
|
||||
redirURL = fmt.Sprintf("/%s%s%s", cr.prefix, coll.Alias, redirURL)
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, redirURL}
|
||||
}
|
||||
|
||||
coll.Posts, _ = app.db.GetLangPosts(app.cfg, c, lang, page, cr.isCollOwner)
|
||||
if err != nil {
|
||||
return ErrCollectionPageNotFound
|
||||
}
|
||||
|
||||
// Serve collection
|
||||
displayPage := struct {
|
||||
CollectionPage
|
||||
Tag string
|
||||
}{
|
||||
CollectionPage: CollectionPage{
|
||||
DisplayCollection: coll,
|
||||
StaticPage: pageForReq(app, r),
|
||||
IsCustomDomain: cr.isCustomDomain,
|
||||
},
|
||||
Tag: lang,
|
||||
}
|
||||
var owner *User
|
||||
if u != nil {
|
||||
displayPage.Username = u.Username
|
||||
displayPage.IsOwner = u.ID == coll.OwnerID
|
||||
if displayPage.IsOwner {
|
||||
// Add in needed information for users viewing their own collection
|
||||
owner = u
|
||||
displayPage.CanPin = true
|
||||
|
||||
pubColls, err := app.db.GetPublishableCollections(owner, app.cfg.App.Host)
|
||||
if err != nil {
|
||||
log.Error("unable to fetch collections: %v", err)
|
||||
}
|
||||
displayPage.Collections = pubColls
|
||||
}
|
||||
}
|
||||
isOwner := owner != nil
|
||||
if !isOwner {
|
||||
// Current user doesn't own collection; retrieve owner information
|
||||
owner, err = app.db.GetUserByID(coll.OwnerID)
|
||||
if err != nil {
|
||||
// Log the error and just continue
|
||||
log.Error("Error getting user for collection: %v", err)
|
||||
}
|
||||
if owner.IsSilenced() {
|
||||
return ErrCollectionNotFound
|
||||
}
|
||||
}
|
||||
displayPage.Silenced = owner != nil && owner.IsSilenced()
|
||||
displayPage.Owner = owner
|
||||
coll.Owner = displayPage.Owner
|
||||
// Add more data
|
||||
// TODO: fix this mess of collections inside collections
|
||||
displayPage.PinnedPosts, _ = app.db.GetPinnedPosts(coll.CollectionObj, isOwner)
|
||||
displayPage.Monetization = app.db.GetCollectionAttribute(coll.ID, "monetization_pointer")
|
||||
|
||||
collTmpl := "collection"
|
||||
if app.cfg.App.Chorus {
|
||||
collTmpl = "chorus-collection"
|
||||
}
|
||||
err = templates[collTmpl].ExecuteTemplate(w, "collection", displayPage)
|
||||
if err != nil {
|
||||
log.Error("Unable to render collection lang page: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleCollectionPostRedirect(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
vars := mux.Vars(r)
|
||||
slug := vars["slug"]
|
||||
|
@ -1038,7 +1307,7 @@ func existingCollection(app *App, w http.ResponseWriter, r *http.Request) error
|
|||
}
|
||||
}
|
||||
|
||||
err = app.db.UpdateCollection(&c, collAlias)
|
||||
err = app.db.UpdateCollection(app, &c, collAlias)
|
||||
if err != nil {
|
||||
if err, ok := err.(impart.HTTPError); ok {
|
||||
if reqJSON {
|
||||
|
@ -1150,3 +1419,43 @@ func isAuthorizedForCollection(app *App, alias string, r *http.Request) bool {
|
|||
}
|
||||
return authd
|
||||
}
|
||||
|
||||
func logOutCollection(app *App, alias string, w http.ResponseWriter, r *http.Request) error {
|
||||
session, err := app.sessionStore.Get(r, blogPassCookieName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove this from map of blogs logged into
|
||||
delete(session.Values, alias)
|
||||
|
||||
// If not auth'd with any blog, delete entire cookie
|
||||
if len(session.Values) == 0 {
|
||||
session.Options.MaxAge = -1
|
||||
}
|
||||
return session.Save(r, w)
|
||||
}
|
||||
|
||||
func handleLogOutCollection(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
alias := collectionAliasFromReq(r)
|
||||
var c *Collection
|
||||
var err error
|
||||
if app.cfg.App.SingleUser {
|
||||
c, err = app.db.GetCollectionByID(1)
|
||||
} else {
|
||||
c, err = app.db.GetCollection(alias)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !c.IsProtected() {
|
||||
// Invalid to log out of this collection
|
||||
return ErrCollectionPageNotFound
|
||||
}
|
||||
|
||||
err = logOutCollection(app, c.Alias, w, r)
|
||||
if err != nil {
|
||||
addSessionFlash(app, w, r, "Logging out failed. Try clearing cookies for this site, instead.", nil)
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, c.CanonicalURL()}
|
||||
}
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
[server]
|
||||
hidden_host =
|
||||
port = 8080
|
||||
|
||||
[database]
|
||||
type = mysql
|
||||
username = root
|
||||
password = changeme
|
||||
database = writefreely
|
||||
host = db
|
||||
port = 3306
|
||||
|
||||
[app]
|
||||
site_name = WriteFreely Example Blog!
|
||||
host = http://localhost:8080
|
||||
theme = write
|
||||
disable_js = false
|
||||
webfonts = true
|
||||
single_user = true
|
||||
open_registration = false
|
||||
min_username_len = 3
|
||||
max_blogs = 1
|
||||
federation = true
|
||||
public_stats = true
|
||||
private = false
|
||||
update_checks = true
|
||||
|
103
config/config.go
103
config/config.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -12,9 +12,12 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/ini.v1"
|
||||
"github.com/go-ini/ini"
|
||||
"github.com/writeas/web-core/log"
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -45,6 +48,8 @@ type (
|
|||
|
||||
HashSeed string `ini:"hash_seed"`
|
||||
|
||||
GopherPort int `ini:"gopher_port"`
|
||||
|
||||
Dev bool `ini:"-"`
|
||||
}
|
||||
|
||||
|
@ -57,6 +62,7 @@ type (
|
|||
Database string `ini:"database"`
|
||||
Host string `ini:"host"`
|
||||
Port int `ini:"port"`
|
||||
TLS bool `ini:"tls"`
|
||||
}
|
||||
|
||||
WriteAsOauthCfg struct {
|
||||
|
@ -69,6 +75,24 @@ type (
|
|||
CallbackProxyAPI string `ini:"callback_proxy_api"`
|
||||
}
|
||||
|
||||
GitlabOauthCfg struct {
|
||||
ClientID string `ini:"client_id"`
|
||||
ClientSecret string `ini:"client_secret"`
|
||||
Host string `ini:"host"`
|
||||
DisplayName string `ini:"display_name"`
|
||||
CallbackProxy string `ini:"callback_proxy"`
|
||||
CallbackProxyAPI string `ini:"callback_proxy_api"`
|
||||
}
|
||||
|
||||
GiteaOauthCfg struct {
|
||||
ClientID string `ini:"client_id"`
|
||||
ClientSecret string `ini:"client_secret"`
|
||||
Host string `ini:"host"`
|
||||
DisplayName string `ini:"display_name"`
|
||||
CallbackProxy string `ini:"callback_proxy"`
|
||||
CallbackProxyAPI string `ini:"callback_proxy_api"`
|
||||
}
|
||||
|
||||
SlackOauthCfg struct {
|
||||
ClientID string `ini:"client_id"`
|
||||
ClientSecret string `ini:"client_secret"`
|
||||
|
@ -77,6 +101,24 @@ type (
|
|||
CallbackProxyAPI string `ini:"callback_proxy_api"`
|
||||
}
|
||||
|
||||
GenericOauthCfg struct {
|
||||
ClientID string `ini:"client_id"`
|
||||
ClientSecret string `ini:"client_secret"`
|
||||
Host string `ini:"host"`
|
||||
DisplayName string `ini:"display_name"`
|
||||
CallbackProxy string `ini:"callback_proxy"`
|
||||
CallbackProxyAPI string `ini:"callback_proxy_api"`
|
||||
TokenEndpoint string `ini:"token_endpoint"`
|
||||
InspectEndpoint string `ini:"inspect_endpoint"`
|
||||
AuthEndpoint string `ini:"auth_endpoint"`
|
||||
Scope string `ini:"scope"`
|
||||
AllowDisconnect bool `ini:"allow_disconnect"`
|
||||
MapUserID string `ini:"map_user_id"`
|
||||
MapUsername string `ini:"map_username"`
|
||||
MapDisplayName string `ini:"map_display_name"`
|
||||
MapEmail string `ini:"map_email"`
|
||||
}
|
||||
|
||||
// AppCfg holds values that affect how the application functions
|
||||
AppCfg struct {
|
||||
SiteName string `ini:"site_name"`
|
||||
|
@ -94,17 +136,22 @@ type (
|
|||
|
||||
// Site functionality
|
||||
Chorus bool `ini:"chorus"`
|
||||
Forest bool `ini:"forest"` // The admin cares about the forest, not the trees. Hide unnecessary technical info.
|
||||
DisableDrafts bool `ini:"disable_drafts"`
|
||||
|
||||
// Users
|
||||
SingleUser bool `ini:"single_user"`
|
||||
OpenRegistration bool `ini:"open_registration"`
|
||||
OpenDeletion bool `ini:"open_deletion"`
|
||||
MinUsernameLen int `ini:"min_username_len"`
|
||||
MaxBlogs int `ini:"max_blogs"`
|
||||
|
||||
// Options for public instances
|
||||
// Federation
|
||||
Federation bool `ini:"federation"`
|
||||
PublicStats bool `ini:"public_stats"`
|
||||
Monetization bool `ini:"monetization"`
|
||||
NotesOnly bool `ini:"notes_only"`
|
||||
|
||||
// Access
|
||||
Private bool `ini:"private"`
|
||||
|
@ -118,6 +165,23 @@ type (
|
|||
|
||||
// Check for Updates
|
||||
UpdateChecks bool `ini:"update_checks"`
|
||||
|
||||
// Disable password authentication if use only Oauth
|
||||
DisablePasswordAuth bool `ini:"disable_password_auth"`
|
||||
}
|
||||
|
||||
EmailCfg struct {
|
||||
// SMTP configuration values
|
||||
Host string `ini:"smtp_host"`
|
||||
Port int `ini:"smtp_port"`
|
||||
Username string `ini:"smtp_username"`
|
||||
Password string `ini:"smtp_password"`
|
||||
EnableStartTLS bool `ini:"smtp_enable_start_tls"`
|
||||
|
||||
// Mailgun configuration values
|
||||
Domain string `ini:"domain"`
|
||||
MailgunPrivate string `ini:"mailgun_private"`
|
||||
MailgunEurope bool `ini:"mailgun_europe"`
|
||||
}
|
||||
|
||||
// Config holds the complete configuration for running a writefreely instance
|
||||
|
@ -125,8 +189,12 @@ type (
|
|||
Server ServerCfg `ini:"server"`
|
||||
Database DatabaseCfg `ini:"database"`
|
||||
App AppCfg `ini:"app"`
|
||||
Email EmailCfg `ini:"email"`
|
||||
SlackOauth SlackOauthCfg `ini:"oauth.slack"`
|
||||
WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"`
|
||||
GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"`
|
||||
GiteaOauth GiteaOauthCfg `ini:"oauth.gitea"`
|
||||
GenericOauth GenericOauthCfg `ini:"oauth.generic"`
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -182,6 +250,21 @@ func (ac *AppCfg) LandingPath() string {
|
|||
return ac.Landing
|
||||
}
|
||||
|
||||
func (lc EmailCfg) Enabled() bool {
|
||||
return (lc.Domain != "" && lc.MailgunPrivate != "") ||
|
||||
lc.Username != "" && lc.Password != "" && lc.Host != "" && lc.Port > 0
|
||||
}
|
||||
|
||||
func (ac AppCfg) SignupPath() string {
|
||||
if !ac.OpenRegistration {
|
||||
return ""
|
||||
}
|
||||
if ac.Chorus || ac.Private || (ac.Landing != "" && ac.Landing != "/") {
|
||||
return "/signup"
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
|
||||
// Load reads the given configuration file, then parses and returns it as a Config.
|
||||
func Load(fname string) (*Config, error) {
|
||||
if fname == "" {
|
||||
|
@ -198,6 +281,22 @@ func Load(fname string) (*Config, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Do any transformations
|
||||
u, err := url.Parse(uc.App.Host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, err := idna.ToASCII(u.Hostname())
|
||||
if err != nil {
|
||||
log.Error("idna.ToASCII for %s: %s", u.Hostname(), err)
|
||||
return nil, err
|
||||
}
|
||||
uc.App.Host = u.Scheme + "://" + d
|
||||
if u.Port() != "" {
|
||||
uc.App.Host += ":" + u.Port()
|
||||
}
|
||||
|
||||
return uc, nil
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
* Copyright © 2018 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
* Copyright © 2018, 2020-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -11,14 +11,34 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"github.com/writeas/web-core/log"
|
||||
"golang.org/x/net/idna"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FriendlyHost returns the app's Host sans any schema
|
||||
func (ac AppCfg) FriendlyHost() string {
|
||||
return ac.Host[strings.Index(ac.Host, "://")+len("://"):]
|
||||
rawHost := ac.Host[strings.Index(ac.Host, "://")+len("://"):]
|
||||
|
||||
u, err := url.Parse(ac.Host)
|
||||
if err != nil {
|
||||
log.Error("url.Parse failed on %s: %s", ac.Host, err)
|
||||
return rawHost
|
||||
}
|
||||
d, err := idna.ToUnicode(u.Hostname())
|
||||
if err != nil {
|
||||
log.Error("idna.ToUnicode failed on %s: %s", ac.Host, err)
|
||||
return rawHost
|
||||
}
|
||||
|
||||
res := d
|
||||
if u.Port() != "" {
|
||||
res += ":" + u.Port()
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (ac AppCfg) CanCreateBlogs(currentlyUsed uint64) bool {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
* Copyright © 2018 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -12,12 +12,14 @@ package config
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/mitchellh/go-wordwrap"
|
||||
"github.com/writeas/web-core/auth"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SetupData struct {
|
||||
|
@ -57,7 +59,7 @@ func Configure(fname string, configSections string) (*SetupData, error) {
|
|||
Success: "{{ . | bold | faint }}: ",
|
||||
}
|
||||
selTmpls := &promptui.SelectTemplates{
|
||||
Selected: fmt.Sprintf(`{{.Label}} {{ . | faint }}`),
|
||||
Selected: `{{.Label}} {{ . | faint }}`,
|
||||
}
|
||||
|
||||
var selPrompt promptui.Select
|
||||
|
@ -80,6 +82,8 @@ func Configure(fname string, configSections string) (*SetupData, error) {
|
|||
isDevEnv := envType == "Development"
|
||||
isStandalone := envType == "Production, standalone"
|
||||
|
||||
_, isDocker := os.LookupEnv("WRITEFREELY_DOCKER")
|
||||
|
||||
data.Config.Server.Dev = isDevEnv
|
||||
|
||||
if isDevEnv || !isStandalone {
|
||||
|
@ -150,6 +154,16 @@ func Configure(fname string, configSections string) (*SetupData, error) {
|
|||
data.Config.Server.TLSKeyPath = ""
|
||||
}
|
||||
|
||||
// If running in docker:
|
||||
// 1. always bind to 0.0.0.0 instead of localhost
|
||||
// 2. set paths of static files in UNIX manners
|
||||
if !isDevEnv && isDocker {
|
||||
data.Config.Server.TemplatesParentDir = "/usr/share/writefreely"
|
||||
data.Config.Server.StaticParentDir = "/usr/share/writefreely"
|
||||
data.Config.Server.PagesParentDir = "/usr/share/writefreely"
|
||||
data.Config.Server.Bind = "0.0.0.0"
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
|
@ -356,7 +370,7 @@ func Configure(fname string, configSections string) (*SetupData, error) {
|
|||
if data.Config.App.Federation {
|
||||
selPrompt = promptui.Select{
|
||||
Templates: selTmpls,
|
||||
Label: "Federation usage stats",
|
||||
Label: "Usage stats (active users, posts)",
|
||||
Items: []string{"Public", "Private"},
|
||||
}
|
||||
_, fedStatsType, err := selPrompt.Run()
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
* Copyright © 2018 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
//go:build wflib
|
||||
// +build wflib
|
||||
|
||||
/*
|
||||
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -22,3 +23,7 @@ func (db *datastore) isDuplicateKeyErr(err error) bool {
|
|||
func (db *datastore) isIgnorableError(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *datastore) isHighLoadError(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
//go:build !sqlite && !wflib
|
||||
// +build !sqlite,!wflib
|
||||
|
||||
/*
|
||||
* Copyright © 2019-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -40,3 +41,13 @@ func (db *datastore) isIgnorableError(err error) bool {
|
|||
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *datastore) isHighLoadError(err error) bool {
|
||||
if db.driverName == driverMySQL {
|
||||
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||
return mysqlErr.Number == mySQLErrMaxUserConns || mysqlErr.Number == mySQLErrTooManyConns
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
//go:build sqlite && !wflib
|
||||
// +build sqlite,!wflib
|
||||
|
||||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -60,3 +61,13 @@ func (db *datastore) isIgnorableError(err error) bool {
|
|||
|
||||
return false
|
||||
}
|
||||
|
||||
func (db *datastore) isHighLoadError(err error) bool {
|
||||
if db.driverName == driverMySQL {
|
||||
if mysqlErr, ok := err.(*mysql.MySQLError); ok {
|
||||
return mysqlErr.Number == mySQLErrMaxUserConns || mysqlErr.Number == mySQLErrTooManyConns
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
790
database.go
790
database.go
File diff suppressed because it is too large
Load diff
49
database_activitypub.go
Normal file
49
database_activitypub.go
Normal file
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright © 2024 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/writeas/web-core/activitystreams"
|
||||
"github.com/writeas/web-core/log"
|
||||
)
|
||||
|
||||
func apAddRemoteUser(app *App, t *sql.Tx, fullActor *activitystreams.Person) (int64, error) {
|
||||
// Add remote user locally, since it wasn't found before
|
||||
res, err := t.Exec("INSERT INTO remoteusers (actor_id, inbox, shared_inbox, url) VALUES (?, ?, ?, ?)", fullActor.ID, fullActor.Inbox, fullActor.Endpoints.SharedInbox, fullActor.URL)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return -1, fmt.Errorf("couldn't add new remoteuser in DB: %v", err)
|
||||
}
|
||||
|
||||
remoteUserID, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return -1, fmt.Errorf("no lastinsertid for followers, rolling back: %v", err)
|
||||
}
|
||||
|
||||
// Add in key
|
||||
_, err = t.Exec("INSERT INTO remoteuserkeys (id, remote_user_id, public_key) VALUES (?, ?, ?)", fullActor.PublicKey.ID, remoteUserID, fullActor.PublicKey.PublicKeyPEM)
|
||||
if err != nil {
|
||||
if !app.db.isDuplicateKeyErr(err) {
|
||||
t.Rollback()
|
||||
log.Error("Couldn't add follower keys in DB: %v\n", err)
|
||||
return -1, fmt.Errorf("couldn't add follower keys in DB: %v", err)
|
||||
} else {
|
||||
t.Rollback()
|
||||
log.Error("Couldn't add follower keys in DB: %v\n", err)
|
||||
return -1, fmt.Errorf("couldn't add follower keys in DB: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return remoteUserID, nil
|
||||
}
|
|
@ -18,13 +18,13 @@ func TestOAuthDatastore(t *testing.T) {
|
|||
driverName: "",
|
||||
}
|
||||
|
||||
state, err := ds.GenerateOAuthState(ctx, "test", "development")
|
||||
state, err := ds.GenerateOAuthState(ctx, "test", "development", 0, "")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, state, 24)
|
||||
|
||||
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_client_states` WHERE `state` = ? AND `used` = false", state)
|
||||
|
||||
_, _, err = ds.ValidateOAuthState(ctx, state)
|
||||
_, _, _, _, err = ds.ValidateOAuthState(ctx, state)
|
||||
assert.NoError(t, err)
|
||||
|
||||
countRows(t, ctx, db, 1, "SELECT COUNT(*) FROM `oauth_client_states` WHERE `state` = ? AND `used` = true", state)
|
||||
|
|
31
db/create.go
31
db/create.go
|
@ -1,3 +1,13 @@
|
|||
/*
|
||||
* Copyright © 2019-2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
|
@ -139,6 +149,15 @@ func (c *Column) SetDefault(value string) *Column {
|
|||
return c
|
||||
}
|
||||
|
||||
func (c *Column) SetDefaultCurrentTimestamp() *Column {
|
||||
def := "NOW()"
|
||||
if c.Dialect == DialectSQLite {
|
||||
def = "CURRENT_TIMESTAMP"
|
||||
}
|
||||
c.Default = OptionalString{Set: true, Value: def}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Column) SetType(t ColumnType) *Column {
|
||||
c.Type = t
|
||||
return c
|
||||
|
@ -168,7 +187,11 @@ func (c *Column) String() (string, error) {
|
|||
|
||||
if c.Default.Set {
|
||||
str.WriteString(" DEFAULT ")
|
||||
str.WriteString(c.Default.Value)
|
||||
val := c.Default.Value
|
||||
if val == "" {
|
||||
val = "''"
|
||||
}
|
||||
str.WriteString(val)
|
||||
}
|
||||
|
||||
if c.PrimaryKey {
|
||||
|
@ -224,10 +247,7 @@ func (b *CreateTableSqlBuilder) ToSQL() (string, error) {
|
|||
}
|
||||
things = append(things, columnStr)
|
||||
}
|
||||
for _, constraint := range b.Constraints {
|
||||
things = append(things, constraint)
|
||||
}
|
||||
|
||||
things = append(things, b.Constraints...)
|
||||
if thingLen := len(things); thingLen > 0 {
|
||||
str.WriteString(" ( ")
|
||||
for i, thing := range things {
|
||||
|
@ -241,4 +261,3 @@ func (b *CreateTableSqlBuilder) ToSQL() (string, error) {
|
|||
|
||||
return str.String(), nil
|
||||
}
|
||||
|
||||
|
|
1
db/tx.go
1
db/tx.go
|
@ -23,4 +23,3 @@ func RunTransactionWithOptions(ctx context.Context, db *sql.DB, txOpts *sql.TxOp
|
|||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
|
|
25
docker-compose.prod.yml
Normal file
25
docker-compose.prod.yml
Normal file
|
@ -0,0 +1,25 @@
|
|||
services:
|
||||
app:
|
||||
image: writefreely
|
||||
container_name: writefreely
|
||||
volumes:
|
||||
- ./data:/data
|
||||
ports:
|
||||
- 127.0.0.1:8080:8080
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: lscr.io/linuxserver/mariadb
|
||||
container_name: writefreely-mariadb
|
||||
volumes:
|
||||
- ./db:/config
|
||||
environment:
|
||||
- PUID=65534
|
||||
- PGID=65534
|
||||
- TZ=Etc/UTC
|
||||
- MYSQL_DATABASE=writefreely
|
||||
- MYSQL_USER=writefreely
|
||||
- MYSQL_PASSWORD=P@ssw0rd
|
||||
restart: unless-stopped
|
|
@ -1,32 +1,47 @@
|
|||
version: "3"
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
volumes:
|
||||
- "web-data:/go/src/app"
|
||||
- "./config.ini.example:/go/src/app/config.ini"
|
||||
ports:
|
||||
- "8080:8080"
|
||||
networks:
|
||||
- writefreely
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
db:
|
||||
image: "mariadb:latest"
|
||||
volumes:
|
||||
- "./schema.sql:/tmp/schema.sql"
|
||||
- db-data:/var/lib/mysql/data
|
||||
networks:
|
||||
- writefreely
|
||||
environment:
|
||||
- MYSQL_DATABASE=writefreely
|
||||
- MYSQL_ROOT_PASSWORD=changeme
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
web-data:
|
||||
web-keys:
|
||||
db-data:
|
||||
|
||||
networks:
|
||||
writefreely:
|
||||
external_writefreely:
|
||||
internal_writefreely:
|
||||
internal: true
|
||||
|
||||
services:
|
||||
writefreely-web:
|
||||
container_name: "writefreely-web"
|
||||
image: "writeas/writefreely:latest"
|
||||
|
||||
volumes:
|
||||
- "web-keys:/go/keys"
|
||||
- "./config.ini:/go/config.ini"
|
||||
|
||||
networks:
|
||||
- "internal_writefreely"
|
||||
- "external_writefreely"
|
||||
|
||||
ports:
|
||||
- "8080:8080"
|
||||
|
||||
depends_on:
|
||||
- "writefreely-db"
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
writefreely-db:
|
||||
container_name: "writefreely-db"
|
||||
image: "mariadb:latest"
|
||||
|
||||
volumes:
|
||||
- "db-data:/var/lib/mysql/data"
|
||||
|
||||
networks:
|
||||
- "internal_writefreely"
|
||||
|
||||
environment:
|
||||
- MYSQL_DATABASE=writefreely
|
||||
- MYSQL_ROOT_PASSWORD=changeme
|
||||
|
||||
restart: unless-stopped
|
||||
|
|
477
email.go
Normal file
477
email.go
Normal file
|
@ -0,0 +1,477 @@
|
|||
/*
|
||||
* Copyright © 2019-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/writefreely/writefreely/mailer"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aymerick/douceur/inliner"
|
||||
"github.com/gorilla/mux"
|
||||
stripmd "github.com/writeas/go-strip-markdown/v2"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/data"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/writefreely/key"
|
||||
"github.com/writefreely/writefreely/spam"
|
||||
)
|
||||
|
||||
const (
|
||||
emailSendDelay = 15
|
||||
)
|
||||
|
||||
type (
|
||||
SubmittedSubscription struct {
|
||||
CollAlias string
|
||||
UserID int64
|
||||
|
||||
Email string `schema:"email" json:"email"`
|
||||
Web bool `schema:"web" json:"web"`
|
||||
Slug string `schema:"slug" json:"slug"`
|
||||
From string `schema:"from" json:"from"`
|
||||
}
|
||||
|
||||
EmailSubscriber struct {
|
||||
ID string
|
||||
CollID int64
|
||||
UserID sql.NullInt64
|
||||
Email sql.NullString
|
||||
Subscribed time.Time
|
||||
Token string
|
||||
Confirmed bool
|
||||
AllowExport bool
|
||||
acctEmail sql.NullString
|
||||
}
|
||||
)
|
||||
|
||||
func (es *EmailSubscriber) FinalEmail(keys *key.Keychain) string {
|
||||
if !es.UserID.Valid || es.Email.Valid {
|
||||
return es.Email.String
|
||||
}
|
||||
|
||||
decEmail, err := data.Decrypt(keys.EmailKey, []byte(es.acctEmail.String))
|
||||
if err != nil {
|
||||
log.Error("Error decrypting user email: %v", err)
|
||||
return ""
|
||||
}
|
||||
return string(decEmail)
|
||||
}
|
||||
|
||||
func (es *EmailSubscriber) SubscribedFriendly() string {
|
||||
return es.Subscribed.Format("January 2, 2006")
|
||||
}
|
||||
|
||||
func handleCreateEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
reqJSON := IsJSON(r)
|
||||
vars := mux.Vars(r)
|
||||
var err error
|
||||
|
||||
ss := SubmittedSubscription{
|
||||
CollAlias: vars["alias"],
|
||||
}
|
||||
u := getUserSession(app, r)
|
||||
if u != nil {
|
||||
ss.UserID = u.ID
|
||||
}
|
||||
if reqJSON {
|
||||
// Decode JSON request
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
err = decoder.Decode(&ss)
|
||||
if err != nil {
|
||||
log.Error("Couldn't parse new subscription JSON request: %v\n", err)
|
||||
return ErrBadJSON
|
||||
}
|
||||
} else {
|
||||
err = r.ParseForm()
|
||||
if err != nil {
|
||||
log.Error("Couldn't parse new subscription form request: %v\n", err)
|
||||
return ErrBadFormData
|
||||
}
|
||||
|
||||
err = app.formDecoder.Decode(&ss, r.PostForm)
|
||||
if err != nil {
|
||||
log.Error("Continuing, but error decoding new subscription form request: %v\n", err)
|
||||
//return ErrBadFormData
|
||||
}
|
||||
}
|
||||
|
||||
c, err := app.db.GetCollection(ss.CollAlias)
|
||||
if err != nil {
|
||||
log.Error("getCollection: %s", err)
|
||||
return err
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
from := c.CanonicalURL()
|
||||
isAuthorBanned, err := app.db.IsUserSilenced(c.OwnerID)
|
||||
if isAuthorBanned {
|
||||
log.Info("Author is silenced, so subscription is blocked.")
|
||||
return impart.HTTPError{http.StatusFound, from}
|
||||
}
|
||||
|
||||
if ss.Web {
|
||||
if u != nil && u.ID == c.OwnerID {
|
||||
from = "/" + c.Alias + "/"
|
||||
}
|
||||
from += ss.Slug
|
||||
}
|
||||
|
||||
if r.FormValue(spam.HoneypotFieldName()) != "" || r.FormValue("fake_password") != "" {
|
||||
log.Info("Honeypot field was filled out! Not subscribing.")
|
||||
return impart.HTTPError{http.StatusFound, from}
|
||||
}
|
||||
|
||||
if ss.Email == "" && ss.UserID < 1 {
|
||||
log.Info("No subscriber data. Not subscribing.")
|
||||
return impart.HTTPError{http.StatusFound, from}
|
||||
}
|
||||
|
||||
confirmed := app.db.IsSubscriberConfirmed(ss.Email)
|
||||
es, err := app.db.AddEmailSubscription(c.ID, ss.UserID, ss.Email, confirmed)
|
||||
if err != nil {
|
||||
log.Error("addEmailSubscription: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Send confirmation email if needed
|
||||
if !confirmed {
|
||||
err = sendSubConfirmEmail(app, c, ss.Email, es.ID, es.Token)
|
||||
if err != nil {
|
||||
log.Error("Failed to send subscription confirmation email: %s", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if ss.Web {
|
||||
session, err := app.sessionStore.Get(r, userEmailCookieName)
|
||||
if err != nil {
|
||||
// The cookie should still save, even if there's an error.
|
||||
// Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144
|
||||
log.Error("Getting user email cookie: %v; ignoring", err)
|
||||
}
|
||||
if confirmed {
|
||||
addSessionFlash(app, w, r, "<strong>Subscribed</strong>. You'll now receive future blog posts via email.", nil)
|
||||
} else {
|
||||
addSessionFlash(app, w, r, "Please check your email and <strong>click the confirmation link</strong> to subscribe.", nil)
|
||||
}
|
||||
session.Values[userEmailCookieVal] = ss.Email
|
||||
err = session.Save(r, w)
|
||||
if err != nil {
|
||||
log.Error("save email cookie: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return impart.HTTPError{http.StatusFound, from}
|
||||
}
|
||||
return impart.WriteSuccess(w, "", http.StatusAccepted)
|
||||
}
|
||||
|
||||
func handleDeleteEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
alias := collectionAliasFromReq(r)
|
||||
|
||||
vars := mux.Vars(r)
|
||||
subID := vars["subscriber"]
|
||||
email := r.FormValue("email")
|
||||
token := r.FormValue("t")
|
||||
slug := r.FormValue("slug")
|
||||
isWeb := r.Method == "GET"
|
||||
|
||||
// Display collection if this is a collection
|
||||
var c *Collection
|
||||
var err error
|
||||
if app.cfg.App.SingleUser {
|
||||
c, err = app.db.GetCollectionByID(1)
|
||||
} else {
|
||||
c, err = app.db.GetCollection(alias)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("Get collection: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
from := c.CanonicalURL()
|
||||
|
||||
if subID != "" {
|
||||
// User unsubscribing via email, so assume action is taken by either current
|
||||
// user or not current user, and only use the request's information to
|
||||
// satisfy this unsubscribe, i.e. subscriberID and token.
|
||||
err = app.db.DeleteEmailSubscriber(subID, token)
|
||||
} else {
|
||||
// User unsubscribing through the web app, so assume action is taken by
|
||||
// currently-auth'd user.
|
||||
var userID int64
|
||||
u := getUserSession(app, r)
|
||||
if u != nil {
|
||||
// User is logged in
|
||||
userID = u.ID
|
||||
if userID == c.OwnerID {
|
||||
from = "/" + c.Alias + "/"
|
||||
}
|
||||
}
|
||||
if email == "" && userID <= 0 {
|
||||
// Get email address from saved cookie
|
||||
session, err := app.sessionStore.Get(r, userEmailCookieName)
|
||||
if err != nil {
|
||||
log.Error("Unable to get email cookie: %s", err)
|
||||
} else {
|
||||
email = session.Values[userEmailCookieVal].(string)
|
||||
}
|
||||
}
|
||||
|
||||
if email == "" && userID <= 0 {
|
||||
err = fmt.Errorf("No subscriber given.")
|
||||
log.Error("Not deleting subscription: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = app.db.DeleteEmailSubscriberByUser(email, userID, c.ID)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("Unable to delete subscriber: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if isWeb {
|
||||
from += slug
|
||||
addSessionFlash(app, w, r, "<strong>Unsubscribed</strong>. You will no longer receive these blog posts via email.", nil)
|
||||
return impart.HTTPError{http.StatusFound, from}
|
||||
}
|
||||
return impart.WriteSuccess(w, "", http.StatusAccepted)
|
||||
}
|
||||
|
||||
func handleConfirmEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
alias := collectionAliasFromReq(r)
|
||||
subID := mux.Vars(r)["subscriber"]
|
||||
token := r.FormValue("t")
|
||||
|
||||
var c *Collection
|
||||
var err error
|
||||
if app.cfg.App.SingleUser {
|
||||
c, err = app.db.GetCollectionByID(1)
|
||||
} else {
|
||||
c, err = app.db.GetCollection(alias)
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("Get collection: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
from := c.CanonicalURL()
|
||||
|
||||
err = app.db.UpdateSubscriberConfirmed(subID, token)
|
||||
if err != nil {
|
||||
addSessionFlash(app, w, r, err.Error(), nil)
|
||||
return impart.HTTPError{http.StatusFound, from}
|
||||
}
|
||||
|
||||
addSessionFlash(app, w, r, "<strong>Confirmed</strong>! Thanks. Now you'll receive future blog posts via email.", nil)
|
||||
return impart.HTTPError{http.StatusFound, from}
|
||||
}
|
||||
|
||||
func emailPost(app *App, p *PublicPost, collID int64) error {
|
||||
p.augmentContent()
|
||||
|
||||
// Do some shortcode replacement.
|
||||
// Since the user is receiving this email, we can assume they're subscribed via email.
|
||||
p.Content = strings.Replace(p.Content, "<!--emailsub-->", `<p id="emailsub">You're subscribed to email updates.</p>`, -1)
|
||||
|
||||
if p.HTMLContent == template.HTML("") {
|
||||
p.formatContent(app.cfg, false, false)
|
||||
}
|
||||
p.augmentReadingDestination()
|
||||
|
||||
title := p.Title.String
|
||||
if title != "" {
|
||||
title = p.Title.String + "\n\n"
|
||||
}
|
||||
plainMsg := title + "A new post from " + p.CanonicalURL(app.cfg.App.Host) + "\n\n" + stripmd.Strip(p.Content)
|
||||
plainMsg += `
|
||||
|
||||
---------------------------------------------------------------------------------
|
||||
|
||||
Originally published on ` + p.Collection.DisplayTitle() + ` (` + p.Collection.CanonicalURL() + `), a blog you subscribe to.
|
||||
|
||||
Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%`
|
||||
|
||||
mlr, err := mailer.New(app.cfg.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m, err := mlr.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Email.Domain+">", stripmd.Strip(p.DisplayTitle()), plainMsg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
replyTo := app.db.GetCollectionAttribute(collID, collAttrLetterReplyTo)
|
||||
if replyTo != "" {
|
||||
m.SetReplyTo(replyTo)
|
||||
}
|
||||
|
||||
subs, err := app.db.GetEmailSubscribers(collID, true)
|
||||
if err != nil {
|
||||
log.Error("Unable to get email subscribers: %v", err)
|
||||
return err
|
||||
}
|
||||
if len(subs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if title != "" {
|
||||
title = string(`<h2 id="title">` + p.FormattedDisplayTitle() + `</h2>`)
|
||||
}
|
||||
m.AddTag("New post")
|
||||
|
||||
fontFam := "Lora, Palatino, Baskerville, serif"
|
||||
if p.IsSans() {
|
||||
fontFam = `"Open Sans", Tahoma, Arial, sans-serif`
|
||||
} else if p.IsMonospace() {
|
||||
fontFam = `Hack, consolas, Menlo-Regular, Menlo, Monaco, monospace, monospace`
|
||||
}
|
||||
|
||||
// TODO: move this to a templated file and LESS-generated stylesheet
|
||||
fullHTML := `<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-size: 120%;
|
||||
font-family: ` + fontFam + `;
|
||||
margin: 1em 2em;
|
||||
}
|
||||
#article {
|
||||
line-height: 1.5;
|
||||
margin: 1.5em 0;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6, p, code {
|
||||
display: inline
|
||||
}
|
||||
img, iframe, video {
|
||||
max-width: 100%
|
||||
}
|
||||
#title {
|
||||
margin-bottom: 1em;
|
||||
display: block;
|
||||
}
|
||||
.intro {
|
||||
font-style: italic;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
div#footer {
|
||||
text-align: center;
|
||||
max-width: 35em;
|
||||
margin: 2em auto;
|
||||
}
|
||||
div#footer p {
|
||||
display: block;
|
||||
font-size: 0.86em;
|
||||
color: #666;
|
||||
}
|
||||
hr {
|
||||
border: 1px solid #ccc;
|
||||
margin: 2em 1em;
|
||||
}
|
||||
p#emailsub {
|
||||
text-align: center;
|
||||
display: inline-block !important;
|
||||
width: 100%;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="article">` + title + `<p class="intro">From <a href="` + p.CanonicalURL(app.cfg.App.Host) + `">` + p.DisplayCanonicalURL() + `</a></p>
|
||||
|
||||
` + string(p.HTMLContent) + `</div>
|
||||
<hr />
|
||||
<div id="footer">
|
||||
<p>Originally published on <a href="` + p.Collection.CanonicalURL() + `">` + p.Collection.DisplayTitle() + `</a>, a blog you subscribe to.</p>
|
||||
<p>Sent to %recipient.to%. <a href="` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%">Unsubscribe</a>.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// inline CSS
|
||||
html, err := inliner.Inline(fullHTML)
|
||||
if err != nil {
|
||||
log.Error("Unable to inline email HTML: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
m.SetHTML(html)
|
||||
|
||||
log.Info("[email] Adding %d recipient(s)", len(subs))
|
||||
for _, s := range subs {
|
||||
e := s.FinalEmail(app.keys)
|
||||
log.Info("[email] Adding %s", e)
|
||||
err = m.AddRecipientAndVariables(e, map[string]string{
|
||||
"id": s.ID,
|
||||
"to": e,
|
||||
"token": s.Token,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("Unable to add receipient %s: %s", e, err)
|
||||
}
|
||||
}
|
||||
|
||||
err = mlr.Send(m)
|
||||
log.Info("[email] Email sent")
|
||||
if err != nil {
|
||||
log.Error("Unable to send post email: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendSubConfirmEmail(app *App, c *Collection, email, subID, token string) error {
|
||||
if email == "" {
|
||||
return fmt.Errorf("You must supply an email to verify.")
|
||||
}
|
||||
|
||||
// Send email
|
||||
mlr, err := mailer.New(app.cfg.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plainMsg := "Confirm your subscription to " + c.DisplayTitle() + ` (` + c.CanonicalURL() + `) to start receiving future posts. Simply click the following link (or copy and paste it into your browser):
|
||||
|
||||
` + c.CanonicalURL() + "email/confirm/" + subID + "?t=" + token + `
|
||||
|
||||
If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.`
|
||||
m, err := mlr.NewMessage(c.DisplayTitle()+" <"+c.Alias+"@"+app.cfg.Email.Domain+">", "Confirm your subscription to "+c.DisplayTitle(), plainMsg, fmt.Sprintf("<%s>", email))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.AddTag("Email Verification")
|
||||
|
||||
m.SetHTML(`<html>
|
||||
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
|
||||
<div style="font-size: 1.2em;">
|
||||
<p>Confirm your subscription to <a href="` + c.CanonicalURL() + `">` + c.DisplayTitle() + `</a> to start receiving future posts:</p>
|
||||
<p><a href="` + c.CanonicalURL() + `email/confirm/` + subID + `?t=` + token + `">Subscribe to ` + c.DisplayTitle() + `</a></p>
|
||||
<p>If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`)
|
||||
err = mlr.Send(m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2020 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -37,6 +37,8 @@ var (
|
|||
ErrInternalGeneral = impart.HTTPError{http.StatusInternalServerError, "The humans messed something up. They've been notified."}
|
||||
ErrInternalCookieSession = impart.HTTPError{http.StatusInternalServerError, "Could not get cookie session."}
|
||||
|
||||
ErrUnavailable = impart.HTTPError{http.StatusServiceUnavailable, "Service temporarily unavailable due to high load."}
|
||||
|
||||
ErrCollectionNotFound = impart.HTTPError{http.StatusNotFound, "Collection doesn't exist."}
|
||||
ErrCollectionGone = impart.HTTPError{http.StatusGone, "This blog was unpublished."}
|
||||
ErrCollectionPageNotFound = impart.HTTPError{http.StatusNotFound, "Collection page doesn't exist."}
|
||||
|
@ -50,6 +52,8 @@ var (
|
|||
ErrUserNotFoundEmail = impart.HTTPError{http.StatusNotFound, "Please enter your username instead of your email address."}
|
||||
|
||||
ErrUserSilenced = impart.HTTPError{http.StatusForbidden, "Account is silenced."}
|
||||
|
||||
ErrDisabledPasswordAuth = impart.HTTPError{http.StatusForbidden, "Password authentication is disabled."}
|
||||
)
|
||||
|
||||
// Post operation errors
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2019 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -110,7 +110,7 @@ func compileFullExport(app *App, u *User) *ExportUser {
|
|||
log.Error("unable to fetch collections: %v", err)
|
||||
}
|
||||
|
||||
posts, err := app.db.GetAnonymousPosts(u)
|
||||
posts, err := app.db.GetAnonymousPosts(u, 0)
|
||||
if err != nil {
|
||||
log.Error("unable to fetch anon posts: %v", err)
|
||||
}
|
||||
|
@ -119,7 +119,7 @@ func compileFullExport(app *App, u *User) *ExportUser {
|
|||
var collObjs []CollectionObj
|
||||
for _, c := range *colls {
|
||||
co := &CollectionObj{Collection: c}
|
||||
co.Posts, err = app.db.GetPosts(app.cfg, &c, 0, true, false, true)
|
||||
co.Posts, err = app.db.GetPosts(app.cfg, &c, 0, true, false, true, "")
|
||||
if err != nil {
|
||||
log.Error("unable to get collection posts: %v", err)
|
||||
}
|
||||
|
|
26
feed.go
26
feed.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -15,9 +15,9 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
. "github.com/gorilla/feeds"
|
||||
"github.com/gorilla/feeds"
|
||||
"github.com/gorilla/mux"
|
||||
stripmd "github.com/writeas/go-strip-markdown"
|
||||
stripmd "github.com/writeas/go-strip-markdown/v2"
|
||||
"github.com/writeas/web-core/log"
|
||||
)
|
||||
|
||||
|
@ -67,7 +67,7 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
|
|||
if tag != "" {
|
||||
coll.Posts, _ = app.db.GetPostsTagged(app.cfg, c, tag, 1, false)
|
||||
} else {
|
||||
coll.Posts, _ = app.db.GetPosts(app.cfg, c, 1, false, true, false)
|
||||
coll.Posts, _ = app.db.GetPosts(app.cfg, c, 1, false, true, false, "")
|
||||
}
|
||||
|
||||
author := ""
|
||||
|
@ -87,25 +87,29 @@ func ViewFeed(app *App, w http.ResponseWriter, req *http.Request) error {
|
|||
siteURL += "tag:" + tag
|
||||
}
|
||||
|
||||
feed := &Feed{
|
||||
feed := &feeds.Feed{
|
||||
Title: collectionTitle,
|
||||
Link: &Link{Href: siteURL},
|
||||
Link: &feeds.Link{Href: siteURL},
|
||||
Description: coll.Description,
|
||||
Author: &Author{author, ""},
|
||||
Author: &feeds.Author{author, ""},
|
||||
Created: time.Now(),
|
||||
}
|
||||
|
||||
var title, permalink string
|
||||
for _, p := range *coll.Posts {
|
||||
// Add necessary path back to the web browser for Web Monetization if needed
|
||||
p.Collection = coll.CollectionObj // augmentReadingDestination requires a populated Collection field
|
||||
p.augmentReadingDestination()
|
||||
// Create the item for the feed
|
||||
title = p.PlainDisplayTitle()
|
||||
permalink = fmt.Sprintf("%s%s", baseUrl, p.Slug.String)
|
||||
feed.Items = append(feed.Items, &Item{
|
||||
feed.Items = append(feed.Items, &feeds.Item{
|
||||
Id: fmt.Sprintf("%s%s", basePermalinkUrl, p.Slug.String),
|
||||
Title: title,
|
||||
Link: &Link{Href: permalink},
|
||||
Link: &feeds.Link{Href: permalink},
|
||||
Description: "<![CDATA[" + stripmd.Strip(p.Content) + "]]>",
|
||||
Content: applyMarkdown([]byte(p.Content), "", app.cfg),
|
||||
Author: &Author{author, ""},
|
||||
Content: string(p.HTMLContent),
|
||||
Author: &feeds.Author{author, ""},
|
||||
Created: p.Created,
|
||||
Updated: p.Updated,
|
||||
})
|
||||
|
|
131
go.mod
131
go.mod
|
@ -1,65 +1,98 @@
|
|||
module github.com/writeas/writefreely
|
||||
module github.com/writefreely/writefreely
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v0.3.1 // indirect
|
||||
github.com/alecthomas/gometalinter v3.0.0+incompatible // indirect
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf // indirect
|
||||
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.8.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0
|
||||
github.com/clbanning/mxj v1.8.4 // indirect
|
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 // indirect
|
||||
github.com/dustin/go-humanize v1.0.0
|
||||
github.com/fatih/color v1.7.0
|
||||
github.com/go-fed/httpsig v0.1.1-0.20190924171022-f4c36041199d // indirect
|
||||
github.com/go-sql-driver/mysql v1.4.1
|
||||
github.com/dustin/go-humanize v1.0.1
|
||||
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect
|
||||
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
|
||||
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect
|
||||
github.com/fatih/color v1.17.0
|
||||
github.com/go-ini/ini v1.67.0
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/go-test/deep v1.0.1 // indirect
|
||||
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect
|
||||
github.com/gologme/log v0.0.0-20181207131047-4e5d8ccb38e8 // indirect
|
||||
github.com/gobuffalo/envy v1.9.0 // indirect
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
|
||||
github.com/gorilla/feeds v1.1.0
|
||||
github.com/gorilla/mux v1.7.0
|
||||
github.com/gorilla/schema v1.0.2
|
||||
github.com/gorilla/sessions v1.2.0
|
||||
github.com/guregu/null v3.4.0+incompatible
|
||||
github.com/hashicorp/go-multierror v1.0.0
|
||||
github.com/gorilla/csrf v1.7.2
|
||||
github.com/gorilla/feeds v1.1.2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/schema v1.4.1
|
||||
github.com/gorilla/sessions v1.3.0
|
||||
github.com/gosimple/slug v1.14.0
|
||||
github.com/guregu/null v4.0.0+incompatible
|
||||
github.com/hashicorp/go-multierror v1.1.1
|
||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2
|
||||
github.com/jtolds/gls v4.2.1+incompatible // indirect
|
||||
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
|
||||
github.com/lunixbochs/vtclean v1.0.0 // indirect
|
||||
github.com/manifoldco/promptui v0.3.2
|
||||
github.com/mattn/go-colorable v0.1.0 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.10.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.2
|
||||
github.com/mitchellh/go-wordwrap v1.0.0
|
||||
github.com/nicksnyder/go-i18n v1.10.0 // indirect
|
||||
github.com/mailgun/mailgun-go v2.0.0+incompatible
|
||||
github.com/manifoldco/promptui v0.9.0
|
||||
github.com/mattn/go-sqlite3 v1.14.21
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/mitchellh/go-wordwrap v1.0.1
|
||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
|
||||
github.com/pelletier/go-toml v1.2.0 // indirect
|
||||
github.com/pkg/errors v0.8.1 // indirect
|
||||
github.com/onsi/ginkgo v1.16.4 // indirect
|
||||
github.com/onsi/gomega v1.13.0 // indirect
|
||||
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
|
||||
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect
|
||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect
|
||||
github.com/stretchr/testify v1.3.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/urfave/cli/v2 v2.27.4
|
||||
github.com/writeas/activity v0.1.2
|
||||
github.com/writeas/activityserve v0.0.0-20191115095800-dd6d19cc8b89
|
||||
github.com/writeas/go-mysqldump v0.5.2-0.20200224191309-1bf29d35e962
|
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible
|
||||
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2
|
||||
github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835
|
||||
github.com/writeas/go-strip-markdown/v2 v2.1.1
|
||||
github.com/writeas/go-webfinger v1.1.0
|
||||
github.com/writeas/httpsig v1.0.0
|
||||
github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d
|
||||
github.com/writeas/import v0.2.0
|
||||
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219
|
||||
github.com/writeas/nerds v1.0.0
|
||||
github.com/writeas/saturday v1.7.1
|
||||
github.com/writeas/slug v1.2.0
|
||||
github.com/writeas/web-core v1.2.0
|
||||
github.com/writeas/impart v1.1.1
|
||||
github.com/writeas/import v0.2.1
|
||||
github.com/writeas/monday v1.3.0
|
||||
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320
|
||||
github.com/writeas/web-core v1.6.1-0.20231003013047-d81124d45431
|
||||
github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b
|
||||
github.com/writefreely/go-nodeinfo v1.2.0
|
||||
golang.org/x/crypto v0.0.0-20200109152110-61a87790db17
|
||||
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 // indirect
|
||||
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 // indirect
|
||||
google.golang.org/appengine v1.4.0 // indirect
|
||||
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c // indirect
|
||||
gopkg.in/ini.v1 v1.41.0
|
||||
gopkg.in/yaml.v2 v2.2.2 // indirect
|
||||
src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b // indirect
|
||||
golang.org/x/crypto v0.28.0
|
||||
golang.org/x/net v0.30.0
|
||||
)
|
||||
|
||||
go 1.13
|
||||
require github.com/xhit/go-simple-mail/v2 v2.16.0
|
||||
|
||||
require (
|
||||
code.as/core/socks v1.0.0 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/beevik/etree v1.1.0 // indirect
|
||||
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 // indirect
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 // indirect
|
||||
github.com/fatih/structs v1.1.0 // indirect
|
||||
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe // indirect
|
||||
github.com/gofrs/uuid v3.3.0+incompatible // indirect
|
||||
github.com/gologme/log v1.2.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gosimple/unidecode v1.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.0.0 // indirect
|
||||
github.com/joho/godotenv v1.3.0 // indirect
|
||||
github.com/jtolds/gls v4.2.1+incompatible // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.9.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/sasha-s/go-deadlock v0.3.1 // indirect
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect
|
||||
github.com/writeas/go-writeas/v2 v2.0.2 // indirect
|
||||
github.com/writeas/openssl-go v1.0.0 // indirect
|
||||
github.com/writeas/slug v1.2.0 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/text v0.19.0 // indirect
|
||||
gopkg.in/ini.v1 v1.62.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
go 1.21
|
||||
|
|
385
go.sum
385
go.sum
|
@ -1,18 +1,18 @@
|
|||
code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs=
|
||||
code.as/core/socks v1.0.0/go.mod h1:BAXBy5O9s2gmw6UxLqNJcVbWY7C/UPs+801CcSsfWOY=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.3.0 h1:ljjRxlddjfChBJdFKJs5LuCwCWPLaC1UZLwAo3PBBMk=
|
||||
github.com/DATA-DOG/go-sqlmock v1.3.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
|
||||
github.com/alecthomas/gometalinter v2.0.11+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk=
|
||||
github.com/alecthomas/gometalinter v3.0.0+incompatible h1:e9Zfvfytsw/e6Kd/PYd75wggK+/kX5Xn8IYDUKyc5fU=
|
||||
github.com/alecthomas/gometalinter v3.0.0+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
|
||||
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
|
||||
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
|
||||
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49 h1:jWNY1NDg6a/c8RSXkai7IX6UOhir0LD39I4Dukg+4Ks=
|
||||
github.com/captncraig/cors v0.0.0-20180620154129-376d45073b49/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ=
|
||||
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 h1:AFSJaASPGYNbkUa5c8ZybrcW9pP3Cy7+z5dnpcc/qG8=
|
||||
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ=
|
||||
github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8=
|
||||
|
@ -22,98 +22,150 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
|
|||
github.com/clbanning/mxj v1.8.3/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
||||
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
|
||||
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
||||
github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 h1:74lLNRzvsdIlkTgfDSMuaPjBr4cf6k7pwQQANm/yLKU=
|
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
|
||||
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs=
|
||||
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c h1:8ISkoahWXwZR41ois5lSJBSVw4D0OV19Ht/JSTzvSv0=
|
||||
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=
|
||||
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A=
|
||||
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=
|
||||
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 h1:7HZCaLC5+BZpmbhCOZJ293Lz68O7PYrF2EzeiFMwCLk=
|
||||
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
|
||||
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
|
||||
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
|
||||
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/go-fed/httpsig v0.1.0 h1:6F2OxRVnNTN4OPN+Mc2jxs2WEay9/qiHT/jphlvAwIY=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/go-fed/httpsig v0.1.0/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
|
||||
github.com/go-fed/httpsig v0.1.1-0.20190924171022-f4c36041199d h1:+uoOvOnNDgsYbWtAij4xP6Rgir3eJGjocFPxBJETU/U=
|
||||
github.com/go-fed/httpsig v0.1.1-0.20190924171022-f4c36041199d/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
|
||||
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe h1:U71giCx5NjRn4Lb71UuprPHqhjxGv3Jqonb9fgcaJH8=
|
||||
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
|
||||
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||
github.com/golang/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
|
||||
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1 h1:6DVPu65tee05kY0/rciBQ47ue+AnuY8KTayV6VHikIo=
|
||||
github.com/golang/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
|
||||
github.com/gobuffalo/envy v1.9.0 h1:eZR0DuEgVLfeIb1zIKt3bT4YovIMf9O9LXQeCZLXpqE=
|
||||
github.com/gobuffalo/envy v1.9.0/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w=
|
||||
github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84=
|
||||
github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/gologme/log v0.0.0-20181207131047-4e5d8ccb38e8 h1:WD8iJ37bRNwvETMfVTusVSAi0WdXTpfNVGY2aHycNKY=
|
||||
github.com/gologme/log v0.0.0-20181207131047-4e5d8ccb38e8/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U=
|
||||
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf h1:7+FW5aGwISbqUtkfmIpZJGRgNFg2ioYPvFaUxdqpDsg=
|
||||
github.com/google/shlex v0.0.0-20181106134648-c34317bd91bf/go.mod h1:RpwtwJQFrIEPstU94h88MWPXP2ektJZ8cZ0YntAmXiE=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/gologme/log v1.2.0 h1:Ya5Ip/KD6FX7uH0S31QO87nCCSucKtF44TLbTtO7V4c=
|
||||
github.com/gologme/log v1.2.0/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc h1:cJlkeAx1QYgO5N80aF5xRGstVsRQwgLR7uA2FnP1ZjY=
|
||||
github.com/gordonklaus/ineffassign v0.0.0-20180909121442-1003c8bd00dc/go.mod h1:cuNKsD1zp2v6XfE/orVX2QE1LC+i254ceGcVeDT3pTU=
|
||||
github.com/gorilla/feeds v1.1.0 h1:pcgLJhbdYgaUESnj3AmXPcB7cS3vy63+jC/TI14AGXk=
|
||||
github.com/gorilla/feeds v1.1.0/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
|
||||
github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U=
|
||||
github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/schema v1.0.2 h1:sAgNfOcNYvdDSrzGHVy9nzCQahG+qmsg+nE8dK85QRA=
|
||||
github.com/gorilla/schema v1.0.2/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ=
|
||||
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/guregu/null v3.4.0+incompatible h1:a4mw37gBO7ypcBlTJeZGuMpSxxFTV9qFfFKgWxQSGaM=
|
||||
github.com/guregu/null v3.4.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM=
|
||||
github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
|
||||
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/feeds v1.1.2 h1:pxzZ5PD3RJdhFH2FsJJ4x6PqMqbgFk1+Vez4XWBW8Iw=
|
||||
github.com/gorilla/feeds v1.1.2/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
|
||||
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
||||
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.3.0 h1:XYlkq7KcpOB2ZhHBPv5WpjMIxrQosiZanfoy1HLZFzg=
|
||||
github.com/gorilla/sessions v1.3.0/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
|
||||
github.com/gosimple/slug v1.14.0 h1:RtTL/71mJNDfpUbCOmnf/XFkzKRtD6wL6Uy+3akm4Es=
|
||||
github.com/gosimple/slug v1.14.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
|
||||
github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
|
||||
github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
|
||||
github.com/guregu/null v4.0.0+incompatible h1:4zw0ckM7ECd6FNNddc3Fu4aty9nTlpkkzH7dPn4/4Gw=
|
||||
github.com/guregu/null v4.0.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM=
|
||||
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o=
|
||||
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||
github.com/hashicorp/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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM=
|
||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw=
|
||||
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
|
||||
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
||||
github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
|
||||
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU=
|
||||
github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU=
|
||||
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/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
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/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec h1:ZXWuspqypleMuJy4bzYEqlMhJnGAYpLrWe5p7W3CdvI=
|
||||
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec/go.mod h1:voECJzdraJmolzPBgL9Z7ANwXf4oMXaTCsIkdiPpR/g=
|
||||
github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw=
|
||||
github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
|
||||
github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8=
|
||||
github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
|
||||
github.com/manifoldco/promptui v0.3.2 h1:rir7oByTERac6jhpHUPErHuopoRDvO3jxS+FdadEns8=
|
||||
github.com/manifoldco/promptui v0.3.2/go.mod h1:8JU+igZ+eeiiRku4T5BjtKh2ms8sziGpSYl1gN8Bazw=
|
||||
github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-colorable v0.1.0 h1:v2XXALHHh6zHfYTJ+cSkwtyffnaOyR1MXaA91mTrb8o=
|
||||
github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
|
||||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o=
|
||||
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
|
||||
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
|
||||
github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4=
|
||||
github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
|
||||
github.com/nicksnyder/go-i18n v1.10.0 h1:5AzlPKvXBH4qBzmZ09Ua9Gipyruv6uApMcrNZdo96+Q=
|
||||
github.com/nicksnyder/go-i18n v1.10.0/go.mod h1:HrK7VCrbOvQoUAQ7Vpy7i87N7JZZZ7R2xBGjv0j365Q=
|
||||
github.com/mailgun/mailgun-go v2.0.0+incompatible h1:0FoRHWwMUctnd8KIR3vtZbqdfjpIMxOZgcSa51s8F8o=
|
||||
github.com/mailgun/mailgun-go v2.0.0+incompatible/go.mod h1:NWTyU+O4aczg/nsGhQnvHL6v2n5Gy6Sv5tNDVvC6FbU=
|
||||
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
|
||||
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.21 h1:IXocQLOykluc3xPE0Lvy8FtggMz1G+U3mEjg+0zGizc=
|
||||
github.com/mattn/go-sqlite3 v1.14.21/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
|
||||
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
|
||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
|
||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
|
||||
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E=
|
||||
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.13.0 h1:7lLHu94wT9Ij0o6EWWclhu0aOh32VxhkwEJvzuWPeak=
|
||||
github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY=
|
||||
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ=
|
||||
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
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/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
|
||||
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
|
||||
github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
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/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0=
|
||||
github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 h1:Jpy1PXuP99tXNrhbq2BaPz9B+jNAvH1JPQQpG/9GCXY=
|
||||
|
@ -121,88 +173,161 @@ github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1
|
|||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w=
|
||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9 h1:vY5WqiEon0ZSTGM3ayVVi+twaHKHDFUVloaQ/wug9/c=
|
||||
github.com/tsenart/deadcode v0.0.0-20160724212837-210d2dc333e9/go.mod h1:q+QjxYvZ+fpjMXqs+XEriussHjSYqeXVnAdSV1tkMYk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM=
|
||||
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
|
||||
github.com/urfave/cli/v2 v2.27.4 h1:o1owoI+02Eb+K107p27wEX9Bb8eqIoZCfLXloLUSWJ8=
|
||||
github.com/urfave/cli/v2 v2.27.4/go.mod h1:m4QzxcD2qpra4z7WhzEGn74WZLViBnMpb1ToCAKdGRQ=
|
||||
github.com/writeas/activity v0.1.2 h1:Y12B5lIrabfqKE7e7HFCWiXrlfXljr9tlkFm2mp7DgY=
|
||||
github.com/writeas/activity v0.1.2/go.mod h1:mYYgiewmEM+8tlifirK/vl6tmB2EbjYaxwb+ndUw5T0=
|
||||
github.com/writeas/activityserve v0.0.0-20191008122325-5fc3b48e70c5 h1:nG84xWpxBM8YU/FJchezJqg7yZH8ImSRow6NoYtbSII=
|
||||
github.com/writeas/activityserve v0.0.0-20191008122325-5fc3b48e70c5/go.mod h1:Kz62mzYsCnrFTSTSFLXFj3fGYBQOntmBWTDDq57b46A=
|
||||
github.com/writeas/activityserve v0.0.0-20191011072627-3a81f7784d5b h1:rd2wX/bTqD55hxtBjAhwLcUgaQE36c70KX3NzpDAwVI=
|
||||
github.com/writeas/activityserve v0.0.0-20191011072627-3a81f7784d5b/go.mod h1:Kz62mzYsCnrFTSTSFLXFj3fGYBQOntmBWTDDq57b46A=
|
||||
github.com/writeas/activityserve v0.0.0-20191115095800-dd6d19cc8b89 h1:NJhzq9aTccL3SSSZMrcnYhkD6sObdY9otNZ1X6/ZKNE=
|
||||
github.com/writeas/activityserve v0.0.0-20191115095800-dd6d19cc8b89/go.mod h1:Kz62mzYsCnrFTSTSFLXFj3fGYBQOntmBWTDDq57b46A=
|
||||
github.com/writeas/go-mysqldump v0.5.2-0.20200224191309-1bf29d35e962 h1:UGcFuq/0VQ6y5uX37AFfCkwYTIg89S8B0d/R94q8DxE=
|
||||
github.com/writeas/go-mysqldump v0.5.2-0.20200224191309-1bf29d35e962/go.mod h1:Zuj7/A1hkMz7k0B8arb9bjzMYgfhsfcxCfqE8SyF0Vk=
|
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
|
||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE=
|
||||
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2 h1:DUsp4OhdfI+e6iUqcPQlwx8QYXuUDsToTz/x82D3Zuo=
|
||||
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc=
|
||||
github.com/writeas/go-writeas v1.1.0 h1:WHGm6wriBkxYAOGbvriXH8DlMUGOi6jhSZLUZKQ+4mQ=
|
||||
github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835 h1:bm/7gYo6y3GxtTa1qyUFyCk29CTnBAKt7z4D2MASYrw=
|
||||
github.com/writeas/activityserve v0.0.0-20230428180247-dc13a4f4d835/go.mod h1:4akDJSl+sSp+QhrQKMqzAqdV1gJ1pPx6XPI77zgMM8o=
|
||||
github.com/writeas/go-strip-markdown/v2 v2.1.1 h1:hAxUM21Uhznf/FnbVGiJciqzska6iLei22Ijc3q2e28=
|
||||
github.com/writeas/go-strip-markdown/v2 v2.1.1/go.mod h1:UvvgPJgn1vvN8nWuE5e7v/+qmDu3BSVnKAB6Gl7hFzA=
|
||||
github.com/writeas/go-webfinger v1.1.0 h1:MzNyt0ry/GMsRmJGftn2o9mPwqK1Q5MLdh4VuJCfb1Q=
|
||||
github.com/writeas/go-webfinger v1.1.0/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc=
|
||||
github.com/writeas/go-writeas v1.1.0/go.mod h1:oh9U1rWaiE0p3kzdKwwvOpNXgp0P0IELI7OLOwV4fkA=
|
||||
github.com/writeas/go-writeas/v2 v2.0.2 h1:akvdMg89U5oBJiCkBwOXljVLTqP354uN6qnG2oOMrbk=
|
||||
github.com/writeas/go-writeas/v2 v2.0.2/go.mod h1:9sjczQJKmru925fLzg0usrU1R1tE4vBmQtGnItUMR0M=
|
||||
github.com/writeas/httpsig v1.0.0 h1:peIAoIA3DmlP8IG8tMNZqI4YD1uEnWBmkcC9OFPjt3A=
|
||||
github.com/writeas/httpsig v1.0.0/go.mod h1:7ClMGSrSVXJbmiLa17bZ1LrG1oibGZmUMlh3402flPY=
|
||||
github.com/writeas/impart v1.1.0 h1:nPnoO211VscNkp/gnzir5UwCDEvdHThL5uELU60NFSE=
|
||||
github.com/writeas/impart v1.1.0/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
|
||||
github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d h1:PK7DOj3JE6MGf647esPrKzXEHFjGWX2hl22uX79ixaE=
|
||||
github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
|
||||
github.com/writeas/import v0.2.0 h1:Ov23JW9Rnjxk06rki1Spar45bNX647HhwhAZj3flJiY=
|
||||
github.com/writeas/import v0.2.0/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM=
|
||||
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 h1:baEp0631C8sT2r/hqwypIw2snCFZa6h7U6TojoLHu/c=
|
||||
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219/go.mod h1:NyM35ayknT7lzO6O/1JpfgGyv+0W9Z9q7aE0J8bXxfQ=
|
||||
github.com/writeas/nerds v1.0.0 h1:ZzRcCN+Sr3MWID7o/x1cr1ZbLvdpej9Y1/Ho+JKlqxo=
|
||||
github.com/writeas/nerds v1.0.0/go.mod h1:Gn2bHy1EwRcpXeB7ZhVmuUwiweK0e+JllNf66gvNLdU=
|
||||
github.com/writeas/impart v1.1.1 h1:RyA9+CqbdbDuz53k+nXCWUY+NlEkdyw6+nWanxSBl5o=
|
||||
github.com/writeas/impart v1.1.1/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
|
||||
github.com/writeas/import v0.2.1 h1:3k+bDNCyqaWdZinyUZtEO4je3mR6fr/nE4ozTh9/9Wg=
|
||||
github.com/writeas/import v0.2.1/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM=
|
||||
github.com/writeas/monday v1.3.0 h1:h51wJ0DULXIDZ1w11zutLL7YCBRO5LznXISSzqVLZeA=
|
||||
github.com/writeas/monday v1.3.0/go.mod h1:9/CdGLDdIeAvzvf4oeihX++PE/qXUT2+tUlPQKCfRWY=
|
||||
github.com/writeas/openssl-go v1.0.0 h1:YXM1tDXeYOlTyJjoMlYLQH1xOloUimSR1WMF8kjFc5o=
|
||||
github.com/writeas/openssl-go v1.0.0/go.mod h1:WsKeK5jYl0B5y8ggOmtVjbmb+3rEGqSD25TppjJnETA=
|
||||
github.com/writeas/saturday v1.6.0/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
|
||||
github.com/writeas/saturday v1.7.1 h1:lYo1EH6CYyrFObQoA9RNWHVlpZA5iYL5Opxo7PYAnZE=
|
||||
github.com/writeas/saturday v1.7.1/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
|
||||
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320 h1:PozPZ29CQ/xt6ym/+FvIz+KvKEObSSc5ye+95zbTjVU=
|
||||
github.com/writeas/saturday v1.7.2-0.20200427193424-392b95a03320/go.mod h1:ETE1EK6ogxptJpAgUbcJD0prAtX48bSloie80+tvnzQ=
|
||||
github.com/writeas/slug v1.2.0 h1:EMQ+cwLiOcA6EtFwUgyw3Ge18x9uflUnOnR6bp/J+/g=
|
||||
github.com/writeas/slug v1.2.0/go.mod h1:RE8shOqQP3YhsfsQe0L3RnuejfQ4Mk+JjY5YJQFubfQ=
|
||||
github.com/writeas/web-core v1.2.0 h1:CYqvBd+byi1cK4mCr1NZ6CjILuMOFmiFecv+OACcmG0=
|
||||
github.com/writeas/web-core v1.2.0/go.mod h1:vTYajviuNBAxjctPp2NUYdgjofywVkxUGpeaERF3SfI=
|
||||
github.com/writeas/web-core v1.6.1-0.20231003013047-d81124d45431 h1:ruqL2u87k504PXkR/fC4DcfZyyHmCindlpjOQKmyOsY=
|
||||
github.com/writeas/web-core v1.6.1-0.20231003013047-d81124d45431/go.mod h1:7+idL4Y4woF7MnUfNX2mvkaQ8nLIJXths2y5iYPtA3k=
|
||||
github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b h1:h3NzB8OZ50NNi5k9yrFeyFszt3LyqyVK4+xUHFYY8B0=
|
||||
github.com/writefreely/go-gopher v0.0.0-20220429181814-40127126f83b/go.mod h1:T2UVVzt+R5KSSZe2xRSytnwc2M9AoDegi7foeIsik+M=
|
||||
github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss=
|
||||
github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg=
|
||||
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59 h1:hk3yo72LXLapY9EXVttc3Z1rLOxT9IuAPPX3GpY2+jo=
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA=
|
||||
github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200109152110-61a87790db17 h1:nVJ3guKA9qdkEQ3TUdXI9QSINo2CUPM/cySEvw2w8I0=
|
||||
golang.org/x/crypto v0.0.0-20200109152110-61a87790db17/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1 h1:rJm0LuqUjoDhSk2zO9ISMSToQxGz7Os2jRiOL8AWu4c=
|
||||
golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3 h1:eH6Eip3UpmR+yM/qI9Ijluzb1bNv/cAU/n+6l8tRSis=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/tools v0.0.0-20181122213734-04b5d21e00f1/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67 h1:bPP/rGuN1LUM0eaEwo6vnP6OfIWJzJBulzGUiKLjjSY=
|
||||
golang.org/x/tools v0.0.0-20190208222737-3744606dbb67/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c h1:vTxShRUnK60yd8DZU+f95p1zSLj814+5CuEh7NjF2/Y=
|
||||
gopkg.in/alecthomas/kingpin.v3-unstable v3.0.0-20180810215634-df19058c872c/go.mod h1:3HH7i1SgMqlzxCcBmUHW657sD4Kvv9sC3HpL3YukzwA=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
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/ini.v1 v1.41.0 h1:Ka3ViY6gNYSKiVy71zXBEqKplnV35ImDLVG+8uoIklE=
|
||||
gopkg.in/ini.v1 v1.41.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
|
||||
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU=
|
||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b h1:rPAdjgXks4ToezTjygsnKZroxKVnA1L35DSpsJXPtfc=
|
||||
src.techknowlogick.com/xgo v0.0.0-20200129005940-d0fae26e014b/go.mod h1:31CE1YKtDOrKTk9PSnjTpe6YbO6W/0LTYZ1VskL09oU=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
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=
|
||||
|
|
167
gopher.go
Normal file
167
gopher.go
Normal file
|
@ -0,0 +1,167 @@
|
|||
/*
|
||||
* Copyright © 2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/go-gopher"
|
||||
)
|
||||
|
||||
func initGopher(apper Apper) {
|
||||
handler := NewWFHandler(apper)
|
||||
|
||||
gopher.HandleFunc("/", handler.Gopher(handleGopher))
|
||||
log.Info("Serving on gopher://localhost:%d", apper.App().Config().Server.GopherPort)
|
||||
gopher.ListenAndServe(fmt.Sprintf(":%d", apper.App().Config().Server.GopherPort), nil)
|
||||
}
|
||||
|
||||
// Utility function to strip the URL from the hostname provided by app.cfg.App.Host
|
||||
func stripHostProtocol(app *App) string {
|
||||
u, err := url.Parse(app.cfg.App.Host)
|
||||
if err != nil {
|
||||
// Fall back to host, with scheme stripped
|
||||
return string(regexp.MustCompile("^.*://").ReplaceAll([]byte(app.cfg.App.Host), []byte("")))
|
||||
}
|
||||
return u.Hostname()
|
||||
}
|
||||
|
||||
func handleGopher(app *App, w gopher.ResponseWriter, r *gopher.Request) error {
|
||||
parts := strings.Split(r.Selector, "/")
|
||||
if app.cfg.App.SingleUser {
|
||||
if parts[1] != "" {
|
||||
return handleGopherCollectionPost(app, w, r)
|
||||
}
|
||||
return handleGopherCollection(app, w, r)
|
||||
}
|
||||
|
||||
// Show all public collections (a gopher Reader view, essentially)
|
||||
if len(parts) == 3 {
|
||||
return handleGopherCollection(app, w, r)
|
||||
}
|
||||
|
||||
w.WriteInfo(fmt.Sprintf("Welcome to %s", app.cfg.App.SiteName))
|
||||
|
||||
colls, err := app.db.GetPublicCollections(app.cfg.App.Host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, c := range *colls {
|
||||
w.WriteItem(&gopher.Item{
|
||||
Host: stripHostProtocol(app),
|
||||
Port: app.cfg.Server.GopherPort,
|
||||
Type: gopher.DIRECTORY,
|
||||
Description: c.DisplayTitle(),
|
||||
Selector: "/" + c.Alias + "/",
|
||||
})
|
||||
}
|
||||
return w.End()
|
||||
}
|
||||
|
||||
func handleGopherCollection(app *App, w gopher.ResponseWriter, r *gopher.Request) error {
|
||||
var collAlias, slug string
|
||||
var c *Collection
|
||||
var err error
|
||||
var baseSel = "/"
|
||||
|
||||
parts := strings.Split(r.Selector, "/")
|
||||
if app.cfg.App.SingleUser {
|
||||
// sanity check
|
||||
slug = parts[1]
|
||||
if slug != "" {
|
||||
return handleGopherCollectionPost(app, w, r)
|
||||
}
|
||||
|
||||
c, err = app.db.GetCollectionByID(1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
collAlias = parts[1]
|
||||
slug = parts[2]
|
||||
if slug != "" {
|
||||
return handleGopherCollectionPost(app, w, r)
|
||||
}
|
||||
|
||||
c, err = app.db.GetCollection(collAlias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
baseSel = "/" + c.Alias + "/"
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
w.WriteInfo(c.DisplayTitle())
|
||||
if c.Description != "" {
|
||||
w.WriteInfo(c.Description)
|
||||
}
|
||||
|
||||
posts, err := app.db.GetPosts(app.cfg, c, 0, false, false, false, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, p := range *posts {
|
||||
w.WriteItem(&gopher.Item{
|
||||
Port: app.cfg.Server.GopherPort,
|
||||
Host: stripHostProtocol(app),
|
||||
Type: gopher.FILE,
|
||||
Description: p.CreatedDate() + " - " + p.DisplayTitle(),
|
||||
Selector: baseSel + p.Slug.String,
|
||||
})
|
||||
}
|
||||
return w.End()
|
||||
}
|
||||
|
||||
func handleGopherCollectionPost(app *App, w gopher.ResponseWriter, r *gopher.Request) error {
|
||||
var collAlias, slug string
|
||||
var c *Collection
|
||||
var err error
|
||||
|
||||
parts := strings.Split(r.Selector, "/")
|
||||
if app.cfg.App.SingleUser {
|
||||
slug = parts[1]
|
||||
c, err = app.db.GetCollectionByID(1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
collAlias = parts[1]
|
||||
slug = parts[2]
|
||||
c, err = app.db.GetCollection(collAlias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c.hostName = app.cfg.App.Host
|
||||
|
||||
p, err := app.db.GetPost(slug, c.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b := bytes.Buffer{}
|
||||
if p.Title.String != "" {
|
||||
b.WriteString(p.Title.String + "\n")
|
||||
}
|
||||
b.WriteString(p.DisplayDate + "\n\n")
|
||||
b.WriteString(p.Content)
|
||||
io.Copy(w, &b)
|
||||
|
||||
return w.End()
|
||||
}
|
129
handle.go
129
handle.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -23,8 +23,9 @@ import (
|
|||
"github.com/gorilla/sessions"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writeas/writefreely/page"
|
||||
"github.com/writefreely/go-gopher"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
"github.com/writefreely/writefreely/page"
|
||||
)
|
||||
|
||||
// UserLevel represents the required user level for accessing an endpoint
|
||||
|
@ -64,6 +65,7 @@ func UserLevelReader(cfg *config.Config) UserLevel {
|
|||
|
||||
type (
|
||||
handlerFunc func(app *App, w http.ResponseWriter, r *http.Request) error
|
||||
gopherFunc func(app *App, w gopher.ResponseWriter, r *gopher.Request) error
|
||||
userHandlerFunc func(app *App, u *User, w http.ResponseWriter, r *http.Request) error
|
||||
userApperHandlerFunc func(apper Apper, u *User, w http.ResponseWriter, r *http.Request) error
|
||||
dataHandlerFunc func(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error)
|
||||
|
@ -83,6 +85,7 @@ type ErrorPages struct {
|
|||
NotFound *template.Template
|
||||
Gone *template.Template
|
||||
InternalServerError *template.Template
|
||||
UnavailableError *template.Template
|
||||
Blank *template.Template
|
||||
}
|
||||
|
||||
|
@ -94,6 +97,7 @@ func NewHandler(apper Apper) *Handler {
|
|||
NotFound: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>404</title></head><body><p>Not found.</p></body></html>{{end}}")),
|
||||
Gone: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>410</title></head><body><p>Gone.</p></body></html>{{end}}")),
|
||||
InternalServerError: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>500</title></head><body><p>Internal server error.</p></body></html>{{end}}")),
|
||||
UnavailableError: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>503</title></head><body><p>Service is temporarily unavailable.</p></body></html>{{end}}")),
|
||||
Blank: template.Must(template.New("").Parse("{{define \"base\"}}<html><head><title>{{.Title}}</title></head><body><p>{{.Content}}</p></body></html>{{end}}")),
|
||||
},
|
||||
sessionStore: apper.App().SessionStore(),
|
||||
|
@ -111,6 +115,7 @@ func NewWFHandler(apper Apper) *Handler {
|
|||
NotFound: pages["404-general.tmpl"],
|
||||
Gone: pages["410.tmpl"],
|
||||
InternalServerError: pages["500.tmpl"],
|
||||
UnavailableError: pages["503.tmpl"],
|
||||
Blank: pages["blank.tmpl"],
|
||||
})
|
||||
return h
|
||||
|
@ -150,8 +155,14 @@ func (h *Handler) User(f userHandlerFunc) http.HandlerFunc {
|
|||
err := f(h.app.App(), u, w, r)
|
||||
if err == nil {
|
||||
status = http.StatusOK
|
||||
} else if err, ok := err.(impart.HTTPError); ok {
|
||||
status = err.Status
|
||||
} else if impErr, ok := err.(impart.HTTPError); ok {
|
||||
status = impErr.Status
|
||||
if impErr == ErrUserNotFound {
|
||||
log.Info("Logged-in user not found. Logging out.")
|
||||
sendRedirect(w, http.StatusFound, "/me/logout?to="+h.app.App().cfg.App.LandingPath())
|
||||
// Reset err so handleHTTPError does nothing
|
||||
err = nil
|
||||
}
|
||||
} else {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
|
@ -251,7 +262,7 @@ func apiAuth(app *App, r *http.Request) (*User, error) {
|
|||
return u, nil
|
||||
}
|
||||
|
||||
// optionaAPIAuth is used for endpoints that accept authenticated requests via
|
||||
// optionalAPIAuth is used for endpoints that accept authenticated requests via
|
||||
// Authorization header or cookie, unlike apiAuth. It returns a different err
|
||||
// in the case where no Authorization header is present.
|
||||
func optionalAPIAuth(app *App, r *http.Request) (*User, error) {
|
||||
|
@ -282,6 +293,26 @@ func (h *Handler) UserAPI(f userHandlerFunc) http.HandlerFunc {
|
|||
return h.UserAll(false, f, apiAuth)
|
||||
}
|
||||
|
||||
// UserWebAPI handles endpoints that accept a user authorized either via the web (cookies) or an Authorization header.
|
||||
func (h *Handler) UserWebAPI(f userHandlerFunc) http.HandlerFunc {
|
||||
return h.UserAll(false, f, func(app *App, r *http.Request) (*User, error) {
|
||||
// Authorize user via cookies
|
||||
u := getUserSession(app, r)
|
||||
if u != nil {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Fall back to access token, since user isn't logged in via web
|
||||
var err error
|
||||
u, err = apiAuth(app, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return u, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) UserAll(web bool, f userHandlerFunc, a authFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
handleFunc := func() error {
|
||||
|
@ -549,6 +580,38 @@ func (h *Handler) All(f handlerFunc) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
func (h *Handler) PlainTextAPI(f handlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleTextError(w, r, func() error {
|
||||
// TODO: return correct "success" status
|
||||
status := 200
|
||||
start := time.Now()
|
||||
|
||||
defer func() {
|
||||
if e := recover(); e != nil {
|
||||
log.Error("%s:\n%s", e, debug.Stack())
|
||||
status = http.StatusInternalServerError
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprintf(w, "Something didn't work quite right. The robots have alerted the humans.")
|
||||
}
|
||||
|
||||
log.Info(fmt.Sprintf("\"%s %s\" %d %s \"%s\" \"%s\"", r.Method, r.RequestURI, status, time.Since(start), r.UserAgent(), r.Host))
|
||||
}()
|
||||
|
||||
err := f(h.app.App(), w, r)
|
||||
if err != nil {
|
||||
if err, ok := err.(impart.HTTPError); ok {
|
||||
status = err.Status
|
||||
} else {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}())
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) OAuth(f handlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleOAuthError(w, r, func() error {
|
||||
|
@ -596,6 +659,9 @@ func (h *Handler) AllReader(f handlerFunc) http.HandlerFunc {
|
|||
log.Info(h.app.ReqLog(r, status, time.Since(start)))
|
||||
}()
|
||||
|
||||
// Allow any origin, as public endpoints are handled in here
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
if h.app.App().cfg.App.Private {
|
||||
// This instance is private, so ensure it's being accessed by a valid user
|
||||
// Check if authenticated with an access token
|
||||
|
@ -752,7 +818,7 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er
|
|||
return
|
||||
} else if err.Status == http.StatusNotFound {
|
||||
w.WriteHeader(err.Status)
|
||||
if strings.Contains(r.Header.Get("Accept"), "application/activity+json") {
|
||||
if IsActivityPubRequest(r) {
|
||||
// This is a fediverse request; simply return the header
|
||||
return
|
||||
}
|
||||
|
@ -763,6 +829,10 @@ func (h *Handler) handleHTTPError(w http.ResponseWriter, r *http.Request, err er
|
|||
log.Info("handleHTTPErorr internal error render")
|
||||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
return
|
||||
} else if err.Status == http.StatusServiceUnavailable {
|
||||
w.WriteHeader(err.Status)
|
||||
h.errors.UnavailableError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
return
|
||||
} else if err.Status == http.StatusAccepted {
|
||||
impart.WriteSuccess(w, "", err.Status)
|
||||
return
|
||||
|
@ -810,6 +880,26 @@ func (h *Handler) handleError(w http.ResponseWriter, r *http.Request, err error)
|
|||
h.errors.InternalServerError.ExecuteTemplate(w, "base", pageForReq(h.app.App(), r))
|
||||
}
|
||||
|
||||
func (h *Handler) handleTextError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err, ok := err.(impart.HTTPError); ok {
|
||||
if err.Status >= 300 && err.Status < 400 {
|
||||
sendRedirect(w, err.Status, err.Message)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(err.Status)
|
||||
fmt.Fprintf(w, http.StatusText(err.Status))
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, "This is an unhelpful error message for a miscellaneous internal error.")
|
||||
}
|
||||
|
||||
func (h *Handler) handleOAuthError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
|
@ -891,8 +981,33 @@ func (h *Handler) LogHandlerFunc(f http.HandlerFunc) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Gopher(f gopherFunc) gopher.HandlerFunc {
|
||||
return func(w gopher.ResponseWriter, r *gopher.Request) {
|
||||
defer func() {
|
||||
if e := recover(); e != nil {
|
||||
log.Error("%s: %s", e, debug.Stack())
|
||||
w.WriteError("An internal error occurred")
|
||||
}
|
||||
log.Info("gopher: %s", r.Selector)
|
||||
}()
|
||||
|
||||
err := f(h.app.App(), w, r)
|
||||
if err != nil {
|
||||
log.Error("failed: %s", err)
|
||||
w.WriteError("the page failed for some reason (see logs)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendRedirect(w http.ResponseWriter, code int, location string) int {
|
||||
w.Header().Set("Location", location)
|
||||
w.WriteHeader(code)
|
||||
return code
|
||||
}
|
||||
|
||||
func cacheControl(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2019 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
* Copyright © 2018 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
|
28
invites.go
28
invites.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -19,9 +19,9 @@ import (
|
|||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/nerds/store"
|
||||
"github.com/writeas/web-core/id"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/page"
|
||||
"github.com/writefreely/writefreely/page"
|
||||
)
|
||||
|
||||
type Invite struct {
|
||||
|
@ -42,6 +42,18 @@ func (i Invite) Expired() bool {
|
|||
return i.Expires != nil && i.Expires.Before(time.Now())
|
||||
}
|
||||
|
||||
func (i Invite) Active(db *datastore) bool {
|
||||
if i.Expired() {
|
||||
return false
|
||||
}
|
||||
if i.MaxUses.Valid && i.MaxUses.Int64 > 0 {
|
||||
if c := db.GetUsersInvitedCount(i.ID); c >= i.MaxUses.Int64 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (i Invite) ExpiresFriendly() string {
|
||||
return i.Expires.Format("January 2, 2006, 3:04 PM")
|
||||
}
|
||||
|
@ -66,6 +78,9 @@ func handleViewUserInvites(app *App, u *User, w http.ResponseWriter, r *http.Req
|
|||
|
||||
p.Silenced, err = app.db.IsUserSilenced(u.ID)
|
||||
if err != nil {
|
||||
if err == ErrUserNotFound {
|
||||
return err
|
||||
}
|
||||
log.Error("view invites: %v", err)
|
||||
}
|
||||
|
||||
|
@ -109,7 +124,7 @@ func handleCreateUserInvite(app *App, u *User, w http.ResponseWriter, r *http.Re
|
|||
expDate = &ed
|
||||
}
|
||||
|
||||
inviteID := store.GenerateRandomString("0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz", 6)
|
||||
inviteID := id.GenerateRandomString("0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz", 6)
|
||||
err = app.db.CreateUserInvite(inviteID, u.ID, maxUses, expDate)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -158,11 +173,13 @@ func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
|
||||
p := struct {
|
||||
page.StaticPage
|
||||
*OAuthButtons
|
||||
Error string
|
||||
Flashes []template.HTML
|
||||
Invite string
|
||||
}{
|
||||
StaticPage: pageForReq(app, r),
|
||||
OAuthButtons: NewOAuthButtons(app.cfg),
|
||||
Invite: inviteCode,
|
||||
}
|
||||
|
||||
|
@ -170,6 +187,9 @@ func handleViewInvite(app *App, w http.ResponseWriter, r *http.Request) error {
|
|||
p.Error = "This invite link has expired."
|
||||
}
|
||||
|
||||
// Tell search engines not to index invite links
|
||||
w.Header().Set("X-Robots-Tag", "noindex")
|
||||
|
||||
// Get error messages
|
||||
session, err := app.sessionStore.Get(r, cookieName)
|
||||
if err != nil {
|
||||
|
|
72
jobs.go
Normal file
72
jobs.go
Normal file
|
@ -0,0 +1,72 @@
|
|||
package writefreely
|
||||
|
||||
import (
|
||||
"github.com/writeas/web-core/log"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PostJob struct {
|
||||
ID int64
|
||||
PostID string
|
||||
Action string
|
||||
Delay int64
|
||||
}
|
||||
|
||||
func addJob(app *App, p *PublicPost, action string, delay int64) error {
|
||||
j := &PostJob{
|
||||
PostID: p.ID,
|
||||
Action: action,
|
||||
Delay: delay,
|
||||
}
|
||||
return app.db.InsertJob(j)
|
||||
}
|
||||
|
||||
func startPublishJobsQueue(app *App) {
|
||||
t := time.NewTicker(62 * time.Second)
|
||||
for {
|
||||
log.Info("[jobs] Done.")
|
||||
<-t.C
|
||||
log.Info("[jobs] Fetching email publish jobs...")
|
||||
jobs, err := app.db.GetJobsToRun("email")
|
||||
if err != nil {
|
||||
log.Error("[jobs] %s - Skipping.", err)
|
||||
continue
|
||||
}
|
||||
log.Info("[jobs] Running %d email publish jobs...", len(jobs))
|
||||
err = runJobs(app, jobs, true)
|
||||
if err != nil {
|
||||
log.Error("[jobs] Failed: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runJobs(app *App, jobs []*PostJob, reqColl bool) error {
|
||||
for _, j := range jobs {
|
||||
p, err := app.db.GetPost(j.PostID, 0)
|
||||
if err != nil {
|
||||
log.Info("[job #%d] Unable to get post: %s", j.ID, err)
|
||||
continue
|
||||
}
|
||||
if !p.CollectionID.Valid && reqColl {
|
||||
log.Info("[job #%d] Post %s not part of a collection", j.ID, p.ID)
|
||||
app.db.DeleteJob(j.ID)
|
||||
continue
|
||||
}
|
||||
coll, err := app.db.GetCollectionByID(p.CollectionID.Int64)
|
||||
if err != nil {
|
||||
log.Info("[job #%d] Unable to get collection: %s", j.ID, err)
|
||||
continue
|
||||
}
|
||||
coll.hostName = app.cfg.App.Host
|
||||
coll.ForPublic()
|
||||
p.Collection = &CollectionObj{Collection: *coll}
|
||||
err = emailPost(app, p, p.Collection.ID)
|
||||
if err != nil {
|
||||
log.Error("[job #%d] Failed to email post %s", j.ID, p.ID)
|
||||
continue
|
||||
}
|
||||
log.Info("[job #%d] Success for post %s.", j.ID, p.ID)
|
||||
app.db.DeleteJob(j.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
10
key/key.go
10
key/key.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019, 2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -20,7 +20,7 @@ const (
|
|||
)
|
||||
|
||||
type Keychain struct {
|
||||
EmailKey, CookieAuthKey, CookieKey []byte
|
||||
EmailKey, CookieAuthKey, CookieKey, CSRFKey []byte
|
||||
}
|
||||
|
||||
// GenerateKeys generates necessary keys for the app on the given Keychain,
|
||||
|
@ -47,6 +47,12 @@ func (keys *Keychain) GenerateKeys() error {
|
|||
keyErrs = err
|
||||
}
|
||||
}
|
||||
if len(keys.CSRFKey) == 0 {
|
||||
keys.CSRFKey, err = GenerateBytes(EncKeysBytes)
|
||||
if err != nil {
|
||||
keyErrs = err
|
||||
}
|
||||
}
|
||||
|
||||
return keyErrs
|
||||
}
|
||||
|
|
11
keys.go
11
keys.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018-2019 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2019, 2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -12,8 +12,7 @@ package writefreely
|
|||
|
||||
import (
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/key"
|
||||
"io/ioutil"
|
||||
"github.com/writefreely/writefreely/key"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
@ -26,6 +25,7 @@ var (
|
|||
emailKeyPath = filepath.Join(keysDir, "email.aes256")
|
||||
cookieAuthKeyPath = filepath.Join(keysDir, "cookies_auth.aes256")
|
||||
cookieKeyPath = filepath.Join(keysDir, "cookies_enc.aes256")
|
||||
csrfKeyPath = filepath.Join(keysDir, "csrf.aes256")
|
||||
)
|
||||
|
||||
// InitKeys loads encryption keys into memory via the given Apper interface
|
||||
|
@ -42,6 +42,7 @@ func initKeyPaths(app *App) {
|
|||
emailKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, emailKeyPath)
|
||||
cookieAuthKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, cookieAuthKeyPath)
|
||||
cookieKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, cookieKeyPath)
|
||||
csrfKeyPath = filepath.Join(app.cfg.Server.KeysParentDir, csrfKeyPath)
|
||||
}
|
||||
|
||||
// generateKey generates a key at the given path used for the encryption of
|
||||
|
@ -50,7 +51,7 @@ func initKeyPaths(app *App) {
|
|||
func generateKey(path string) error {
|
||||
// Check if key file exists
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
log.Info("%s already exists. rm the file if you understand the consquences.", path)
|
||||
log.Info("%s already exists. rm the file if you understand the consequences.", path)
|
||||
return nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
log.Error("%s", err)
|
||||
|
@ -63,7 +64,7 @@ func generateKey(path string) error {
|
|||
log.Error("FAILED. %s. Run writefreely --gen-keys again.", err)
|
||||
return err
|
||||
}
|
||||
err = ioutil.WriteFile(path, b, 0600)
|
||||
err = os.WriteFile(path, b, 0600)
|
||||
if err != nil {
|
||||
log.Error("FAILED writing file: %s", err)
|
||||
return err
|
||||
|
|
|
@ -5,6 +5,7 @@ all :
|
|||
lessc app.less --clean-css="--s1 --advanced" $(CSSDIR)write.css
|
||||
lessc fonts.less --clean-css="--s1 --advanced" $(CSSDIR)fonts.css
|
||||
lessc icons.less --clean-css="--s1 --advanced" $(CSSDIR)icons.css
|
||||
lessc prose.less --clean-css="--s1 --advanced" $(CSSDIR)prose.css
|
||||
|
||||
install :
|
||||
./install-less.sh
|
||||
|
|
|
@ -13,19 +13,38 @@ nav#admin {
|
|||
display: block;
|
||||
margin: 0.5em 0;
|
||||
a {
|
||||
color: @primary;
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
.rounded(.25em);
|
||||
border: 0;
|
||||
&.selected {
|
||||
background: #dedede;
|
||||
font-weight: bold;
|
||||
.blip {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
}
|
||||
.blip {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
&:not(.pages) {
|
||||
display: block;
|
||||
margin: 0.5em 0;
|
||||
a {
|
||||
margin-left: 0;
|
||||
.rounded(.25em);
|
||||
|
||||
&+a {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: #333;
|
||||
font-family: @sansFont;
|
||||
|
@ -41,4 +60,69 @@ nav#admin {
|
|||
background: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
&.sub {
|
||||
margin: 1em 0 2em;
|
||||
a:not(.toggle) {
|
||||
border: 0;
|
||||
border-bottom: 2px transparent solid;
|
||||
.rounded(0);
|
||||
padding: 0.5em;
|
||||
margin-left: 0.5em;
|
||||
margin-right: 0.5em;
|
||||
|
||||
&:hover {
|
||||
color: @primary;
|
||||
background: transparent;
|
||||
}
|
||||
&.selected {
|
||||
color: @primary;
|
||||
background: transparent;
|
||||
border-bottom-color: @primary;
|
||||
}
|
||||
&+a {
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
a.toggle {
|
||||
margin-top: -0.5em;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
.btn {
|
||||
font-family: @sansFont;
|
||||
font-size: 0.86em;
|
||||
}
|
||||
}
|
||||
|
||||
.features {
|
||||
margin: 1em 0;
|
||||
|
||||
div {
|
||||
&:first-child {
|
||||
font-weight: bold;
|
||||
}
|
||||
&+div {
|
||||
padding-left: 1em;
|
||||
}
|
||||
|
||||
p {
|
||||
font-weight: normal;
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.86em;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
div.row.features {
|
||||
align-items: start;
|
||||
}
|
||||
.features div + div {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
|
@ -5,6 +5,8 @@
|
|||
@import "post-temp";
|
||||
@import "effects";
|
||||
@import "admin";
|
||||
@import "login";
|
||||
@import "pages/error";
|
||||
@import "resources";
|
||||
@import "lib/elements";
|
||||
@import "lib/material";
|
||||
|
|
196
less/core.less
196
less/core.less
|
@ -1,15 +1,3 @@
|
|||
@primary: rgb(114, 120, 191);
|
||||
@secondary: rgb(114, 191, 133);
|
||||
@subheaders: #444;
|
||||
@headerTextColor: black;
|
||||
@sansFont: 'Open Sans', 'Segoe UI', Tahoma, Arial, sans-serif;
|
||||
@serifFont: Lora, 'Palatino Linotype', 'Book Antiqua', 'New York', 'DejaVu serif', serif;
|
||||
@monoFont: Hack, consolas, Menlo-Regular, Menlo, Monaco, 'ubuntu mono', monospace, monospace;
|
||||
@dangerCol: #e21d27;
|
||||
@errUrgentCol: #ecc63c;
|
||||
@proSelectedCol: #71D571;
|
||||
@textLinkColor: rgb(0, 0, 238);
|
||||
|
||||
body {
|
||||
font-family: @serifFont;
|
||||
font-size-adjust: 0.5;
|
||||
|
@ -81,7 +69,7 @@ body {
|
|||
font-size: 1.5em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.17em;
|
||||
font-size: 1.4em;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -222,6 +210,10 @@ body {
|
|||
pre {
|
||||
line-height: 1.5;
|
||||
}
|
||||
.flash {
|
||||
text-align: center;
|
||||
margin-bottom: 4em;
|
||||
}
|
||||
}
|
||||
&#subpage {
|
||||
#wrapper {
|
||||
|
@ -405,6 +397,14 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
img {
|
||||
&.paid {
|
||||
height: 0.86em;
|
||||
vertical-align: middle;
|
||||
margin-bottom: 0.1em;
|
||||
}
|
||||
}
|
||||
|
||||
nav#full-nav {
|
||||
margin: 0;
|
||||
|
||||
|
@ -524,12 +524,12 @@ pre, body#post article, #post .alert, #subpage .alert, body#collection article,
|
|||
margin-bottom: 1em;
|
||||
p {
|
||||
text-align: left;
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
textarea, pre, body#post article, body#collection article p {
|
||||
textarea, input#title, pre, body#post article, body#collection article p {
|
||||
&.norm, &.sans, &.wrap {
|
||||
line-height: 1.4em;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap; /* CSS 3 */
|
||||
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
|
||||
white-space: -pre-wrap; /* Opera 4-6 */
|
||||
|
@ -537,7 +537,7 @@ textarea, pre, body#post article, body#collection article p {
|
|||
word-wrap: break-word; /* Internet Explorer 5.5+ */
|
||||
}
|
||||
}
|
||||
textarea, pre, body#post article, body#collection article, body#subpage article, span, .font {
|
||||
textarea, input#title, pre, body#post article, body#collection article, body#subpage article, span, .font {
|
||||
&.norm {
|
||||
font-family: @serifFont;
|
||||
}
|
||||
|
@ -639,6 +639,23 @@ table.classy {
|
|||
}
|
||||
}
|
||||
|
||||
article table {
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
th {
|
||||
border-width: 1px 1px 2px 1px;
|
||||
border-style: solid;
|
||||
border-color: #ccc;
|
||||
}
|
||||
td {
|
||||
border-width: 0 1px 1px 1px;
|
||||
border-style: solid;
|
||||
border-color: #ccc;
|
||||
padding: .25rem .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
body#collection article, body#subpage article {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
|
@ -655,6 +672,26 @@ body#collection article, body#subpage article {
|
|||
}
|
||||
}
|
||||
}
|
||||
#wrapper.archive {
|
||||
h1 {
|
||||
margin: 0 !important;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
line-height: 1.4;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.year {
|
||||
font-weight: bold;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
body#post article {
|
||||
p.badge {
|
||||
font-size: 0.9em;
|
||||
|
@ -682,6 +719,7 @@ table.downloads {
|
|||
|
||||
select.inputform, textarea.inputform {
|
||||
border: 1px solid #999;
|
||||
background: white;
|
||||
}
|
||||
|
||||
input, button, select.inputform, textarea.inputform, a.btn {
|
||||
|
@ -726,6 +764,31 @@ input, button, select.inputform, textarea.inputform, a.btn {
|
|||
}
|
||||
}
|
||||
|
||||
.btn.pager {
|
||||
border: 1px solid @lightNavBorder;
|
||||
font-size: .86em;
|
||||
padding: .5em 1em;
|
||||
white-space: nowrap;
|
||||
font-family: @sansFont;
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background: @lightNavBorder;
|
||||
}
|
||||
}
|
||||
|
||||
.btn.cta.secondary, input[type=submit].secondary {
|
||||
background: transparent;
|
||||
color: @primary;
|
||||
&:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
.btn.cta.disabled {
|
||||
background-color: desaturate(@primary, 100%) !important;
|
||||
border-color: desaturate(@primary, 100%) !important;
|
||||
}
|
||||
|
||||
div.flat-select {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
|
@ -788,6 +851,9 @@ input {
|
|||
margin: 0 auto 3em;
|
||||
font-size: 1.2em;
|
||||
|
||||
&.toosmall {
|
||||
max-width: 25em;
|
||||
}
|
||||
&.tight {
|
||||
max-width: 30em;
|
||||
}
|
||||
|
@ -810,7 +876,7 @@ input {
|
|||
font-weight: normal;
|
||||
}
|
||||
p {
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
}
|
||||
li {
|
||||
margin: 0.3em 0;
|
||||
|
@ -865,20 +931,6 @@ input {
|
|||
text-align: center;
|
||||
}
|
||||
}
|
||||
div.features {
|
||||
margin-top: 1.5em;
|
||||
text-align: center;
|
||||
font-size: 0.86em;
|
||||
ul {
|
||||
text-align: left;
|
||||
max-width: 26em;
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
li.soon, span.soon {
|
||||
color: lighten(#111, 40%);
|
||||
}
|
||||
}
|
||||
}
|
||||
div.blurbs {
|
||||
>h2 {
|
||||
text-align: center;
|
||||
|
@ -962,7 +1014,12 @@ footer.contain-me {
|
|||
}
|
||||
ul {
|
||||
&.collections {
|
||||
padding-left: 0;
|
||||
margin-left: 0;
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
li {
|
||||
&.collection {
|
||||
a.title {
|
||||
|
@ -1004,7 +1061,7 @@ footer.contain-me {
|
|||
}
|
||||
|
||||
li {
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
|
||||
.item-desc, .prog-lang {
|
||||
font-size: 0.6em;
|
||||
|
@ -1036,6 +1093,19 @@ li {
|
|||
background-color: #dff0d8;
|
||||
border-color: #d6e9c6;
|
||||
}
|
||||
&.danger {
|
||||
border-color: #856404;
|
||||
background-color: white;
|
||||
h3 {
|
||||
margin: 0 0 0.5em 0;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
color: black !important;
|
||||
}
|
||||
h3 + p, button {
|
||||
font-size: 0.86em;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
|
@ -1092,7 +1162,8 @@ body#pad-sub #posts, .atoms {
|
|||
}
|
||||
.electron {
|
||||
font-weight: normal;
|
||||
margin-left: 0.5em;
|
||||
font-size: 0.86em;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
}
|
||||
h3, h4 {
|
||||
|
@ -1242,7 +1313,7 @@ header {
|
|||
}
|
||||
}
|
||||
&.singleuser {
|
||||
margin: 0.5em 0.25em;
|
||||
margin: 0.5em 1em 0.5em 0.25em;
|
||||
nav#user-nav {
|
||||
nav > ul > li:first-child {
|
||||
img {
|
||||
|
@ -1250,6 +1321,9 @@ header {
|
|||
}
|
||||
}
|
||||
}
|
||||
.right-side {
|
||||
padding-top: 0.5em;
|
||||
}
|
||||
}
|
||||
.dash-nav {
|
||||
font-weight: bold;
|
||||
|
@ -1342,6 +1416,16 @@ div.row {
|
|||
}
|
||||
}
|
||||
|
||||
.check, .blip {
|
||||
font-size: 1.125em;
|
||||
color: #71D571;
|
||||
}
|
||||
|
||||
.ex.failure {
|
||||
font-weight: bold;
|
||||
color: @dangerCol;
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px) {
|
||||
body#post {
|
||||
header {
|
||||
|
@ -1408,7 +1492,7 @@ div.row {
|
|||
}
|
||||
|
||||
@media all and (max-width: 600px) {
|
||||
div.row {
|
||||
div.row:not(.admin-actions) {
|
||||
flex-direction: column;
|
||||
}
|
||||
.half {
|
||||
|
@ -1493,6 +1577,11 @@ div.row {
|
|||
margin-left: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
article {
|
||||
.hidden {
|
||||
.opacity(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
|
@ -1534,3 +1623,38 @@ div.row {
|
|||
pre.code-block {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
#emailsub {
|
||||
text-align: center;
|
||||
}
|
||||
p#emailsub {
|
||||
display: inline-block !important;
|
||||
width: 100%;
|
||||
font-style: italic;
|
||||
}
|
||||
#subscribe-btn {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
#org-nav {
|
||||
font-family: @sansFont;
|
||||
font-size: 1.1em;
|
||||
color: #888;
|
||||
|
||||
em, strong {
|
||||
color: #000;
|
||||
}
|
||||
&+h1 {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
a:link, a:visited, a:hover {
|
||||
color: @accent;
|
||||
}
|
||||
a:first-child {
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
a.coll-name {
|
||||
font-weight: bold;
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: optional;
|
||||
src: url('/fonts/open-sans-v13-latin-regular.eot'); /* IE9 Compat Modes */
|
||||
src: local('Open Sans'), local('OpenSans'),
|
||||
url('/fonts/open-sans-v13-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||
|
@ -17,7 +16,6 @@
|
|||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: optional;
|
||||
src: url('/fonts/open-sans-v13-latin-700.eot'); /* IE9 Compat Modes */
|
||||
src: local('Open Sans Bold'), local('OpenSans-Bold'),
|
||||
url('/fonts/open-sans-v13-latin-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||
|
@ -31,7 +29,6 @@
|
|||
font-family: 'Lora';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: optional;
|
||||
src: url('/fonts/Lora-Regular.eot'); /* IE9 Compat Modes */
|
||||
src: local('Lora'), local('Lora-Regular'),
|
||||
url('/fonts/Lora-Regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||
|
@ -44,7 +41,6 @@
|
|||
font-family: 'Lora';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: optional;
|
||||
src: url('/fonts/Lora-Bold.eot'); /* IE9 Compat Modes */
|
||||
src: local('Lora Bold'), local('Lora-Bold'),
|
||||
url('/fonts/Lora-Bold.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||
|
@ -56,7 +52,6 @@
|
|||
font-family: 'Lora';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: optional;
|
||||
src: url('/fonts/Lora-Italic.eot'); /* IE9 Compat Modes */
|
||||
src: local('Lora Italic'), local('Lora-Italic'),
|
||||
url('/fonts/Lora-Italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
# Install Less via npm
|
||||
if [ ! -e "$(which lessc)" ]; then
|
||||
sudo npm install -g less
|
||||
sudo npm install -g less@3.5.3
|
||||
sudo npm install -g less-plugin-clean-css
|
||||
else
|
||||
echo LESS $(npm view less version 2>&1 | grep -v WARN) is installed
|
||||
|
|
91
less/login.less
Normal file
91
less/login.less
Normal file
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* Copyright © 2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
.row.signinbtns {
|
||||
justify-content: center;
|
||||
font-size: 1em;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.loginbtn {
|
||||
height: 40px;
|
||||
margin: 0.5em;
|
||||
|
||||
&.btn {
|
||||
box-sizing: border-box;
|
||||
font-size: 17px;
|
||||
white-space: nowrap;
|
||||
|
||||
img {
|
||||
height: 1.5em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
&#writeas-login, &#slack-login {
|
||||
img {
|
||||
margin-top: -0.2em;
|
||||
}
|
||||
}
|
||||
|
||||
&#gitlab-login {
|
||||
background-color: #fc6d26;
|
||||
border-color: #fc6d26;
|
||||
&:hover {
|
||||
background-color: darken(#fc6d26, 5%);
|
||||
border-color: darken(#fc6d26, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
&#gitea-login {
|
||||
background-color: #2ecc71;
|
||||
border-color: #2ecc71;
|
||||
&:hover {
|
||||
background-color: #2cc26b;
|
||||
border-color: #2cc26b;
|
||||
}
|
||||
}
|
||||
|
||||
&#slack-login, &#gitlab-login, &#gitea-login, &#generic-oauth-login {
|
||||
font-size: 0.86em;
|
||||
font-family: @sansFont;
|
||||
}
|
||||
|
||||
&#slack-login, &#generic-oauth-login {
|
||||
color: @lightTextColor;
|
||||
background-color: @lightNavBG;
|
||||
border-color: @lightNavBorder;
|
||||
&:hover {
|
||||
background-color: @lightNavHoverBG;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.or {
|
||||
text-align: center;
|
||||
margin-bottom: 3.5em;
|
||||
|
||||
p {
|
||||
display: inline-block;
|
||||
background-color: white;
|
||||
padding: 0 1em;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin-top: -1.6em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
hr.short {
|
||||
max-width: 30rem;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
@actionNavColor: #999;
|
||||
@actionNavColor: #767676;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
|
@ -58,7 +58,7 @@ header {
|
|||
}
|
||||
p {
|
||||
&.description {
|
||||
color: #666;
|
||||
color: #444;
|
||||
font-size: 1.1em;
|
||||
margin-top: 0.5em;
|
||||
line-height: 1.5;
|
||||
|
@ -113,7 +113,7 @@ textarea {
|
|||
ul {
|
||||
margin: 0;
|
||||
padding: 0 0 0 1em;
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
|
||||
&.collections, &.posts, &.integrations {
|
||||
list-style: none;
|
||||
|
@ -127,7 +127,6 @@ textarea {
|
|||
&.collection {
|
||||
a.title {
|
||||
font-size: 1.3em;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -206,7 +205,7 @@ code, textarea#embed {
|
|||
font-weight: normal;
|
||||
}
|
||||
p {
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
}
|
||||
li {
|
||||
margin: 0.3em 0;
|
||||
|
|
|
@ -188,18 +188,18 @@ body#pad, body#pad-sub {
|
|||
body#pad {
|
||||
.pad-theme-transition;
|
||||
|
||||
textarea {
|
||||
textarea, #title {
|
||||
.pad-theme-transition;
|
||||
}
|
||||
|
||||
&.dark {
|
||||
textarea {
|
||||
textarea, #title, #editor {
|
||||
background-color: @darkBG;
|
||||
color: @darkTextColor;
|
||||
}
|
||||
}
|
||||
&.light {
|
||||
textarea {
|
||||
textarea, #title, #editor {
|
||||
background-color: @lightBG;
|
||||
color: @lightTextColor;
|
||||
}
|
||||
|
|
|
@ -60,7 +60,7 @@
|
|||
&:hover {
|
||||
background: @lightNavHoverBG;
|
||||
}
|
||||
&:hover > ul {
|
||||
&:hover > ul, &.open > ul {
|
||||
display: block;
|
||||
}
|
||||
&.selected {
|
||||
|
@ -256,7 +256,7 @@ body#pad {
|
|||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
textarea {
|
||||
textarea, #title {
|
||||
position: fixed !important;
|
||||
top: 3em;
|
||||
right: 0;
|
||||
|
@ -340,6 +340,15 @@ body#pad {
|
|||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
line-height: 1.5;
|
||||
|
||||
input[type=text].confirm {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.short {
|
||||
text-align: center;
|
||||
}
|
||||
|
@ -361,12 +370,38 @@ body#pad {
|
|||
z-index: 10;
|
||||
}
|
||||
|
||||
body#pad .alert {
|
||||
position: fixed;
|
||||
bottom: 0.25em;
|
||||
left: 2em;
|
||||
right: 2em;
|
||||
font-size: 1.1em;
|
||||
|
||||
&#edited-elsewhere {
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (max-height: 500px) {
|
||||
body#pad {
|
||||
textarea {
|
||||
top: 2.25em;
|
||||
padding-top: 0.25em;
|
||||
}
|
||||
&.classic {
|
||||
#editor {
|
||||
top: 5.25em;
|
||||
}
|
||||
#title {
|
||||
top: 3.5rem;
|
||||
}
|
||||
}
|
||||
#tools {
|
||||
padding-top: 0.5em;
|
||||
padding-bottom: 0.5em;
|
||||
|
@ -420,43 +455,63 @@ body#pad {
|
|||
}
|
||||
|
||||
@media all and (min-width: 50em) {
|
||||
body#pad {
|
||||
textarea {
|
||||
body#pad, body#pad.classic {
|
||||
textarea, #title {
|
||||
padding-left: 10%;
|
||||
padding-right: 10%;
|
||||
}
|
||||
.alert {
|
||||
left: 10%;
|
||||
right: 10%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media all and (min-width: 60em) {
|
||||
body#pad {
|
||||
textarea {
|
||||
body#pad, body#pad.classic {
|
||||
textarea, #title {
|
||||
padding-left: 15%;
|
||||
padding-right: 15%;
|
||||
}
|
||||
.alert {
|
||||
left: 15%;
|
||||
right: 15%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media all and (min-width: 70em) {
|
||||
body#pad {
|
||||
textarea {
|
||||
body#pad, body#pad.classic {
|
||||
textarea, #title {
|
||||
padding-left: 20%;
|
||||
padding-right: 20%;
|
||||
}
|
||||
.alert {
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media all and (min-width: 85em) {
|
||||
body#pad {
|
||||
textarea {
|
||||
body#pad, body#pad.classic {
|
||||
textarea, #title {
|
||||
padding-left: 25%;
|
||||
padding-right: 25%;
|
||||
}
|
||||
.alert {
|
||||
left: 25%;
|
||||
right: 25%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media all and (min-width: 105em) {
|
||||
body#pad {
|
||||
textarea {
|
||||
body#pad, body#pad.classic {
|
||||
textarea, #title {
|
||||
padding-left: 30%;
|
||||
padding-right: 30%;
|
||||
}
|
||||
.alert {
|
||||
left: 30%;
|
||||
right: 30%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
|
|
|
@ -30,13 +30,32 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
article, pre, .hljs {
|
||||
article, pre, .hljs, #wrapper.archive ul {
|
||||
padding: 0.5em 2rem 1.5em;
|
||||
}
|
||||
body#post article, pre, .hljs {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
p.split {
|
||||
color: #6161FF;
|
||||
font-style: italic;
|
||||
font-size: 0.86em;
|
||||
}
|
||||
|
||||
#readmore-sell {
|
||||
padding: 1em 1em 2em;
|
||||
background-color: #fafafa;
|
||||
p.split {
|
||||
color: black;
|
||||
font-style: normal;
|
||||
font-size: 1.4em;
|
||||
}
|
||||
.cta + .cta {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
/* Post mixins */
|
||||
.article-code() {
|
||||
background-color: #f8f8f8;
|
||||
|
@ -49,7 +68,7 @@ body#post article, pre, .hljs {
|
|||
border-left: 4px solid #ddd;
|
||||
padding: 0 1em;
|
||||
margin: 0.5em;
|
||||
color: #777;
|
||||
color: #767676;
|
||||
display: inline-block;
|
||||
|
||||
p {
|
||||
|
@ -58,7 +77,7 @@ body#post article, pre, .hljs {
|
|||
}
|
||||
}
|
||||
.article-p() {
|
||||
line-height: 1.4em;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap; /* CSS 3 */
|
||||
white-space: -moz-pre-wrap; /* Mozilla, since 1999 */
|
||||
white-space: -pre-wrap; /* Opera 4-6 */
|
||||
|
|
490
less/prose-editor.less
Normal file
490
less/prose-editor.less
Normal file
|
@ -0,0 +1,490 @@
|
|||
@classicHorizMargin: 2rem;
|
||||
|
||||
body#pad.classic {
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
#editor {
|
||||
top: 4em;
|
||||
bottom: 1em;
|
||||
}
|
||||
#title {
|
||||
top: 4.25rem;
|
||||
bottom: unset;
|
||||
height: auto;
|
||||
font-weight: bold;
|
||||
font-size: 2em;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
#tools {
|
||||
#belt {
|
||||
float: none;
|
||||
}
|
||||
}
|
||||
#target {
|
||||
ul {
|
||||
a {
|
||||
padding: 0 0.5em !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#title {
|
||||
margin-left: @classicHorizMargin;
|
||||
margin-right: @classicHorizMargin;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
position: relative;
|
||||
height: calc(~"100% - 1.6em");
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
font-size: 1.2em;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
-webkit-font-variant-ligatures: none;
|
||||
font-variant-ligatures: none;
|
||||
padding: 0.5em @classicHorizMargin;
|
||||
line-height: 1.5;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ProseMirror pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ProseMirror li {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection *::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection *::-moz-selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection {
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-selectednode {
|
||||
outline: 2px solid #8cf;
|
||||
}
|
||||
|
||||
/* Make sure li selections wrap around markers */
|
||||
|
||||
li.ProseMirror-selectednode {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
li.ProseMirror-selectednode:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -32px;
|
||||
right: -2px;
|
||||
top: -2px;
|
||||
bottom: -2px;
|
||||
border: 2px solid #8cf;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ProseMirror-textblock-dropdown {
|
||||
min-width: 3em;
|
||||
}
|
||||
|
||||
.ProseMirror-menu {
|
||||
margin: 0 -4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ProseMirror-tooltip .ProseMirror-menu {
|
||||
width: -webkit-fit-content;
|
||||
width: fit-content;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
margin-right: 3px;
|
||||
display: inline-block;
|
||||
div {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menuseparator {
|
||||
border-right: 1px solid #ddd;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown, .ProseMirror-menu-dropdown-menu {
|
||||
font-size: 90%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown {
|
||||
vertical-align: 1px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-wrap {
|
||||
padding: 1px 0 1px 4px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown:after {
|
||||
content: "";
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 4px solid currentColor;
|
||||
opacity: .6;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: calc(50% - 2px);
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-menu, .ProseMirror-menu-submenu {
|
||||
position: absolute;
|
||||
background: white;
|
||||
color: #666;
|
||||
border: 1px solid #aaa;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-menu {
|
||||
z-index: 15;
|
||||
min-width: 6em;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-item {
|
||||
cursor: pointer;
|
||||
padding: 2px 8px 2px 4px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-dropdown-item:hover {
|
||||
background: #f2f2f2;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-submenu-wrap {
|
||||
position: relative;
|
||||
margin-right: -4px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-submenu-label:after {
|
||||
content: "";
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 4px solid currentColor;
|
||||
opacity: .6;
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
top: calc(50% - 4px);
|
||||
}
|
||||
|
||||
.ProseMirror-menu-submenu {
|
||||
display: none;
|
||||
min-width: 4em;
|
||||
left: 100%;
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
background: #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
background: #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-disabled {
|
||||
opacity: .3;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu, .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
font-family: @sansFont;
|
||||
position: relative;
|
||||
min-height: 1em;
|
||||
color: #666;
|
||||
padding: 0.5em;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
z-index: 10;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
overflow: visible;
|
||||
margin-left: @classicHorizMargin;
|
||||
margin-right: @classicHorizMargin;
|
||||
}
|
||||
|
||||
.ProseMirror-icon {
|
||||
display: inline-block;
|
||||
line-height: .8;
|
||||
vertical-align: -2px; /* Compensate for padding */
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-disabled.ProseMirror-icon {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ProseMirror-icon svg {
|
||||
fill: currentColor;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.ProseMirror-icon span {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 20px;
|
||||
border-top: 1px solid black;
|
||||
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
|
||||
}
|
||||
|
||||
@keyframes ProseMirror-cursor-blink {
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-focused .ProseMirror-gapcursor {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Add space around the hr to make clicking it easier */
|
||||
|
||||
.ProseMirror-example-setup-style hr {
|
||||
padding: 4px 10px;
|
||||
border: none;
|
||||
margin: 1em 0;
|
||||
background: initial;
|
||||
}
|
||||
|
||||
.ProseMirror-example-setup-style hr:after {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 1px;
|
||||
background-color: #ccc;
|
||||
line-height: 2px;
|
||||
}
|
||||
|
||||
.ProseMirror ul, .ProseMirror ol {
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.ProseMirror blockquote {
|
||||
padding-left: 1em;
|
||||
border-left: 4px solid #ddd;
|
||||
color: #767676;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.ProseMirror-example-setup-style img {
|
||||
cursor: default;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt {
|
||||
background: white;
|
||||
padding: 1em;
|
||||
border: 1px solid silver;
|
||||
position: fixed;
|
||||
border-radius: 0.25em;
|
||||
z-index: 11;
|
||||
box-shadow: -.5px 2px 5px rgba(0, 0, 0, .2);
|
||||
}
|
||||
|
||||
.ProseMirror-prompt h5 {
|
||||
margin: 0 0 0.75em;
|
||||
font-family: @sansFont;
|
||||
font-size: 100%;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt input[type="text"],
|
||||
.ProseMirror-prompt textarea {
|
||||
background: #eee;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt input[type="text"] {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-close {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 1px;
|
||||
color: #666;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-close:after {
|
||||
content: "✕";
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ProseMirror-invalid {
|
||||
background: #ffc;
|
||||
border: 1px solid #cc7;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
position: absolute;
|
||||
min-width: 10em;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-buttons {
|
||||
margin-top: 5px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#editor, .editor {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
color: black;
|
||||
background-clip: padding-box;
|
||||
padding: 5px 0;
|
||||
margin: 4em auto 23px auto;
|
||||
}
|
||||
|
||||
.dark #editor {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ProseMirror p:first-child,
|
||||
.ProseMirror h1:first-child,
|
||||
.ProseMirror h2:first-child,
|
||||
.ProseMirror h3:first-child,
|
||||
.ProseMirror h4:first-child,
|
||||
.ProseMirror h5:first-child,
|
||||
.ProseMirror h6:first-child {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ProseMirror p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 123px;
|
||||
border: 1px solid silver;
|
||||
box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
padding: 3px 10px;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar-wrapper {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar-wrapper, #markdown textarea {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.editorreadmore {
|
||||
color: @textLinkColor;
|
||||
text-decoration: underline;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media all and (min-width: 50em) {
|
||||
#photo-upload label {
|
||||
display: inline;
|
||||
}
|
||||
.ProseMirror-menubar, #title, #photo-upload {
|
||||
margin-left: 10%;
|
||||
margin-right: 10%;
|
||||
}
|
||||
.ProseMirror {
|
||||
padding-left: 10%;
|
||||
padding-right: 10%;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (min-width: 60em) {
|
||||
.ProseMirror-menubar, #title, #photo-upload {
|
||||
margin-left: 15%;
|
||||
margin-right: 15%;
|
||||
}
|
||||
.ProseMirror {
|
||||
padding-left: 15%;
|
||||
padding-right: 15%;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (min-width: 70em) {
|
||||
.ProseMirror-menubar, #title, #photo-upload {
|
||||
margin-left: 20%;
|
||||
margin-right: 20%;
|
||||
}
|
||||
.ProseMirror {
|
||||
padding-left: 20%;
|
||||
padding-right: 20%;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (min-width: 85em) {
|
||||
.ProseMirror-menubar, #title, #photo-upload {
|
||||
margin-left: 25%;
|
||||
margin-right: 25%;
|
||||
}
|
||||
.ProseMirror {
|
||||
padding-left: 25%;
|
||||
padding-right: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (min-width: 105em) {
|
||||
.ProseMirror-menubar, #title, #photo-upload {
|
||||
margin-left: 30%;
|
||||
margin-right: 30%;
|
||||
}
|
||||
.ProseMirror {
|
||||
padding-left: 30%;
|
||||
padding-right: 30%;
|
||||
}
|
||||
}
|
4
less/prose.less
Normal file
4
less/prose.less
Normal file
|
@ -0,0 +1,4 @@
|
|||
@import "prose-editor";
|
||||
@import "pad-theme";
|
||||
@import "resources";
|
||||
@import "lib/elements";
|
13
less/resources.less
Normal file
13
less/resources.less
Normal file
|
@ -0,0 +1,13 @@
|
|||
@primary: rgb(114, 120, 191);
|
||||
@secondary: rgb(114, 191, 133);
|
||||
@subheaders: #444;
|
||||
@headerTextColor: black;
|
||||
@sansFont: 'Open Sans', 'Segoe UI', Tahoma, Arial, sans-serif;
|
||||
@serifFont: Lora, 'Palatino Linotype', 'Book Antiqua', 'New York', 'DejaVu serif', serif;
|
||||
@monoFont: Hack, consolas, Menlo-Regular, Menlo, Monaco, 'ubuntu mono', monospace, monospace;
|
||||
@dangerCol: #e21d27;
|
||||
@errUrgentCol: #ecc63c;
|
||||
@proSelectedCol: #71D571;
|
||||
@textLinkColor: rgb(0, 0, 238);
|
||||
|
||||
@accent: #767676;
|
181
mailer/mailer.go
Normal file
181
mailer/mailer.go
Normal file
|
@ -0,0 +1,181 @@
|
|||
/*
|
||||
* Copyright © 2024 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/mailgun/mailgun-go"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
mail "github.com/xhit/go-simple-mail/v2"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
// Mailer holds configurations for the preferred mailing provider.
|
||||
Mailer struct {
|
||||
smtp *mail.SMTPServer
|
||||
mailGun *mailgun.MailgunImpl
|
||||
}
|
||||
|
||||
// Message holds the email contents and metadata for the preferred mailing provider.
|
||||
Message struct {
|
||||
mgMsg *mailgun.Message
|
||||
smtpMsg *SmtpMessage
|
||||
}
|
||||
|
||||
SmtpMessage struct {
|
||||
from string
|
||||
replyTo string
|
||||
subject string
|
||||
recipients []Recipient
|
||||
html string
|
||||
text string
|
||||
}
|
||||
|
||||
Recipient struct {
|
||||
email string
|
||||
vars map[string]string
|
||||
}
|
||||
)
|
||||
|
||||
// New creates a new Mailer from the instance's config.EmailCfg, returning an error if not properly configured.
|
||||
func New(eCfg config.EmailCfg) (*Mailer, error) {
|
||||
m := &Mailer{}
|
||||
if eCfg.Domain != "" && eCfg.MailgunPrivate != "" {
|
||||
m.mailGun = mailgun.NewMailgun(eCfg.Domain, eCfg.MailgunPrivate)
|
||||
if eCfg.MailgunEurope {
|
||||
m.mailGun.SetAPIBase("https://api.eu.mailgun.net/v3")
|
||||
}
|
||||
} else if eCfg.Username != "" && eCfg.Password != "" && eCfg.Host != "" && eCfg.Port > 0 {
|
||||
m.smtp = mail.NewSMTPClient()
|
||||
m.smtp.Host = eCfg.Host
|
||||
m.smtp.Port = eCfg.Port
|
||||
m.smtp.Username = eCfg.Username
|
||||
m.smtp.Password = eCfg.Password
|
||||
if eCfg.EnableStartTLS {
|
||||
m.smtp.Encryption = mail.EncryptionSTARTTLS
|
||||
}
|
||||
// To allow sending multiple email
|
||||
m.smtp.KeepAlive = true
|
||||
} else {
|
||||
return nil, fmt.Errorf("no email provider is configured")
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// NewMessage creates a new Message from the given parameters.
|
||||
func (m *Mailer) NewMessage(from, subject, text string, to ...string) (*Message, error) {
|
||||
msg := &Message{}
|
||||
if m.mailGun != nil {
|
||||
msg.mgMsg = m.mailGun.NewMessage(from, subject, text, to...)
|
||||
} else if m.smtp != nil {
|
||||
msg.smtpMsg = &SmtpMessage{
|
||||
from: from,
|
||||
replyTo: "",
|
||||
subject: subject,
|
||||
recipients: make([]Recipient, len(to)),
|
||||
html: "",
|
||||
text: text,
|
||||
}
|
||||
for _, r := range to {
|
||||
msg.smtpMsg.recipients = append(msg.smtpMsg.recipients, Recipient{r, make(map[string]string)})
|
||||
}
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// SetHTML sets the body of the message.
|
||||
func (m *Message) SetHTML(html string) {
|
||||
if m.smtpMsg != nil {
|
||||
m.smtpMsg.html = html
|
||||
} else if m.mgMsg != nil {
|
||||
m.mgMsg.SetHtml(html)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Message) SetReplyTo(replyTo string) {
|
||||
if m.smtpMsg != nil {
|
||||
m.smtpMsg.replyTo = replyTo
|
||||
} else {
|
||||
m.mgMsg.SetReplyTo(replyTo)
|
||||
}
|
||||
}
|
||||
|
||||
// AddTag attaches a tag to the Message for providers that support it.
|
||||
func (m *Message) AddTag(tag string) {
|
||||
if m.mgMsg != nil {
|
||||
m.mgMsg.AddTag(tag)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Message) AddRecipientAndVariables(r string, vars map[string]string) error {
|
||||
if m.smtpMsg != nil {
|
||||
m.smtpMsg.recipients = append(m.smtpMsg.recipients, Recipient{r, vars})
|
||||
return nil
|
||||
} else {
|
||||
varsInterfaces := make(map[string]interface{}, len(vars))
|
||||
for k, v := range vars {
|
||||
varsInterfaces[k] = v
|
||||
}
|
||||
return m.mgMsg.AddRecipientAndVariables(r, varsInterfaces)
|
||||
}
|
||||
}
|
||||
|
||||
// Send sends the given message via the preferred provider.
|
||||
func (m *Mailer) Send(msg *Message) error {
|
||||
if m.smtp != nil {
|
||||
client, err := m.smtp.Connect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
emailSent := false
|
||||
for _, r := range msg.smtpMsg.recipients {
|
||||
customMsg := mail.NewMSG()
|
||||
customMsg.SetFrom(msg.smtpMsg.from)
|
||||
if msg.smtpMsg.replyTo != "" {
|
||||
customMsg.SetReplyTo(msg.smtpMsg.replyTo)
|
||||
}
|
||||
customMsg.SetSubject(msg.smtpMsg.subject)
|
||||
customMsg.AddTo(r.email)
|
||||
cText := msg.smtpMsg.text
|
||||
cHtml := msg.smtpMsg.html
|
||||
for v, value := range r.vars {
|
||||
placeHolder := fmt.Sprintf("%%recipient.%s%%", v)
|
||||
cText = strings.ReplaceAll(cText, placeHolder, value)
|
||||
cHtml = strings.ReplaceAll(cHtml, placeHolder, value)
|
||||
}
|
||||
customMsg.SetBody(mail.TextHTML, cHtml)
|
||||
customMsg.AddAlternative(mail.TextPlain, cText)
|
||||
e := customMsg.Error
|
||||
if e == nil {
|
||||
e = customMsg.Send(client)
|
||||
}
|
||||
if e == nil {
|
||||
emailSent = true
|
||||
} else {
|
||||
log.Error("Unable to send email to %s: %v", r.email, e)
|
||||
err = e
|
||||
}
|
||||
}
|
||||
if !emailSent {
|
||||
// only send an error if no email could be sent (to avoid retry of successfully sent emails)
|
||||
return err
|
||||
}
|
||||
} else if m.mailGun != nil {
|
||||
_, _, err := m.mailGun.Send(msg.mgMsg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -36,6 +36,13 @@ func (db *datastore) typeSmallInt() string {
|
|||
return "SMALLINT"
|
||||
}
|
||||
|
||||
func (db *datastore) typeTinyInt() string {
|
||||
if db.driverName == driverSQLite {
|
||||
return "INTEGER"
|
||||
}
|
||||
return "TINYINT"
|
||||
}
|
||||
|
||||
func (db *datastore) typeText() string {
|
||||
return "TEXT"
|
||||
}
|
||||
|
@ -54,6 +61,13 @@ func (db *datastore) typeVarChar(l int) string {
|
|||
return fmt.Sprintf("VARCHAR(%d)", l)
|
||||
}
|
||||
|
||||
func (db *datastore) typeVarBinary(l int) string {
|
||||
if db.driverName == driverSQLite {
|
||||
return "BLOB"
|
||||
}
|
||||
return fmt.Sprintf("VARBINARY(%d)", l)
|
||||
}
|
||||
|
||||
func (db *datastore) typeBool() string {
|
||||
if db.driverName == driverSQLite {
|
||||
return "INTEGER"
|
||||
|
@ -65,6 +79,15 @@ func (db *datastore) typeDateTime() string {
|
|||
return "DATETIME"
|
||||
}
|
||||
|
||||
func (db *datastore) typeIntPrimaryKey() string {
|
||||
if db.driverName == driverSQLite {
|
||||
// From docs: "In SQLite, a column with type INTEGER PRIMARY KEY is an alias for the ROWID (except in WITHOUT
|
||||
// ROWID tables) which is always a 64-bit signed integer."
|
||||
return "INTEGER PRIMARY KEY"
|
||||
}
|
||||
return "INT AUTO_INCREMENT PRIMARY KEY"
|
||||
}
|
||||
|
||||
func (db *datastore) collateMultiByte() string {
|
||||
if db.driverName == driverSQLite {
|
||||
return ""
|
||||
|
@ -78,3 +101,10 @@ func (db *datastore) engine() string {
|
|||
}
|
||||
return " ENGINE = InnoDB"
|
||||
}
|
||||
|
||||
func (db *datastore) after(colName string) string {
|
||||
if db.driverName == driverSQLite {
|
||||
return ""
|
||||
}
|
||||
return " AFTER " + colName
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -61,7 +61,17 @@ var migrations = []Migration{
|
|||
New("support users suspension", supportUserStatus), // V2 -> V3 (v0.11.0)
|
||||
New("support oauth", oauth), // V3 -> V4
|
||||
New("support slack oauth", oauthSlack), // V4 -> v5
|
||||
New("support ActivityPub mentions", supportActivityPubMentions), // V5 -> V6 (v0.12.0)
|
||||
New("support ActivityPub mentions", supportActivityPubMentions), // V5 -> V6
|
||||
New("support oauth attach", oauthAttach), // V6 -> V7
|
||||
New("support oauth via invite", oauthInvites), // V7 -> V8 (v0.12.0)
|
||||
New("optimize drafts retrieval", optimizeDrafts), // V8 -> V9
|
||||
New("support post signatures", supportPostSignatures), // V9 -> V10 (v0.13.0)
|
||||
New("Widen oauth_users.access_token", widenOauthAcceesToken), // V10 -> V11
|
||||
New("support verifying fedi profile", fediverseVerifyProfile), // V11 -> V12 (v0.14.0)
|
||||
New("support newsletters", supportLetters), // V12 -> V13
|
||||
New("support password resetting", supportPassReset), // V13 -> V14
|
||||
New("speed up blog post retrieval", addPostRetrievalIndex), // V14 -> V15
|
||||
New("support ActivityPub likes", supportRemoteLikes), // V15 -> V16 (v0.16.0)
|
||||
}
|
||||
|
||||
// CurrentVer returns the current migration version the application is on
|
||||
|
@ -83,6 +93,9 @@ func Migrate(db *datastore) error {
|
|||
var err error
|
||||
if db.tableExists("appmigrations") {
|
||||
err = db.QueryRow("SELECT MAX(version) FROM appmigrations").Scan(&version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
log.Info("Initializing appmigrations table...")
|
||||
version = 0
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -12,6 +12,9 @@ package migrations
|
|||
|
||||
func supportUserInvites(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = t.Exec(`CREATE TABLE userinvites (
|
||||
id ` + db.typeChar(6) + ` NOT NULL ,
|
||||
owner_id ` + db.typeInt() + ` NOT NULL ,
|
||||
|
|
33
migrations/v10.go
Normal file
33
migrations/v10.go
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright © 2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
func supportPostSignatures(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`ALTER TABLE collections ADD COLUMN post_signature ` + db.typeText() + db.collateMultiByte() + ` NULL` + db.after("script"))
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
38
migrations/v11.go
Normal file
38
migrations/v11.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright © 2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
/**
|
||||
* Widen `oauth_users.access_token`, necessary only for mysql
|
||||
*/
|
||||
func widenOauthAcceesToken(db *datastore) error {
|
||||
if db.driverName == driverMySQL {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`ALTER TABLE oauth_users MODIFY COLUMN access_token ` + db.typeText() + db.collateMultiByte() + ` NULL`)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
33
migrations/v12.go
Normal file
33
migrations/v12.go
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright © 2023 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
func fediverseVerifyProfile(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN url ` + db.typeVarChar(255) + ` NULL` + db.after("shared_inbox"))
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
58
migrations/v13.go
Normal file
58
migrations/v13.go
Normal file
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* Copyright © 2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
func supportLetters(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`CREATE TABLE publishjobs (
|
||||
id ` + db.typeIntPrimaryKey() + `,
|
||||
post_id ` + db.typeVarChar(16) + ` not null,
|
||||
action ` + db.typeVarChar(16) + ` not null,
|
||||
delay ` + db.typeTinyInt() + ` not null
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`CREATE TABLE emailsubscribers (
|
||||
id ` + db.typeChar(8) + ` not null,
|
||||
collection_id ` + db.typeInt() + ` not null,
|
||||
user_id ` + db.typeInt() + ` null,
|
||||
email ` + db.typeVarChar(255) + ` null,
|
||||
subscribed ` + db.typeDateTime() + ` not null,
|
||||
token ` + db.typeChar(16) + ` not null,
|
||||
confirmed ` + db.typeBool() + ` default 0 not null,
|
||||
allow_export ` + db.typeBool() + ` default 0 not null,
|
||||
constraint eu_coll_email
|
||||
unique (collection_id, email),
|
||||
constraint eu_coll_user
|
||||
unique (collection_id, user_id),
|
||||
PRIMARY KEY (id)
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
37
migrations/v14.go
Normal file
37
migrations/v14.go
Normal file
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright © 2023 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
func supportPassReset(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`CREATE TABLE password_resets (
|
||||
user_id ` + db.typeInt() + ` not null,
|
||||
token ` + db.typeChar(32) + ` not null primary key,
|
||||
used ` + db.typeBool() + ` default 0 not null,
|
||||
created ` + db.typeDateTime() + ` not null
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
33
migrations/v15.go
Normal file
33
migrations/v15.go
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright © 2023 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
func addPostRetrievalIndex(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec("CREATE INDEX posts_get_collection_index ON posts (`collection_id`, `pinned_position`, `created`)")
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
38
migrations/v16.go
Normal file
38
migrations/v16.go
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright © 2024 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
func supportRemoteLikes(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`CREATE TABLE remote_likes (
|
||||
post_id ` + db.typeChar(16) + ` NOT NULL,
|
||||
remote_user_id ` + db.typeInt() + ` NOT NULL,
|
||||
created ` + db.typeDateTime() + ` NOT NULL,
|
||||
PRIMARY KEY (post_id,remote_user_id)
|
||||
)`)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -12,6 +12,9 @@ package migrations
|
|||
|
||||
func supportInstancePages(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`ALTER TABLE appcontent ADD COLUMN title ` + db.typeVarChar(255) + db.collateMultiByte() + ` NULL`)
|
||||
if err != nil {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -12,6 +12,9 @@ package migrations
|
|||
|
||||
func supportUserStatus(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`ALTER TABLE users ADD COLUMN status ` + db.typeInt() + ` DEFAULT '0' NOT NULL`)
|
||||
if err != nil {
|
||||
|
|
|
@ -1,10 +1,20 @@
|
|||
/*
|
||||
* Copyright © 2019-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
wf_db "github.com/writeas/writefreely/db"
|
||||
wf_db "github.com/writefreely/writefreely/db"
|
||||
)
|
||||
|
||||
func oauth(db *datastore) error {
|
||||
|
@ -15,21 +25,19 @@ func oauth(db *datastore) error {
|
|||
return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error {
|
||||
createTableUsersOauth, err := dialect.
|
||||
Table("oauth_users").
|
||||
SetIfNotExists(true).
|
||||
SetIfNotExists(false).
|
||||
Column(dialect.Column("user_id", wf_db.ColumnTypeInteger, wf_db.UnsetSize)).
|
||||
Column(dialect.Column("remote_user_id", wf_db.ColumnTypeInteger, wf_db.UnsetSize)).
|
||||
UniqueConstraint("user_id").
|
||||
UniqueConstraint("remote_user_id").
|
||||
ToSQL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
createTableOauthClientState, err := dialect.
|
||||
Table("oauth_client_states").
|
||||
SetIfNotExists(true).
|
||||
SetIfNotExists(false).
|
||||
Column(dialect.Column("state", wf_db.ColumnTypeVarChar, wf_db.OptionalInt{Set: true, Value: 255})).
|
||||
Column(dialect.Column("used", wf_db.ColumnTypeBool, wf_db.UnsetSize)).
|
||||
Column(dialect.Column("created_at", wf_db.ColumnTypeDateTime, wf_db.UnsetSize).SetDefault("NOW()")).
|
||||
Column(dialect.Column("created_at", wf_db.ColumnTypeDateTime, wf_db.UnsetSize).SetDefaultCurrentTimestamp()).
|
||||
UniqueConstraint("state").
|
||||
ToSQL()
|
||||
if err != nil {
|
||||
|
|
|
@ -1,10 +1,20 @@
|
|||
/*
|
||||
* Copyright © 2019-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
wf_db "github.com/writeas/writefreely/db"
|
||||
wf_db "github.com/writefreely/writefreely/db"
|
||||
)
|
||||
|
||||
func oauthSlack(db *datastore) error {
|
||||
|
@ -20,39 +30,50 @@ func oauthSlack(db *datastore) error {
|
|||
Column(
|
||||
"provider",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 24,})).
|
||||
wf_db.OptionalInt{Set: true, Value: 24}).SetDefault("")),
|
||||
dialect.
|
||||
AlterTable("oauth_client_states").
|
||||
AddColumn(dialect.
|
||||
Column(
|
||||
"client_id",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 128,})),
|
||||
wf_db.OptionalInt{Set: true, Value: 128}).SetDefault("")),
|
||||
dialect.
|
||||
AlterTable("oauth_users").
|
||||
AddColumn(dialect.
|
||||
Column(
|
||||
"provider",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 24}).SetDefault("")),
|
||||
dialect.
|
||||
AlterTable("oauth_users").
|
||||
AddColumn(dialect.
|
||||
Column(
|
||||
"client_id",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 128}).SetDefault("")),
|
||||
dialect.
|
||||
AlterTable("oauth_users").
|
||||
AddColumn(dialect.
|
||||
Column(
|
||||
"access_token",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 512}).SetDefault("")),
|
||||
dialect.CreateUniqueIndex("oauth_users_uk", "oauth_users", "user_id", "provider", "client_id"),
|
||||
}
|
||||
|
||||
if dialect != wf_db.DialectSQLite {
|
||||
// This updates the length of the `remote_user_id` column. It isn't needed for SQLite databases.
|
||||
builders = append(builders, dialect.
|
||||
AlterTable("oauth_users").
|
||||
ChangeColumn("remote_user_id",
|
||||
dialect.
|
||||
Column(
|
||||
"remote_user_id",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 128,})).
|
||||
AddColumn(dialect.
|
||||
Column(
|
||||
"provider",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 24,})).
|
||||
AddColumn(dialect.
|
||||
Column(
|
||||
"client_id",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 128,})).
|
||||
AddColumn(dialect.
|
||||
Column(
|
||||
"access_token",
|
||||
wf_db.ColumnTypeVarChar,
|
||||
wf_db.OptionalInt{Set: true, Value: 512,})),
|
||||
dialect.DropIndex("remote_user_id", "oauth_users"),
|
||||
dialect.DropIndex("user_id", "oauth_users"),
|
||||
dialect.CreateUniqueIndex("oauth_users", "oauth_users", "user_id", "provider", "client_id"),
|
||||
wf_db.OptionalInt{Set: true, Value: 128})))
|
||||
}
|
||||
|
||||
for _, builder := range builders {
|
||||
query, err := builder.ToSQL()
|
||||
if err != nil {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2019 A Bunch Tell LLC.
|
||||
* Copyright © 2019-2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -12,8 +12,11 @@ package migrations
|
|||
|
||||
func supportActivityPubMentions(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN handle ` + db.typeVarChar(255) + ` DEFAULT '' NOT NULL`)
|
||||
_, err = t.Exec(`ALTER TABLE remoteusers ADD COLUMN handle ` + db.typeVarChar(255) + ` NULL`)
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
|
|
46
migrations/v7.go
Normal file
46
migrations/v7.go
Normal file
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Copyright © 2020-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
wf_db "github.com/writefreely/writefreely/db"
|
||||
)
|
||||
|
||||
func oauthAttach(db *datastore) error {
|
||||
dialect := wf_db.DialectMySQL
|
||||
if db.driverName == driverSQLite {
|
||||
dialect = wf_db.DialectSQLite
|
||||
}
|
||||
return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error {
|
||||
builders := []wf_db.SQLBuilder{
|
||||
dialect.
|
||||
AlterTable("oauth_client_states").
|
||||
AddColumn(dialect.
|
||||
Column(
|
||||
"attach_user_id",
|
||||
wf_db.ColumnTypeInteger,
|
||||
wf_db.OptionalInt{Set: true, Value: 24}).SetNullable(true)),
|
||||
}
|
||||
for _, builder := range builders {
|
||||
query, err := builder.ToSQL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, query); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
45
migrations/v8.go
Normal file
45
migrations/v8.go
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright © 2020-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
wf_db "github.com/writefreely/writefreely/db"
|
||||
)
|
||||
|
||||
func oauthInvites(db *datastore) error {
|
||||
dialect := wf_db.DialectMySQL
|
||||
if db.driverName == driverSQLite {
|
||||
dialect = wf_db.DialectSQLite
|
||||
}
|
||||
return wf_db.RunTransactionWithOptions(context.Background(), db.DB, &sql.TxOptions{}, func(ctx context.Context, tx *sql.Tx) error {
|
||||
builders := []wf_db.SQLBuilder{
|
||||
dialect.
|
||||
AlterTable("oauth_client_states").
|
||||
AddColumn(dialect.Column("invite_code", wf_db.ColumnTypeChar, wf_db.OptionalInt{
|
||||
Set: true,
|
||||
Value: 6,
|
||||
}).SetNullable(true)),
|
||||
}
|
||||
for _, builder := range builders {
|
||||
query, err := builder.ToSQL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, query); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
37
migrations/v9.go
Normal file
37
migrations/v9.go
Normal file
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright © 2020 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
func optimizeDrafts(db *datastore) error {
|
||||
t, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if db.driverName == driverSQLite {
|
||||
_, err = t.Exec(`CREATE INDEX key_owner_post_id ON posts (owner_id, id)`)
|
||||
} else {
|
||||
_, err = t.Exec(`ALTER TABLE posts ADD INDEX(owner_id, id)`)
|
||||
}
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
err = t.Commit()
|
||||
if err != nil {
|
||||
t.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
160
monetization.go
Normal file
160
monetization.go
Normal file
|
@ -0,0 +1,160 @@
|
|||
/*
|
||||
* Copyright © 2020-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/log"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func displayMonetization(monetization, alias string) string {
|
||||
if monetization == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
ptrURL, err := url.Parse(strings.Replace(monetization, "$", "https://", 1))
|
||||
if err == nil {
|
||||
if strings.HasSuffix(ptrURL.Host, ".xrptipbot.com") {
|
||||
// xrp tip bot doesn't support stream receipts, so return plain pointer
|
||||
return monetization
|
||||
}
|
||||
}
|
||||
|
||||
u := os.Getenv("PAYMENT_HOST")
|
||||
if u == "" {
|
||||
return "$webmonetization.org/api/receipts/" + url.PathEscape(monetization)
|
||||
}
|
||||
u += "/" + alias
|
||||
return u
|
||||
}
|
||||
|
||||
func handleSPSPEndpoint(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
idStr := r.FormValue("id")
|
||||
id, err := url.QueryUnescape(idStr)
|
||||
if err != nil {
|
||||
log.Error("Unable to unescape: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
var c *Collection
|
||||
if strings.IndexRune(id, '.') > 0 && app.cfg.App.SingleUser {
|
||||
c, err = app.db.GetCollectionByID(1)
|
||||
} else {
|
||||
c, err = app.db.GetCollection(id)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pointer := c.Monetization
|
||||
if pointer == "" {
|
||||
err := impart.HTTPError{http.StatusNotFound, "No monetization pointer."}
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, pointer)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleGetSplitContent(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
var collID int64
|
||||
var collLookupID string
|
||||
var coll *Collection
|
||||
var err error
|
||||
vars := mux.Vars(r)
|
||||
if collAlias := vars["alias"]; collAlias != "" {
|
||||
// Fetch collection information, since an alias is provided
|
||||
coll, err = app.db.GetCollection(collAlias)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
collID = coll.ID
|
||||
collLookupID = coll.Alias
|
||||
}
|
||||
|
||||
p, err := app.db.GetPost(vars["post"], collID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
receipt := r.FormValue("receipt")
|
||||
if receipt == "" {
|
||||
return impart.HTTPError{http.StatusBadRequest, "No `receipt` given."}
|
||||
}
|
||||
err = verifyReceipt(receipt, collLookupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
d := struct {
|
||||
Content string `json:"body"`
|
||||
HTMLContent string `json:"html_body"`
|
||||
}{}
|
||||
|
||||
if exc := strings.Index(p.Content, shortCodePaid); exc > -1 {
|
||||
baseURL := ""
|
||||
if coll != nil {
|
||||
baseURL = coll.CanonicalURL()
|
||||
}
|
||||
|
||||
d.Content = p.Content[exc+len(shortCodePaid):]
|
||||
d.HTMLContent = applyMarkdown([]byte(d.Content), baseURL, app.cfg)
|
||||
}
|
||||
|
||||
return impart.WriteSuccess(w, d, http.StatusOK)
|
||||
}
|
||||
|
||||
func verifyReceipt(receipt, id string) error {
|
||||
receiptsHost := os.Getenv("RECEIPTS_HOST")
|
||||
if receiptsHost == "" {
|
||||
receiptsHost = "https://webmonetization.org/api/receipts/verify?id=" + id
|
||||
} else {
|
||||
receiptsHost = fmt.Sprintf("%s/receipts?id=%s", receiptsHost, id)
|
||||
}
|
||||
|
||||
log.Info("Verifying receipt %s at %s", receipt, receiptsHost)
|
||||
r, err := http.NewRequest("POST", receiptsHost, bytes.NewBufferString(receipt))
|
||||
if err != nil {
|
||||
log.Error("Unable to create new request to %s: %s", receiptsHost, err)
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(r)
|
||||
if err != nil {
|
||||
log.Error("Unable to Do() request to %s: %s", receiptsHost, err)
|
||||
return err
|
||||
}
|
||||
if resp != nil && resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Error("Unable to read %s response body: %s", receiptsHost, err)
|
||||
return err
|
||||
}
|
||||
log.Info("Status : %s", resp.Status)
|
||||
log.Info("Response: %s", body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
log.Error("Bad response from %s:\nStatus: %d\n%s", receiptsHost, resp.StatusCode, string(body))
|
||||
return impart.HTTPError{resp.StatusCode, string(body)}
|
||||
}
|
||||
return nil
|
||||
}
|
16
nodeinfo.go
16
nodeinfo.go
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright © 2018 A Bunch Tell LLC.
|
||||
* Copyright © 2018-2019, 2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
|
@ -12,8 +12,8 @@ package writefreely
|
|||
|
||||
import (
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"github.com/writefreely/go-nodeinfo"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
@ -45,8 +45,8 @@ func nodeInfoConfig(db *datastore, cfg *config.Config) *nodeinfo.Config {
|
|||
Private: cfg.App.Private,
|
||||
Software: nodeinfo.SoftwareMeta{
|
||||
HomePage: softwareURL,
|
||||
GitHub: "https://github.com/writeas/writefreely",
|
||||
Follow: "https://writing.exchange/@write_as",
|
||||
GitHub: "https://github.com/writefreely/writefreely",
|
||||
Follow: "https://writing.exchange/@writefreely",
|
||||
},
|
||||
MaxBlogs: cfg.App.MaxBlogs,
|
||||
PublicReader: cfg.App.LocalTimeline,
|
||||
|
@ -94,14 +94,20 @@ INNER JOIN collections c
|
|||
ON collection_id = c.id
|
||||
WHERE collection_id IS NOT NULL
|
||||
AND updated > DATE_SUB(NOW(), INTERVAL 6 MONTH)) co`).Scan(&activeHalfYear)
|
||||
if err != nil {
|
||||
log.Error("Failed getting 6-month active user stats: %s", err)
|
||||
}
|
||||
|
||||
err = r.db.QueryRow(`SELECT COUNT(*) FROM (
|
||||
SELECT DISTINCT collection_id
|
||||
FROM posts
|
||||
INNER JOIN FROM collections c
|
||||
INNER JOIN collections c
|
||||
ON collection_id = c.id
|
||||
WHERE collection_id IS NOT NULL
|
||||
AND updated > DATE_SUB(NOW(), INTERVAL 1 MONTH)) co`).Scan(&activeMonth)
|
||||
if err != nil {
|
||||
log.Error("Failed getting 1-month active user stats: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nodeinfo.Usage{
|
||||
|
|
211
oauth.go
211
oauth.go
|
@ -1,22 +1,58 @@
|
|||
/*
|
||||
* Copyright © 2019-2021 Musing Studio LLC.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writeas/writefreely/config"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/writeas/impart"
|
||||
"github.com/writeas/web-core/log"
|
||||
"github.com/writefreely/writefreely/config"
|
||||
)
|
||||
|
||||
// OAuthButtons holds display information for different OAuth providers we support.
|
||||
type OAuthButtons struct {
|
||||
SlackEnabled bool
|
||||
WriteAsEnabled bool
|
||||
GitLabEnabled bool
|
||||
GitLabDisplayName string
|
||||
GiteaEnabled bool
|
||||
GiteaDisplayName string
|
||||
GenericEnabled bool
|
||||
GenericDisplayName string
|
||||
}
|
||||
|
||||
// NewOAuthButtons creates a new OAuthButtons struct based on our app configuration.
|
||||
func NewOAuthButtons(cfg *config.Config) *OAuthButtons {
|
||||
return &OAuthButtons{
|
||||
SlackEnabled: cfg.SlackOauth.ClientID != "",
|
||||
WriteAsEnabled: cfg.WriteAsOauth.ClientID != "",
|
||||
GitLabEnabled: cfg.GitlabOauth.ClientID != "",
|
||||
GitLabDisplayName: config.OrDefaultString(cfg.GitlabOauth.DisplayName, gitlabDisplayName),
|
||||
GiteaEnabled: cfg.GiteaOauth.ClientID != "",
|
||||
GiteaDisplayName: config.OrDefaultString(cfg.GiteaOauth.DisplayName, giteaDisplayName),
|
||||
GenericEnabled: cfg.GenericOauth.ClientID != "",
|
||||
GenericDisplayName: config.OrDefaultString(cfg.GenericOauth.DisplayName, genericOauthDisplayName),
|
||||
}
|
||||
}
|
||||
|
||||
// TokenResponse contains data returned when a token is created either
|
||||
// through a code exchange or using a refresh token.
|
||||
type TokenResponse struct {
|
||||
|
@ -59,10 +95,10 @@ type OAuthDatastoreProvider interface {
|
|||
type OAuthDatastore interface {
|
||||
GetIDForRemoteUser(context.Context, string, string, string) (int64, error)
|
||||
RecordRemoteUserID(context.Context, int64, string, string, string, string) error
|
||||
ValidateOAuthState(context.Context, string) (string, string, error)
|
||||
GenerateOAuthState(context.Context, string, string) (string, error)
|
||||
ValidateOAuthState(context.Context, string) (string, string, int64, string, error)
|
||||
GenerateOAuthState(context.Context, string, string, int64, string) (string, error)
|
||||
|
||||
CreateUser(*config.Config, *User, string) error
|
||||
CreateUser(*config.Config, *User, string, string) error
|
||||
GetUserByID(int64) (*User, error)
|
||||
}
|
||||
|
||||
|
@ -96,19 +132,32 @@ type oauthHandler struct {
|
|||
|
||||
func (h oauthHandler) viewOauthInit(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||
ctx := r.Context()
|
||||
state, err := h.DB.GenerateOAuthState(ctx, h.oauthClient.GetProvider(), h.oauthClient.GetClientID())
|
||||
|
||||
var attachUser int64
|
||||
if attach := r.URL.Query().Get("attach"); attach == "t" {
|
||||
user, _ := getUserAndSession(app, r)
|
||||
if user == nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, "cannot attach auth to user: user not found in session"}
|
||||
}
|
||||
attachUser = user.ID
|
||||
}
|
||||
|
||||
state, err := h.DB.GenerateOAuthState(ctx, h.oauthClient.GetProvider(), h.oauthClient.GetClientID(), attachUser, r.FormValue("invite_code"))
|
||||
if err != nil {
|
||||
log.Error("viewOauthInit error: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"}
|
||||
}
|
||||
|
||||
if h.callbackProxy != nil {
|
||||
if err := h.callbackProxy.register(ctx, state); err != nil {
|
||||
log.Error("viewOauthInit error: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "could not register state server"}
|
||||
}
|
||||
}
|
||||
|
||||
location, err := h.oauthClient.buildLoginURL(state)
|
||||
if err != nil {
|
||||
log.Error("viewOauthInit error: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, "could not prepare oauth redirect url"}
|
||||
}
|
||||
return impart.HTTPError{http.StatusTemporaryRedirect, location}
|
||||
|
@ -149,7 +198,7 @@ func configureWriteAsOauth(parentHandler *Handler, r *mux.Router, app *App) {
|
|||
callbackLocation: app.Config().App.Host + "/oauth/callback/write.as",
|
||||
httpClient: config.DefaultHTTPClient(),
|
||||
}
|
||||
callbackLocation = app.Config().SlackOauth.CallbackProxy
|
||||
callbackLocation = app.Config().WriteAsOauth.CallbackProxy
|
||||
}
|
||||
|
||||
oauthClient := writeAsOauthClient{
|
||||
|
@ -165,6 +214,98 @@ func configureWriteAsOauth(parentHandler *Handler, r *mux.Router, app *App) {
|
|||
}
|
||||
}
|
||||
|
||||
func configureGitlabOauth(parentHandler *Handler, r *mux.Router, app *App) {
|
||||
if app.Config().GitlabOauth.ClientID != "" {
|
||||
callbackLocation := app.Config().App.Host + "/oauth/callback/gitlab"
|
||||
|
||||
var callbackProxy *callbackProxyClient = nil
|
||||
if app.Config().GitlabOauth.CallbackProxy != "" {
|
||||
callbackProxy = &callbackProxyClient{
|
||||
server: app.Config().GitlabOauth.CallbackProxyAPI,
|
||||
callbackLocation: app.Config().App.Host + "/oauth/callback/gitlab",
|
||||
httpClient: config.DefaultHTTPClient(),
|
||||
}
|
||||
callbackLocation = app.Config().GitlabOauth.CallbackProxy
|
||||
}
|
||||
|
||||
address := config.OrDefaultString(app.Config().GitlabOauth.Host, gitlabHost)
|
||||
oauthClient := gitlabOauthClient{
|
||||
ClientID: app.Config().GitlabOauth.ClientID,
|
||||
ClientSecret: app.Config().GitlabOauth.ClientSecret,
|
||||
ExchangeLocation: address + "/oauth/token",
|
||||
InspectLocation: address + "/api/v4/user",
|
||||
AuthLocation: address + "/oauth/authorize",
|
||||
HttpClient: config.DefaultHTTPClient(),
|
||||
CallbackLocation: callbackLocation,
|
||||
}
|
||||
configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy)
|
||||
}
|
||||
}
|
||||
|
||||
func configureGenericOauth(parentHandler *Handler, r *mux.Router, app *App) {
|
||||
if app.Config().GenericOauth.ClientID != "" {
|
||||
callbackLocation := app.Config().App.Host + "/oauth/callback/generic"
|
||||
|
||||
var callbackProxy *callbackProxyClient = nil
|
||||
if app.Config().GenericOauth.CallbackProxy != "" {
|
||||
callbackProxy = &callbackProxyClient{
|
||||
server: app.Config().GenericOauth.CallbackProxyAPI,
|
||||
callbackLocation: app.Config().App.Host + "/oauth/callback/generic",
|
||||
httpClient: config.DefaultHTTPClient(),
|
||||
}
|
||||
callbackLocation = app.Config().GenericOauth.CallbackProxy
|
||||
}
|
||||
|
||||
oauthClient := genericOauthClient{
|
||||
ClientID: app.Config().GenericOauth.ClientID,
|
||||
ClientSecret: app.Config().GenericOauth.ClientSecret,
|
||||
ExchangeLocation: app.Config().GenericOauth.Host + app.Config().GenericOauth.TokenEndpoint,
|
||||
InspectLocation: app.Config().GenericOauth.Host + app.Config().GenericOauth.InspectEndpoint,
|
||||
AuthLocation: app.Config().GenericOauth.Host + app.Config().GenericOauth.AuthEndpoint,
|
||||
HttpClient: config.DefaultHTTPClient(),
|
||||
CallbackLocation: callbackLocation,
|
||||
Scope: config.OrDefaultString(app.Config().GenericOauth.Scope, "read_user"),
|
||||
MapUserID: config.OrDefaultString(app.Config().GenericOauth.MapUserID, "user_id"),
|
||||
MapUsername: config.OrDefaultString(app.Config().GenericOauth.MapUsername, "username"),
|
||||
MapDisplayName: config.OrDefaultString(app.Config().GenericOauth.MapDisplayName, "-"),
|
||||
MapEmail: config.OrDefaultString(app.Config().GenericOauth.MapEmail, "email"),
|
||||
}
|
||||
configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy)
|
||||
}
|
||||
}
|
||||
|
||||
func configureGiteaOauth(parentHandler *Handler, r *mux.Router, app *App) {
|
||||
if app.Config().GiteaOauth.ClientID != "" {
|
||||
callbackLocation := app.Config().App.Host + "/oauth/callback/gitea"
|
||||
|
||||
var callbackProxy *callbackProxyClient = nil
|
||||
if app.Config().GiteaOauth.CallbackProxy != "" {
|
||||
callbackProxy = &callbackProxyClient{
|
||||
server: app.Config().GiteaOauth.CallbackProxyAPI,
|
||||
callbackLocation: app.Config().App.Host + "/oauth/callback/gitea",
|
||||
httpClient: config.DefaultHTTPClient(),
|
||||
}
|
||||
callbackLocation = app.Config().GiteaOauth.CallbackProxy
|
||||
}
|
||||
|
||||
oauthClient := giteaOauthClient{
|
||||
ClientID: app.Config().GiteaOauth.ClientID,
|
||||
ClientSecret: app.Config().GiteaOauth.ClientSecret,
|
||||
ExchangeLocation: app.Config().GiteaOauth.Host + "/login/oauth/access_token",
|
||||
InspectLocation: app.Config().GiteaOauth.Host + "/login/oauth/userinfo",
|
||||
AuthLocation: app.Config().GiteaOauth.Host + "/login/oauth/authorize",
|
||||
HttpClient: config.DefaultHTTPClient(),
|
||||
CallbackLocation: callbackLocation,
|
||||
Scope: "openid profile email",
|
||||
MapUserID: "sub",
|
||||
MapUsername: "login",
|
||||
MapDisplayName: "full_name",
|
||||
MapEmail: "email",
|
||||
}
|
||||
configureOauthRoutes(parentHandler, r, app, oauthClient, callbackProxy)
|
||||
}
|
||||
}
|
||||
|
||||
func configureOauthRoutes(parentHandler *Handler, r *mux.Router, app *App, oauthClient oauthClient, callbackProxy *callbackProxyClient) {
|
||||
handler := &oauthHandler{
|
||||
Config: app.Config(),
|
||||
|
@ -185,7 +326,7 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
code := r.FormValue("code")
|
||||
state := r.FormValue("state")
|
||||
|
||||
provider, clientID, err := h.DB.ValidateOAuthState(ctx, state)
|
||||
provider, clientID, attachUserID, inviteCode, err := h.DB.ValidateOAuthState(ctx, state)
|
||||
if err != nil {
|
||||
log.Error("Unable to ValidateOAuthState: %s", err)
|
||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
|
@ -194,10 +335,16 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
tokenResponse, err := h.oauthClient.exchangeOauthCode(ctx, code)
|
||||
if err != nil {
|
||||
log.Error("Unable to exchangeOauthCode: %s", err)
|
||||
// TODO: show user friendly message if needed
|
||||
// TODO: show NO message for cases like user pressing "Cancel" on authorize step
|
||||
addSessionFlash(app, w, r, err.Error(), nil)
|
||||
if attachUserID > 0 {
|
||||
return impart.HTTPError{http.StatusFound, "/me/settings"}
|
||||
}
|
||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
}
|
||||
|
||||
// Now that we have the access token, let's use it real quick to make sur
|
||||
// Now that we have the access token, let's use it real quick to make sure
|
||||
// it really really works.
|
||||
tokenInfo, err := h.oauthClient.inspectOauthAccessToken(ctx, tokenResponse.AccessToken)
|
||||
if err != nil {
|
||||
|
@ -211,7 +358,15 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
}
|
||||
|
||||
if localUserID != -1 && attachUserID > 0 {
|
||||
if err = addSessionFlash(app, w, r, "This OAuth account is already attached to another user.", nil); err != nil {
|
||||
return impart.HTTPError{Status: http.StatusInternalServerError, Message: err.Error()}
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, "/me/settings"}
|
||||
}
|
||||
|
||||
if localUserID != -1 {
|
||||
// Existing user, so log in now
|
||||
user, err := h.DB.GetUserByID(localUserID)
|
||||
if err != nil {
|
||||
log.Error("Unable to GetUserByID %d: %s", localUserID, err)
|
||||
|
@ -223,6 +378,31 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
}
|
||||
return nil
|
||||
}
|
||||
if attachUserID > 0 {
|
||||
log.Info("attaching to user %d", attachUserID)
|
||||
log.Info("OAuth userid: %s", tokenInfo.UserID)
|
||||
err = h.DB.RecordRemoteUserID(r.Context(), attachUserID, tokenInfo.UserID, provider, clientID, tokenResponse.AccessToken)
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, "/me/settings"}
|
||||
}
|
||||
|
||||
// New user registration below.
|
||||
// First, verify that user is allowed to register
|
||||
if inviteCode != "" {
|
||||
// Verify invite code is valid
|
||||
i, err := app.db.GetUserInvite(inviteCode)
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, err.Error()}
|
||||
}
|
||||
if !i.Active(app.db) {
|
||||
return impart.HTTPError{http.StatusNotFound, "Invite link has expired."}
|
||||
}
|
||||
} else if !app.cfg.App.OpenRegistration {
|
||||
addSessionFlash(app, w, r, ErrUserNotFound.Error(), nil)
|
||||
return impart.HTTPError{http.StatusFound, "/login"}
|
||||
}
|
||||
|
||||
displayName := tokenInfo.DisplayName
|
||||
if len(displayName) == 0 {
|
||||
|
@ -237,6 +417,7 @@ func (h oauthHandler) viewOauthCallback(app *App, w http.ResponseWriter, r *http
|
|||
TokenRemoteUser: tokenInfo.UserID,
|
||||
Provider: provider,
|
||||
ClientID: clientID,
|
||||
InviteCode: inviteCode,
|
||||
}
|
||||
tp.TokenHash = tp.HashTokenParams(h.Config.Server.HashSeed)
|
||||
|
||||
|
@ -251,7 +432,7 @@ func (r *callbackProxyClient) register(ctx context.Context, state string) error
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "writefreely")
|
||||
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
|
@ -268,7 +449,7 @@ func (r *callbackProxyClient) register(ctx context.Context, state string) error
|
|||
|
||||
func limitedJsonUnmarshal(body io.ReadCloser, n int, thing interface{}) error {
|
||||
lr := io.LimitReader(body, int64(n+1))
|
||||
data, err := ioutil.ReadAll(lr)
|
||||
data, err := io.ReadAll(lr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -7,4 +7,3 @@ type ClientStateStore interface {
|
|||
Generate(ctx context.Context) (string, error)
|
||||
Validate(ctx context.Context, state string) error
|
||||
}
|
||||
|
||||
|
|
144
oauth_generic.go
Normal file
144
oauth_generic.go
Normal file
|
@ -0,0 +1,144 @@
|
|||
/*
|
||||
* Copyright © 2020-2021 Musing Studio LLC and respective authors.
|
||||
*
|
||||
* This file is part of WriteFreely.
|
||||
*
|
||||
* WriteFreely is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License, included
|
||||
* in the LICENSE file in this source code package.
|
||||
*/
|
||||
|
||||
package writefreely
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/writeas/web-core/log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type genericOauthClient struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
AuthLocation string
|
||||
ExchangeLocation string
|
||||
InspectLocation string
|
||||
CallbackLocation string
|
||||
Scope string
|
||||
MapUserID string
|
||||
MapUsername string
|
||||
MapDisplayName string
|
||||
MapEmail string
|
||||
HttpClient HttpClient
|
||||
}
|
||||
|
||||
var _ oauthClient = genericOauthClient{}
|
||||
|
||||
const (
|
||||
genericOauthDisplayName = "OAuth"
|
||||
)
|
||||
|
||||
func (c genericOauthClient) GetProvider() string {
|
||||
return "generic"
|
||||
}
|
||||
|
||||
func (c genericOauthClient) GetClientID() string {
|
||||
return c.ClientID
|
||||
}
|
||||
|
||||
func (c genericOauthClient) GetCallbackLocation() string {
|
||||
return c.CallbackLocation
|
||||
}
|
||||
|
||||
func (c genericOauthClient) buildLoginURL(state string) (string, error) {
|
||||
u, err := url.Parse(c.AuthLocation)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("client_id", c.ClientID)
|
||||
q.Set("redirect_uri", c.CallbackLocation)
|
||||
q.Set("response_type", "code")
|
||||
q.Set("state", state)
|
||||
q.Set("scope", c.Scope)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (c genericOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) {
|
||||
form := url.Values{}
|
||||
form.Add("client_id", c.ClientID)
|
||||
form.Add("client_secret", c.ClientSecret)
|
||||
form.Add("grant_type", "authorization_code")
|
||||
form.Add("redirect_uri", c.CallbackLocation)
|
||||
form.Add("scope", c.Scope)
|
||||
form.Add("code", code)
|
||||
req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.WithContext(ctx)
|
||||
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(c.ClientID, c.ClientSecret)
|
||||
|
||||
resp, err := c.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("unable to exchange code for access token")
|
||||
}
|
||||
|
||||
var tokenResponse TokenResponse
|
||||
if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tokenResponse.Error != "" {
|
||||
return nil, errors.New(tokenResponse.Error)
|
||||
}
|
||||
return &tokenResponse, nil
|
||||
}
|
||||
|
||||
func (c genericOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) {
|
||||
req, err := http.NewRequest("GET", c.InspectLocation, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.WithContext(ctx)
|
||||
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := c.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("unable to inspect access token")
|
||||
}
|
||||
|
||||
// since we don't know what the JSON from the server will look like, we create a
|
||||
// generic interface and then map manually to values set in the config
|
||||
var genericInterface map[string]interface{}
|
||||
if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &genericInterface); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// map each relevant field in inspectResponse to the mapped field from the config
|
||||
var inspectResponse InspectResponse
|
||||
inspectResponse.UserID, _ = genericInterface[c.MapUserID].(string)
|
||||
if inspectResponse.UserID == "" {
|
||||
log.Error("[CONFIGURATION ERROR] Generic OAuth provider returned empty UserID value (`%s`).\n Do you need to configure a different `map_user_id` value for this provider?", c.MapUserID)
|
||||
return nil, fmt.Errorf("no UserID (`%s`) value returned", c.MapUserID)
|
||||
}
|
||||
inspectResponse.Username, _ = genericInterface[c.MapUsername].(string)
|
||||
inspectResponse.DisplayName, _ = genericInterface[c.MapDisplayName].(string)
|
||||
inspectResponse.Email, _ = genericInterface[c.MapEmail].(string)
|
||||
|
||||
return &inspectResponse, nil
|
||||
}
|
133
oauth_gitea.go
Normal file
133
oauth_gitea.go
Normal file
|
@ -0,0 +1,133 @@
|
|||
package writefreely
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/writeas/web-core/log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type giteaOauthClient struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
AuthLocation string
|
||||
ExchangeLocation string
|
||||
InspectLocation string
|
||||
CallbackLocation string
|
||||
Scope string
|
||||
MapUserID string
|
||||
MapUsername string
|
||||
MapDisplayName string
|
||||
MapEmail string
|
||||
HttpClient HttpClient
|
||||
}
|
||||
|
||||
var _ oauthClient = giteaOauthClient{}
|
||||
|
||||
const (
|
||||
giteaDisplayName = "Gitea"
|
||||
)
|
||||
|
||||
func (c giteaOauthClient) GetProvider() string {
|
||||
return "gitea"
|
||||
}
|
||||
|
||||
func (c giteaOauthClient) GetClientID() string {
|
||||
return c.ClientID
|
||||
}
|
||||
|
||||
func (c giteaOauthClient) GetCallbackLocation() string {
|
||||
return c.CallbackLocation
|
||||
}
|
||||
|
||||
func (c giteaOauthClient) buildLoginURL(state string) (string, error) {
|
||||
u, err := url.Parse(c.AuthLocation)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("client_id", c.ClientID)
|
||||
q.Set("redirect_uri", c.CallbackLocation)
|
||||
q.Set("response_type", "code")
|
||||
q.Set("state", state)
|
||||
q.Set("scope", c.Scope)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (c giteaOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) {
|
||||
form := url.Values{}
|
||||
form.Add("grant_type", "authorization_code")
|
||||
form.Add("redirect_uri", c.CallbackLocation)
|
||||
form.Add("scope", c.Scope)
|
||||
form.Add("code", code)
|
||||
req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.WithContext(ctx)
|
||||
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(c.ClientID, c.ClientSecret)
|
||||
|
||||
resp, err := c.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("unable to exchange code for access token")
|
||||
}
|
||||
|
||||
var tokenResponse TokenResponse
|
||||
if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tokenResponse.Error != "" {
|
||||
return nil, errors.New(tokenResponse.Error)
|
||||
}
|
||||
return &tokenResponse, nil
|
||||
}
|
||||
|
||||
func (c giteaOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) {
|
||||
req, err := http.NewRequest("GET", c.InspectLocation, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.WithContext(ctx)
|
||||
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := c.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("unable to inspect access token")
|
||||
}
|
||||
|
||||
// since we don't know what the JSON from the server will look like, we create a
|
||||
// generic interface and then map manually to values set in the config
|
||||
var genericInterface map[string]interface{}
|
||||
if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &genericInterface); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// map each relevant field in inspectResponse to the mapped field from the config
|
||||
var inspectResponse InspectResponse
|
||||
inspectResponse.UserID, _ = genericInterface[c.MapUserID].(string)
|
||||
// log.Info("Userid from Gitea: %s", inspectResponse.UserID)
|
||||
if inspectResponse.UserID == "" {
|
||||
log.Error("[CONFIGURATION ERROR] Gitea OAuth provider returned empty UserID value (`%s`).\n Do you need to configure a different `map_user_id` value for this provider?", c.MapUserID)
|
||||
return nil, fmt.Errorf("no UserID (`%s`) value returned", c.MapUserID)
|
||||
}
|
||||
inspectResponse.Username, _ = genericInterface[c.MapUsername].(string)
|
||||
inspectResponse.DisplayName, _ = genericInterface[c.MapDisplayName].(string)
|
||||
inspectResponse.Email, _ = genericInterface[c.MapEmail].(string)
|
||||
|
||||
return &inspectResponse, nil
|
||||
}
|
115
oauth_gitlab.go
Normal file
115
oauth_gitlab.go
Normal file
|
@ -0,0 +1,115 @@
|
|||
package writefreely
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type gitlabOauthClient struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
AuthLocation string
|
||||
ExchangeLocation string
|
||||
InspectLocation string
|
||||
CallbackLocation string
|
||||
HttpClient HttpClient
|
||||
}
|
||||
|
||||
var _ oauthClient = gitlabOauthClient{}
|
||||
|
||||
const (
|
||||
gitlabHost = "https://gitlab.com"
|
||||
gitlabDisplayName = "GitLab"
|
||||
)
|
||||
|
||||
func (c gitlabOauthClient) GetProvider() string {
|
||||
return "gitlab"
|
||||
}
|
||||
|
||||
func (c gitlabOauthClient) GetClientID() string {
|
||||
return c.ClientID
|
||||
}
|
||||
|
||||
func (c gitlabOauthClient) GetCallbackLocation() string {
|
||||
return c.CallbackLocation
|
||||
}
|
||||
|
||||
func (c gitlabOauthClient) buildLoginURL(state string) (string, error) {
|
||||
u, err := url.Parse(c.AuthLocation)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("client_id", c.ClientID)
|
||||
q.Set("redirect_uri", c.CallbackLocation)
|
||||
q.Set("response_type", "code")
|
||||
q.Set("state", state)
|
||||
q.Set("scope", "read_user")
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (c gitlabOauthClient) exchangeOauthCode(ctx context.Context, code string) (*TokenResponse, error) {
|
||||
form := url.Values{}
|
||||
form.Add("grant_type", "authorization_code")
|
||||
form.Add("redirect_uri", c.CallbackLocation)
|
||||
form.Add("scope", "read_user")
|
||||
form.Add("code", code)
|
||||
req, err := http.NewRequest("POST", c.ExchangeLocation, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.WithContext(ctx)
|
||||
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(c.ClientID, c.ClientSecret)
|
||||
|
||||
resp, err := c.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("unable to exchange code for access token")
|
||||
}
|
||||
|
||||
var tokenResponse TokenResponse
|
||||
if err := limitedJsonUnmarshal(resp.Body, tokenRequestMaxLen, &tokenResponse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tokenResponse.Error != "" {
|
||||
return nil, errors.New(tokenResponse.Error)
|
||||
}
|
||||
return &tokenResponse, nil
|
||||
}
|
||||
|
||||
func (c gitlabOauthClient) inspectOauthAccessToken(ctx context.Context, accessToken string) (*InspectResponse, error) {
|
||||
req, err := http.NewRequest("GET", c.InspectLocation, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.WithContext(ctx)
|
||||
req.Header.Set("User-Agent", ServerUserAgent(""))
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := c.HttpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("unable to inspect access token")
|
||||
}
|
||||
|
||||
var inspectResponse InspectResponse
|
||||
if err := limitedJsonUnmarshal(resp.Body, infoRequestMaxLen, &inspectResponse); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if inspectResponse.Error != "" {
|
||||
return nil, errors.New(inspectResponse.Error)
|
||||
}
|
||||
return &inspectResponse, nil
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue