Compare commits
958 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6462669e9e | ||
|
|
d18f135fbd | ||
|
|
59ad71964b | ||
|
|
bf890aec15 | ||
|
|
9cf85e951e | ||
|
|
814d63554f | ||
|
|
ce650a69d4 | ||
|
|
5e84fb66f9 | ||
|
|
1430d6f7e7 | ||
|
|
f00ac1a590 | ||
|
|
f260064083 | ||
|
|
f2e9c862a6 | ||
|
|
82bb92768c | ||
|
|
c63b2b320b | ||
|
|
a9dbd2cc4e | ||
|
|
ae207c10b6 | ||
|
|
955a933fe9 | ||
|
|
45c3f6ae3f | ||
|
|
bfa7a06958 | ||
|
|
c93071666a | ||
|
|
0d53436f7e | ||
|
|
d8f9f47814 | ||
|
|
b46f7828a5 | ||
|
|
1a0a27bee1 | ||
|
|
6f97903f3b | ||
|
|
1bfc5fb013 | ||
|
|
4eae4cd062 | ||
|
|
a6c9d6cd4f | ||
|
|
b5e9e35427 | ||
|
|
278bf960cb | ||
|
|
0bf5f7c983 | ||
|
|
870b6dbe85 | ||
|
|
2fffbcbede | ||
|
|
551b9a94fe | ||
|
|
24d4150da4 | ||
|
|
add2429606 | ||
|
|
eb096c5991 | ||
|
|
30bb801f9f | ||
|
|
6d7c545c88 | ||
|
|
a1300466f4 | ||
|
|
90b6399407 | ||
|
|
7de4b573e3 | ||
|
|
dc802ff5f6 | ||
|
|
59cd519337 | ||
|
|
aff51b651c | ||
|
|
e1bd389bf1 | ||
|
|
2310e8b33d | ||
|
|
129bc97b09 | ||
|
|
1a666e8371 | ||
|
|
03940cd8fd | ||
|
|
1f03017327 | ||
|
|
3798e170d0 | ||
|
|
5cae547f8d | ||
|
|
fde70fa61a | ||
|
|
a211772309 | ||
|
|
a6d3ebbeef | ||
|
|
79742f47dc | ||
|
|
4cc6284eb4 | ||
|
|
13d43e8e71 | ||
|
|
0ae8f632b5 | ||
|
|
85aceb2e48 | ||
|
|
0692aa6efa | ||
|
|
15e291b487 | ||
|
|
343a507ecc | ||
|
|
fa1dd69e2d | ||
|
|
e0adaca2a2 | ||
|
|
1fba91f772 | ||
|
|
710f965144 | ||
|
|
c737aeba8e | ||
|
|
9eac364e01 | ||
|
|
d3f411915f | ||
|
|
7bd07801f2 | ||
|
|
287f428a83 | ||
|
|
8c0a20a743 | ||
|
|
6d85dbdfcb | ||
|
|
77cd27a458 | ||
|
|
e5e688a154 | ||
|
|
fa5be6bd6a | ||
|
|
bf9840bd14 | ||
|
|
0551b8e12d | ||
|
|
9722b94eae | ||
|
|
70974d3c35 | ||
|
|
99a7658956 | ||
|
|
64068f9d23 | ||
|
|
1d96db5313 | ||
|
|
e64457c7e2 | ||
|
|
6bb4f90c18 | ||
|
|
e50c8b6a5b | ||
|
|
d12bbb2a1b | ||
|
|
0fc94cab3b | ||
|
|
affe456fb8 | ||
|
|
980b902927 | ||
|
|
7f23aa893b | ||
|
|
5dfcfc548f | ||
|
|
c0060f1baf | ||
|
|
58bcbc4da7 | ||
|
|
4d8fe93188 | ||
|
|
cf08479c48 | ||
|
|
ddb3cfc978 | ||
|
|
cd12ccd6c1 | ||
|
|
ec69fc2ac0 | ||
|
|
3832328aaf | ||
|
|
70aff2df68 | ||
|
|
55329eaae0 | ||
|
|
4a4f72fd66 | ||
|
|
441c7714d9 | ||
|
|
294924fc49 | ||
|
|
2155ca12be | ||
|
|
8874688054 | ||
|
|
cf75679d7f | ||
|
|
37cbe12c4d | ||
|
|
d2531e8ace | ||
|
|
9e08248f0c | ||
|
|
138f4fade3 | ||
|
|
f95f57c4d4 | ||
|
|
fd9145b7a8 | ||
|
|
8ae4f3815a | ||
|
|
85ef96fc7f | ||
|
|
6edb0310d8 | ||
|
|
054b8bc5cb | ||
|
|
1d17831454 | ||
|
|
98616ceefb | ||
|
|
ffa0c209b6 | ||
|
|
a2e907390f | ||
|
|
0a712128a5 | ||
|
|
d54527454f | ||
|
|
f35aae6c44 | ||
|
|
fb5c3fcd12 | ||
|
|
da1e209f9e | ||
|
|
d6b15b1b85 | ||
|
|
26f2dca5d6 | ||
|
|
88944712fe | ||
|
|
b67d86dc57 | ||
|
|
dad99e854d | ||
|
|
2d4465617b | ||
|
|
d46befbd1d | ||
|
|
765348c440 | ||
|
|
404d63f6d0 | ||
|
|
5bb4e967a7 | ||
|
|
c26e896afe | ||
|
|
2d921438a9 | ||
|
|
385997cdcc | ||
|
|
37bc4458e5 | ||
|
|
1beb18e321 | ||
|
|
5a4ce29206 | ||
|
|
1679585c4c | ||
|
|
963173cdae | ||
|
|
e7aec8752c | ||
|
|
dc1b58a791 | ||
|
|
dbde49b9bd | ||
|
|
7e44e55b3f | ||
|
|
1d301d72ae | ||
|
|
45e5460975 | ||
|
|
f79b0bc999 | ||
|
|
54b2dfb78d | ||
|
|
9ff9b90f6b | ||
|
|
d638610361 | ||
|
|
ad1dc13a51 | ||
|
|
2908fcc9e8 | ||
|
|
512e0295a2 | ||
|
|
12740a2d06 | ||
|
|
838f2fd4cf | ||
|
|
52630e7042 | ||
|
|
40b34e4855 | ||
|
|
9840b5e10f | ||
|
|
14855b9dfe | ||
|
|
844fbf7c9e | ||
|
|
7a6b93a36c | ||
|
|
dc1ddb758d | ||
|
|
411fcd8af5 | ||
|
|
25ea870f71 | ||
|
|
d55668d529 | ||
|
|
dde085464c | ||
|
|
9d79543951 | ||
|
|
37f68bbffd | ||
|
|
2bb3731187 | ||
|
|
ed06d0b54c | ||
|
|
c68bfdf6e1 | ||
|
|
757c227f00 | ||
|
|
c9a1581932 | ||
|
|
9d1d56bd08 | ||
|
|
666eef063c | ||
|
|
1b983f9334 | ||
|
|
e5b7325379 | ||
|
|
58342e86e1 | ||
|
|
0576aff972 | ||
|
|
cd4cfa6a70 | ||
|
|
d75254fc71 | ||
|
|
3d3e64edab | ||
|
|
1993231663 | ||
|
|
58b4d7454f | ||
|
|
5f8c57b3e1 | ||
|
|
ebb0f52f1e | ||
|
|
c674a1309c | ||
|
|
65e2e19ff1 | ||
|
|
7112a66e4c | ||
|
|
ec506241f0 | ||
|
|
f1ef85b314 | ||
|
|
2a1a164d59 | ||
|
|
8d1af1b0cd | ||
|
|
54e282b03c | ||
|
|
b6373dc185 | ||
|
|
84b9fc3719 | ||
|
|
232ce83e4d | ||
|
|
dd38a3900c | ||
|
|
c2d270e4e3 | ||
|
|
3fe07a79b8 | ||
|
|
52602c3da7 | ||
|
|
956a5fd2b3 | ||
|
|
764061b4be | ||
|
|
457a4054b7 | ||
|
|
ce64afe283 | ||
|
|
f98d7ec560 | ||
|
|
21b4f8a024 | ||
|
|
5b756ea2dd | ||
|
|
fc1877c6cc | ||
|
|
f114f9a51a | ||
|
|
066220ffbd | ||
|
|
e19a1b061a | ||
|
|
28577d017a | ||
|
|
7fc7959712 | ||
|
|
6622ee9020 | ||
|
|
4063d58d79 | ||
|
|
ed9ffe34f4 | ||
|
|
e6c7e8a597 | ||
|
|
e5b44cb946 | ||
|
|
131fd1c6e9 | ||
|
|
ef57198220 | ||
|
|
935ad72936 | ||
|
|
bf42f3d677 | ||
|
|
045b7d6083 | ||
|
|
54fd81f076 | ||
|
|
d4afd84019 | ||
|
|
3fe9926fcf | ||
|
|
416e3009a0 | ||
|
|
276f82882f | ||
|
|
59a3463c72 | ||
|
|
6a810529bc | ||
|
|
1856176de5 | ||
|
|
247a8fbce3 | ||
|
|
e3e285571e | ||
|
|
a0ce18337a | ||
|
|
bff1c5f734 | ||
|
|
fda1167234 | ||
|
|
6ff27ede73 | ||
|
|
03d3a2d3d4 | ||
|
|
264e2fe8ac | ||
|
|
e5f222c529 | ||
|
|
546b7446b9 | ||
|
|
7c622730dc | ||
|
|
2aeada4904 | ||
|
|
76d1ccc859 | ||
|
|
99fac323c8 | ||
|
|
ff7b11440d | ||
|
|
450058213d | ||
|
|
1d2ea36fac | ||
|
|
bf071c1b27 | ||
|
|
9ba6237f13 | ||
|
|
f60663506a | ||
|
|
29cbe7d293 | ||
|
|
ba431e2b11 | ||
|
|
bc961b70bb | ||
|
|
cf1104d762 | ||
|
|
c7aae24d42 | ||
|
|
8ac476fe66 | ||
|
|
3216fc339a | ||
|
|
9c30dacda7 | ||
|
|
2f61cd8f0a | ||
|
|
bbd56b600d | ||
|
|
c4339e64bd | ||
|
|
e32b6f9f8e | ||
|
|
88bb724ae0 | ||
|
|
24efc77770 | ||
|
|
11ba1ab5c8 | ||
|
|
b086e65404 | ||
|
|
8188a6ffc7 | ||
|
|
ded8799a9c | ||
|
|
80b874e5fb | ||
|
|
dcdc8c7365 | ||
|
|
0e9db83279 | ||
|
|
3484b1e1a1 | ||
|
|
1c543723fb | ||
|
|
bedc25bacf | ||
|
|
cde2836982 | ||
|
|
5d64ecd04f | ||
|
|
ea0afdaf22 | ||
|
|
59cf4e384a | ||
|
|
8706c7b405 | ||
|
|
85de7b8ddc | ||
|
|
c58c8c6cc8 | ||
|
|
7b3158c102 | ||
|
|
d839c274b1 | ||
|
|
d096ab830c | ||
|
|
acd2bcb469 | ||
|
|
1137782f2a | ||
|
|
9d88fdbe53 | ||
|
|
deada6cbd9 | ||
|
|
fbd352e23c | ||
|
|
82da70bcac | ||
|
|
16f302c2dc | ||
|
|
4926d6ff5d | ||
|
|
a9ea5eb672 | ||
|
|
09f30db83a | ||
|
|
dc12b269f5 | ||
|
|
621dd7e9d9 | ||
|
|
fbfd237f27 | ||
|
|
c14621ee06 | ||
|
|
44d7264b79 | ||
|
|
a7b29d563e | ||
|
|
6af6bde12a | ||
|
|
8d2451cafc | ||
|
|
20970a76fd | ||
|
|
c621d9251e | ||
|
|
7268bd74f7 | ||
|
|
98d63d85d4 | ||
|
|
6f97f9f8f1 | ||
|
|
c334cd9cc8 | ||
|
|
1509786090 | ||
|
|
f67fed12e0 | ||
|
|
e00182cf54 | ||
|
|
4fdb96930f | ||
|
|
6f67881d96 | ||
|
|
41341cf252 | ||
|
|
43b87dbfd3 | ||
|
|
e293bd280d | ||
|
|
84a0a07ea6 | ||
|
|
0ae9cfe26c | ||
|
|
83399ba5f1 | ||
|
|
a8541bdc44 | ||
|
|
cbbf49905b | ||
|
|
c94dd7c59d | ||
|
|
8796f694bc | ||
|
|
cfefd56a55 | ||
|
|
c8b909db08 | ||
|
|
0708b3c45d | ||
|
|
b14fa17e1a | ||
|
|
5074ac788f | ||
|
|
06376cf58a | ||
|
|
2743528727 | ||
|
|
57e17e7607 | ||
|
|
e4768620e2 | ||
|
|
91da99c934 | ||
|
|
deee65ad6d | ||
|
|
ca42df1dfd | ||
|
|
46933c1bef | ||
|
|
d1d7ca25a4 | ||
|
|
caa071d353 | ||
|
|
594e8ca4e6 | ||
|
|
eb405d33cd | ||
|
|
8f339669b5 | ||
|
|
cd4b021aec | ||
|
|
4e38749ccb | ||
|
|
49c53de99e | ||
|
|
7431c1e21d | ||
|
|
49a301663a | ||
|
|
a037448ebb | ||
|
|
025d5bea94 | ||
|
|
ece36f6adc | ||
|
|
87bb0b6bcb | ||
|
|
1b98381242 | ||
|
|
0b3e74107e | ||
|
|
a6574249df | ||
|
|
55256e3568 | ||
|
|
fb9a0feac8 | ||
|
|
c899f12893 | ||
|
|
7a73a1a24e | ||
|
|
5fc6c4dcfa | ||
|
|
79cf43d752 | ||
|
|
eb466a0cc7 | ||
|
|
756f67c0f3 | ||
|
|
4594c69808 | ||
|
|
61b773ed11 | ||
|
|
048dd6b0ab | ||
|
|
fb84db3ea7 | ||
|
|
ecc7d1eee7 | ||
|
|
8a920218ea | ||
|
|
3ef361f521 | ||
|
|
3e19b11609 | ||
|
|
005a3a2721 | ||
|
|
34370a082a | ||
|
|
8b23eb888d | ||
|
|
50ebc12783 | ||
|
|
d527947182 | ||
|
|
c59ebef851 | ||
|
|
be69407c01 | ||
|
|
40e7903d90 | ||
|
|
b333ecc816 | ||
|
|
ef0cca671a | ||
|
|
b320ddf3ae | ||
|
|
26f1407efe | ||
|
|
8d968fa98c | ||
|
|
340ed7b258 | ||
|
|
259fba17a7 | ||
|
|
b55237cdc8 | ||
|
|
80b5184d6a | ||
|
|
59b069ce2c | ||
|
|
6301121900 | ||
|
|
083b77bbb9 | ||
|
|
36b25e0307 | ||
|
|
da369e604c | ||
|
|
ace6921447 | ||
|
|
afc5a74a40 | ||
|
|
5b6924810e | ||
|
|
fb9dbcdff0 | ||
|
|
8444ff5741 | ||
|
|
217d3c286d | ||
|
|
fa0d48b88d | ||
|
|
569ba8bf2d | ||
|
|
bd1f09837b | ||
|
|
bbfd26bb64 | ||
|
|
66c5c6e62d | ||
|
|
2fea17fdaa | ||
|
|
a3b745358b | ||
|
|
5dd8b872d9 | ||
|
|
9682cd0f99 | ||
|
|
653cf712ea | ||
|
|
c20e6eb3b8 | ||
|
|
055ee417cb | ||
|
|
1837a6feb4 | ||
|
|
bfbaa7ce2c | ||
|
|
dc8a64355a | ||
|
|
32f71b3adf | ||
|
|
8d2d9bd7fa | ||
|
|
aac94e578f | ||
|
|
d6fe6d2068 | ||
|
|
bfa44e3f34 | ||
|
|
b0645855ec | ||
|
|
83f573c14f | ||
|
|
cbcfe51362 | ||
|
|
9796280a55 | ||
|
|
06a8dd1c0a | ||
|
|
19d8680289 | ||
|
|
3ec5118771 | ||
|
|
95b8eb6e20 | ||
|
|
b2405bd118 | ||
|
|
93ebeba368 | ||
|
|
2f94884d37 | ||
|
|
02c3c9d0bf | ||
|
|
ca31830fb3 | ||
|
|
8765a45240 | ||
|
|
2860323294 | ||
|
|
4552d3712b | ||
|
|
cd5ef61ce5 | ||
|
|
ad3a417b03 | ||
|
|
e732a3df03 | ||
|
|
14ace17ad4 | ||
|
|
9f7850a9b1 | ||
|
|
845041e4db | ||
|
|
962c159ddd | ||
|
|
2eb0509fd3 | ||
|
|
c1dcdc78ae | ||
|
|
54cea29ce9 | ||
|
|
7a73b8db91 | ||
|
|
2f8b85a299 | ||
|
|
f26493140f | ||
|
|
d570e8c200 | ||
|
|
1298b3732e | ||
|
|
d06301ed72 | ||
|
|
7638a094f4 | ||
|
|
074d0e3dcc | ||
|
|
64b263a1c1 | ||
|
|
0a31b7a8f6 | ||
|
|
df84572148 | ||
|
|
3b704b4c8c | ||
|
|
11bb0a6f49 | ||
|
|
33f16bb9b1 | ||
|
|
f494f76f82 | ||
|
|
5a26bdf2f8 | ||
|
|
e52e230ce3 | ||
|
|
120ba0fb81 | ||
|
|
807aa986b0 | ||
|
|
6338f711ad | ||
|
|
9e96eca032 | ||
|
|
e8827bccfa | ||
|
|
d000914f61 | ||
|
|
a265e9df41 | ||
|
|
d2dcdce763 | ||
|
|
d84ae38573 | ||
|
|
a1aa49e089 | ||
|
|
9f1e89b592 | ||
|
|
ce781f3336 | ||
|
|
7f17074d16 | ||
|
|
7cdbb8ba6f | ||
|
|
04651746bb | ||
|
|
777a39faf5 | ||
|
|
6cf97e5dd7 | ||
|
|
0557d52afe | ||
|
|
2e827814de | ||
|
|
33b375f3ae | ||
|
|
f26ab0f0e6 | ||
|
|
c0805ff125 | ||
|
|
f9dcbb1be8 | ||
|
|
728ccc9002 | ||
|
|
df29091c44 | ||
|
|
c4ff5aa2fb | ||
|
|
d61e366a29 | ||
|
|
40f9b46392 | ||
|
|
b53307c824 | ||
|
|
b5b7014c00 | ||
|
|
2537e3cd48 | ||
|
|
5ec19f037a | ||
|
|
835cdc3f18 | ||
|
|
53688095cc | ||
|
|
bec3e4ea70 | ||
|
|
3fade63567 | ||
|
|
48ffe97849 | ||
|
|
360ec4817c | ||
|
|
8da9567ca2 | ||
|
|
076e930369 | ||
|
|
b1d8595a7c | ||
|
|
132bec4d5b | ||
|
|
5ed3f04d48 | ||
|
|
a4aafc202c | ||
|
|
5e1ec8778c | ||
|
|
06315e8a81 | ||
|
|
f523e5d355 | ||
|
|
3879763971 | ||
|
|
23300ae93e | ||
|
|
3f3cf8ec39 | ||
|
|
1e84fa6e41 | ||
|
|
19213ec29e | ||
|
|
2254c3d39c | ||
|
|
b040c88445 | ||
|
|
5e80122e81 | ||
|
|
74ec563ba5 | ||
|
|
6d4b4eb13b | ||
|
|
96d1805925 | ||
|
|
c7ec678a3e | ||
|
|
de8b8e2cc0 | ||
|
|
c7ae7f3042 | ||
|
|
08ce64e9b9 | ||
|
|
c993b7207e | ||
|
|
c7221ae9d1 | ||
|
|
d224d7b9b8 | ||
|
|
f623f2c1a0 | ||
|
|
3bcb7225bf | ||
|
|
ea248c96c4 | ||
|
|
24172b5138 | ||
|
|
5aa1c4e625 | ||
|
|
6d9e385a04 | ||
|
|
9e3311e29f | ||
|
|
739bbe935b | ||
|
|
b755fc5d62 | ||
|
|
166d1c59a5 | ||
|
|
12f7fa4047 | ||
|
|
ad2d47d174 | ||
|
|
2e41bfeee4 | ||
|
|
a05a0b313f | ||
|
|
cf149b737a | ||
|
|
d335965b2e | ||
|
|
d63196b5ee | ||
|
|
53184bbe99 | ||
|
|
bfd4c7884e | ||
|
|
ac906acbe2 | ||
|
|
e68832683f | ||
|
|
7f8ade5fc1 | ||
|
|
9dc143060f | ||
|
|
128a21cd47 | ||
|
|
45c131dfed | ||
|
|
5d2aa82247 | ||
|
|
b5411c01e4 | ||
|
|
6c56b582b3 | ||
|
|
c0fafcdfda | ||
|
|
d51bae52c6 | ||
|
|
69d7d50239 | ||
|
|
691716f7eb | ||
|
|
878abd1c77 | ||
|
|
5f090c3259 | ||
|
|
f9023893af | ||
|
|
47c666894c | ||
|
|
6ed1bd747f | ||
|
|
5554038f44 | ||
|
|
bcbc9e6bf1 | ||
|
|
02cb8bcd4f | ||
|
|
f03542b37e | ||
|
|
b0b750c05d | ||
|
|
1ab1c68d36 | ||
|
|
edf5edca9f | ||
|
|
184dae75ba | ||
|
|
bec60fbf96 | ||
|
|
df466ecaa0 | ||
|
|
3c1b330d4b | ||
|
|
dfc0bf4595 | ||
|
|
cea0544686 | ||
|
|
fbb845f7f8 | ||
|
|
3b2c0d3b5a | ||
|
|
4bf3c44959 | ||
|
|
9cd53ce58a | ||
|
|
df5e06ca8a | ||
|
|
9a917e2801 | ||
|
|
60ca66395c | ||
|
|
0da6d508f3 | ||
|
|
0ac540132a | ||
|
|
fbe86043b7 | ||
|
|
7708bff31f | ||
|
|
334c429bfa | ||
|
|
42e198ca0e | ||
|
|
c3fa867e74 | ||
|
|
9c71c3fe51 | ||
|
|
bc0943c569 | ||
|
|
c75306c58b | ||
|
|
5c817fdb57 | ||
|
|
082df183d3 | ||
|
|
a7e8b2d405 | ||
|
|
9aad2d0b27 | ||
|
|
a3817564f7 | ||
|
|
a88af8cb18 | ||
|
|
c95296b82c | ||
|
|
877b216eae | ||
|
|
832f72160f | ||
|
|
3d5a693d71 | ||
|
|
6617413222 | ||
|
|
cfd9d0ceb1 | ||
|
|
3912314a83 | ||
|
|
4d98034a79 | ||
|
|
5f0ef971f4 | ||
|
|
f3dd229dcb | ||
|
|
26749e576a | ||
|
|
866692c1dc | ||
|
|
7e2f333945 | ||
|
|
b0e49855f5 | ||
|
|
771097d037 | ||
|
|
64cef5c6d6 | ||
|
|
82daa0c74f | ||
|
|
eeafabe4dd | ||
|
|
526ae6cfdd | ||
|
|
4a1ad9dd96 | ||
|
|
f480036454 | ||
|
|
f678d51542 | ||
|
|
4e6e3425ce | ||
|
|
2f46e75659 | ||
|
|
78eaa5478c | ||
|
|
a17c634c16 | ||
|
|
9c1ca570b6 | ||
|
|
5b6a7557bb | ||
|
|
92db74acf3 | ||
|
|
26dc389010 | ||
|
|
a80234c445 | ||
|
|
2d7792e936 | ||
|
|
d43c96e591 | ||
|
|
5a159226db | ||
|
|
e588e98f4e | ||
|
|
8b96401c71 | ||
|
|
838debec25 | ||
|
|
d2113e349f | ||
|
|
1368dac77e | ||
|
|
6445ceedc8 | ||
|
|
0194b471a8 | ||
|
|
3baac85cf7 | ||
|
|
62b68a64ac | ||
|
|
7563315750 | ||
|
|
627afffdb2 | ||
|
|
903415161e | ||
|
|
92a80d97c2 | ||
|
|
5162000a1f | ||
|
|
385bdc13da | ||
|
|
5826acbf24 | ||
|
|
eb96544e68 | ||
|
|
558ae72c82 | ||
|
|
2f823317c2 | ||
|
|
ad9ed2598c | ||
|
|
aca837cb16 | ||
|
|
1216e278e8 | ||
|
|
db2d582295 | ||
|
|
505f7712d6 | ||
|
|
6ae13265fa | ||
|
|
420a0d05dc | ||
|
|
152e42fd30 | ||
|
|
39d9b4c031 | ||
|
|
bc25896ed8 | ||
|
|
7d1522cc1e | ||
|
|
d20988afa1 | ||
|
|
5a52ac005b | ||
|
|
0bc6a89706 | ||
|
|
daba8e8178 | ||
|
|
833f261392 | ||
|
|
59be7cb55f | ||
|
|
5061735da7 | ||
|
|
0679971cc0 | ||
|
|
98a2549a3d | ||
|
|
8213ca62e0 | ||
|
|
8a6d71d958 | ||
|
|
d4894c362e | ||
|
|
0645203d97 | ||
|
|
f3902f8c7b | ||
|
|
757eb835e9 | ||
|
|
cf5684cf26 | ||
|
|
7f48c990e7 | ||
|
|
23d091f7ce | ||
|
|
b5b8831073 | ||
|
|
42ff591e48 | ||
|
|
0e054e7cba | ||
|
|
896d22616d | ||
|
|
42144a578b | ||
|
|
fea19eeb2e | ||
|
|
be881f18cd | ||
|
|
407eb5e205 | ||
|
|
7c285ee14d | ||
|
|
f081941474 | ||
|
|
cc8a97ae79 | ||
|
|
f2c9814171 | ||
|
|
aae99c804a | ||
|
|
ff315af230 | ||
|
|
153aa061f0 | ||
|
|
ba56c98e35 | ||
|
|
da16a5d4c2 | ||
|
|
74b194b1f4 | ||
|
|
65abaa9c7b | ||
|
|
be3bced531 | ||
|
|
939815510c | ||
|
|
57b295ccf2 | ||
|
|
49a2552e96 | ||
|
|
b111a41f01 | ||
|
|
cea9452127 | ||
|
|
38c8ea24a9 | ||
|
|
f2b0de779b | ||
|
|
6dc51ab323 | ||
|
|
03f5965755 | ||
|
|
84bdb75d77 | ||
|
|
93b8609411 | ||
|
|
19c15f7e96 | ||
|
|
2cf1537a7e | ||
|
|
a8132e8d53 | ||
|
|
5f7c77a3d8 | ||
|
|
e95cabb304 | ||
|
|
106e34848a | ||
|
|
99b8c35f7b | ||
|
|
faf829437d | ||
|
|
d09f74e58a | ||
|
|
11c3931007 | ||
|
|
e1555e6fe7 | ||
|
|
a6c5f320e3 | ||
|
|
a93085ae1d | ||
|
|
e59c3aa625 | ||
|
|
de75310b61 | ||
|
|
556ef83ecf | ||
|
|
3004ec2350 | ||
|
|
d29603275a | ||
|
|
bc8220c8f9 | ||
|
|
75992dfe62 | ||
|
|
ae3d5813cf | ||
|
|
51cbb22eb0 | ||
|
|
b8b822e553 | ||
|
|
0ecb65de29 | ||
|
|
8a774fa05d | ||
|
|
98f8ec071c | ||
|
|
1b427cf225 | ||
|
|
70cd00cfa8 | ||
|
|
47ce60494a | ||
|
|
84f2312508 | ||
|
|
f5330b6134 | ||
|
|
e9f504aa0c | ||
|
|
6e7d16864a | ||
|
|
f341f58a73 | ||
|
|
e013362ac4 | ||
|
|
32538586dc | ||
|
|
925179211a | ||
|
|
65498e7bd7 | ||
|
|
de9dca5735 | ||
|
|
6ef3a854d9 | ||
|
|
d33a61e713 | ||
|
|
a0d56c044b | ||
|
|
731fc9847c | ||
|
|
2ec7e512e0 | ||
|
|
c764cc044d | ||
|
|
7ba0eb82f1 | ||
|
|
afeffdbd13 | ||
|
|
b7f8f6689e | ||
|
|
edbe6e45b2 | ||
|
|
641e712272 | ||
|
|
99f14ba114 | ||
|
|
70a669a29c | ||
|
|
cfa0ab4ac9 | ||
|
|
279ccf078f | ||
|
|
8f9472b221 | ||
|
|
924ff9b2d4 | ||
|
|
00fd751c2a | ||
|
|
0359ba13c4 | ||
|
|
c3271ba264 | ||
|
|
527137f279 | ||
|
|
83275be536 | ||
|
|
98f3ab23d8 | ||
|
|
36d70fb612 | ||
|
|
d301d4da09 | ||
|
|
d8cb1d475b | ||
|
|
c61f519a34 | ||
|
|
a1e02d0d78 | ||
|
|
2e98859153 | ||
|
|
5565bf00de | ||
|
|
a6159b9d55 | ||
|
|
9d8c2e81e9 | ||
|
|
b17b2be683 | ||
|
|
3d1cc52d14 | ||
|
|
ddaaa38fce | ||
|
|
efe202ea27 | ||
|
|
4f2c98390c | ||
|
|
c4da7e1484 | ||
|
|
20d1a5f39e | ||
|
|
ffcf01e3cd | ||
|
|
e9e33432c2 | ||
|
|
9f262c12d6 | ||
|
|
876b0dcde8 | ||
|
|
5a7b3d0f25 | ||
|
|
80c9b10c36 | ||
|
|
f0c69cfb33 | ||
|
|
b3bace4d53 | ||
|
|
dae37d47a3 | ||
|
|
8da4b07642 | ||
|
|
d2f5aaf114 | ||
|
|
11369649c0 | ||
|
|
c6c71bebb7 | ||
|
|
1163dacbd6 | ||
|
|
deb532c970 | ||
|
|
3f90625429 | ||
|
|
4902f078a8 | ||
|
|
19823d8eca | ||
|
|
46f41199ac | ||
|
|
e229c30a9f | ||
|
|
43544a44da | ||
|
|
06e97bbf0a | ||
|
|
43b41b793f | ||
|
|
56e32e2c20 | ||
|
|
71d4c82573 | ||
|
|
5c02477c52 | ||
|
|
8f09ea4c60 | ||
|
|
3e94a9d491 | ||
|
|
88ad7178bf | ||
|
|
431bc9c715 | ||
|
|
0eee4a1f20 | ||
|
|
32cb0ea733 | ||
|
|
4c22b0edcc | ||
|
|
f8196f72f9 | ||
|
|
908fdcaa79 | ||
|
|
7f4e39e08b | ||
|
|
5efd832e64 | ||
|
|
a319d1e628 | ||
|
|
29b98fd1d1 | ||
|
|
f4af0e2407 | ||
|
|
268ced27ef | ||
|
|
1d55570abd | ||
|
|
d2767b0862 | ||
|
|
e6a4800bd1 | ||
|
|
381094c12d | ||
|
|
f904ad33ba | ||
|
|
ade9bd08fa | ||
|
|
f87bcbd0da | ||
|
|
3a37790315 | ||
|
|
0706541546 | ||
|
|
5b658984a5 | ||
|
|
f5a0f52b93 | ||
|
|
fbe0e35587 | ||
|
|
fbc0c2c586 | ||
|
|
a87a474a62 | ||
|
|
ddaa7269ba | ||
|
|
241ad8232d | ||
|
|
75043bae15 | ||
|
|
fd59d9ebae | ||
|
|
fc98c95892 | ||
|
|
f5605e6814 | ||
|
|
14851fa93e | ||
|
|
fd38161769 | ||
|
|
eab61b38f1 | ||
|
|
673b7d0bae | ||
|
|
c28628ebb3 | ||
|
|
7a591a024e | ||
|
|
a603b602e6 | ||
|
|
b4b8f51a5a | ||
|
|
36f7299a77 | ||
|
|
dfe678ffae | ||
|
|
820591dddc | ||
|
|
398da5fc3f | ||
|
|
e2362604c7 | ||
|
|
24288c95b5 | ||
|
|
1365987a1c | ||
|
|
0a82cdc59e | ||
|
|
606c7e290c | ||
|
|
ec62906221 | ||
|
|
d4e1c0d95d | ||
|
|
2db4f25ba6 | ||
|
|
b34166de93 | ||
|
|
b1216a43f2 | ||
|
|
a6eb826b04 | ||
|
|
517f0c631e | ||
|
|
119f9ea97b | ||
|
|
060b3980ba | ||
|
|
11460a83ad | ||
|
|
6fdc8b2b9a | ||
|
|
075a23124b | ||
|
|
2b5b82b465 | ||
|
|
29aa43f4ce | ||
|
|
023b80f411 | ||
|
|
fb31375b74 | ||
|
|
093337dd4f | ||
|
|
5fd6a4e43d | ||
|
|
25d087a54b | ||
|
|
6b83336fa3 | ||
|
|
6c3fcf699e | ||
|
|
e502a2d8c8 | ||
|
|
5e87f85851 | ||
|
|
eb976250a4 | ||
|
|
b83d76abf6 | ||
|
|
0d4d894fd4 | ||
|
|
7f6aeeb859 | ||
|
|
ff43b19122 | ||
|
|
29d7b09677 | ||
|
|
7846a03bcf | ||
|
|
4f070c9b65 | ||
|
|
9ad0f88ff2 | ||
|
|
da2520e60e | ||
|
|
980f4c8021 | ||
|
|
8e5d68144c | ||
|
|
c6c92e716f | ||
|
|
c4910fb7f9 | ||
|
|
303928f960 | ||
|
|
9566387273 | ||
|
|
ff14e5a5d3 | ||
|
|
14d3a243a2 | ||
|
|
67bee695e6 | ||
|
|
3f9ec0bc80 | ||
|
|
e07337340d | ||
|
|
a17b9a739e | ||
|
|
6859ab5775 | ||
|
|
4713d0f93f | ||
|
|
9f0eab03f2 | ||
|
|
77cab0962d | ||
|
|
6205718f0d | ||
|
|
2cde8d2dd9 | ||
|
|
8fedd1a07d | ||
|
|
b979daa39a | ||
|
|
4ce5dfeae3 | ||
|
|
5b03d93ef8 | ||
|
|
692db9a334 | ||
|
|
5bdb8360ea | ||
|
|
e48f57a3d8 | ||
|
|
26dfd14aaf | ||
|
|
20629b1712 | ||
|
|
f71c8a50d3 | ||
|
|
52e29e2dee | ||
|
|
3c3814a3c1 | ||
|
|
5fcbcd0f07 | ||
|
|
a9629b825b | ||
|
|
2acd281c76 | ||
|
|
f0f9c78cc6 | ||
|
|
4b51985149 | ||
|
|
c7423d7421 | ||
|
|
f9c9a7d527 | ||
|
|
47c88dd7dd | ||
|
|
19c14ef3fc | ||
|
|
a1fc86761d | ||
|
|
04cd140f6d | ||
|
|
6b17b91235 | ||
|
|
0d278e4fa9 | ||
|
|
fc06b35c09 | ||
|
|
68f16f9101 | ||
|
|
a621ff7271 | ||
|
|
06c30b8af2 | ||
|
|
7b05a34cce | ||
|
|
592f7c0ac2 |
18
.deepsource.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
version = 1
|
||||
|
||||
test_patterns = ["**/*.test.ts"]
|
||||
|
||||
[[analyzers]]
|
||||
name = "shell"
|
||||
|
||||
[[analyzers]]
|
||||
name = "javascript"
|
||||
|
||||
[analyzers.meta]
|
||||
environment = ["nodejs"]
|
||||
|
||||
[[analyzers]]
|
||||
name = "docker"
|
||||
|
||||
[analyzers.meta]
|
||||
dockerfile_paths = ["Dockerfile"]
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
# Bun doesn't run well on Musl but this seems to work
|
||||
FROM oven/bun:1.1.7-alpine as base
|
||||
|
||||
RUN apk add --no-cache libstdc++ git bash curl openssh cloc
|
||||
|
||||
# Switch to Bash by editing /etc/passwd
|
||||
RUN sed -i -e 's|/bin/ash|/bin/bash|g' /etc/passwd
|
||||
|
||||
# Extract Node from its docker image (node:22-alpine)
|
||||
COPY --from=node:22-alpine /usr/local/bin/node /usr/local/bin/node
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"name": "Lysand Dev Container",
|
||||
"dockerFile": "Dockerfile",
|
||||
"runArgs": [
|
||||
"-v",
|
||||
"${localWorkspaceFolder}/config:/workspace/config",
|
||||
"-v",
|
||||
"${localWorkspaceFolder}/logs:/workspace/logs",
|
||||
"-v",
|
||||
"${localWorkspaceFolder}/uploads:/workspace/uploads",
|
||||
"--network=host"
|
||||
],
|
||||
"mounts": [
|
||||
"source=node_modules,target=/workspace/node_modules,type=bind,consistency=cached",
|
||||
"type=bind,source=/home/${localEnv:USER}/.ssh,target=/root/.ssh,readonly"
|
||||
],
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"terminal.integrated.shell.linux": "/bin/bash"
|
||||
},
|
||||
"extensions": [
|
||||
"biomejs.biome",
|
||||
"ms-vscode-remote.remote-containers",
|
||||
"oven.bun-vscode",
|
||||
"vivaxy.vscode-conventional-commits",
|
||||
"EditorConfig.EditorConfig",
|
||||
"tamasfe.even-better-toml",
|
||||
"YoavBls.pretty-ts-errors",
|
||||
"ms-vscode-remote.remote-containers"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
9
.editorconfig
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
tab_width = 4
|
||||
trim_trailing_whitespace = true
|
||||
39
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -1,39 +0,0 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Describe the bug
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
## Steps to reproduce
|
||||
|
||||
Steps to reproduce the behavior, such as a cURL command, HTTP request, situation or code repository
|
||||
|
||||
## Expected behavior
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
## Screenshots
|
||||
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
## Logs
|
||||
|
||||
Please upload logs onto a service like [Pastebin](https://pastebin.com/) or [Hastebin](https://hastebin.com/) and paste the link here. Don't paste the logs directly into the GitHub issue, as it just looks ugly and is hard to read.
|
||||
|
||||
## Environment
|
||||
|
||||
- OS: [e.g. Fedora 39]
|
||||
- Bun version
|
||||
- Postgres version
|
||||
- Lysand commit ID or version
|
||||
|
||||
## Additional context
|
||||
|
||||
Add any other context about the problem here.
|
||||
28
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Is your feature request related to a problem? Please describe.
|
||||
|
||||
A clear and concise description of what the problem is, such as "I'm always frustrated when [...]" or "I can't do [...]"
|
||||
|
||||
## Describe the solution you'd like
|
||||
|
||||
What would you like to see implemented?
|
||||
|
||||
## Describe alternatives you've considered
|
||||
|
||||
If applicable, describe any alternative solutions or features you've considered.
|
||||
|
||||
## Additional context
|
||||
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
||||
## Are you willing to work on this feature?
|
||||
|
||||
If you are willing to work on this feature, please say so here.
|
||||
576
.github/config.workflow.toml
vendored
|
|
@ -1,117 +1,171 @@
|
|||
[database]
|
||||
# You can change the URL to the commit/tag you are using
|
||||
#:schema https://raw.githubusercontent.com/versia-pub/server/main/config/config.schema.json
|
||||
|
||||
# All values marked as "sensitive" can be set to "PATH:/path/to/file" to read the value from a file (e.g. a secret manager)
|
||||
|
||||
|
||||
[postgres]
|
||||
# PostgreSQL database configuration
|
||||
host = "localhost"
|
||||
port = 5432
|
||||
username = "lysand"
|
||||
password = "lysand"
|
||||
database = "lysand"
|
||||
username = "versia"
|
||||
# Sensitive value
|
||||
password = "versia"
|
||||
database = "versia"
|
||||
|
||||
# Additional read-only replicas
|
||||
# [[postgres.replicas]]
|
||||
# host = "other-host"
|
||||
# port = 5432
|
||||
# username = "versia"
|
||||
# password = "mycoolpassword2"
|
||||
# database = "replica1"
|
||||
|
||||
[redis.queue]
|
||||
# A Redis database used for managing queues.
|
||||
# Required for federation
|
||||
host = "localhost"
|
||||
port = 6379
|
||||
password = ""
|
||||
# Sensitive value
|
||||
# password = "test"
|
||||
database = 0
|
||||
|
||||
# A Redis database used for caching SQL queries.
|
||||
# Optional, can be the same as the queue instance
|
||||
# [redis.cache]
|
||||
# host = "localhost"
|
||||
# port = 6380
|
||||
# database = 1
|
||||
# password = ""
|
||||
|
||||
# Search and indexing configuration
|
||||
[search]
|
||||
# Enable indexing and searching?
|
||||
enabled = false
|
||||
|
||||
[redis.cache]
|
||||
host = "localhost"
|
||||
port = 6379
|
||||
password = ""
|
||||
database = 1
|
||||
enabled = false
|
||||
|
||||
[meilisearch]
|
||||
# Optional if search is disabled
|
||||
[search.sonic]
|
||||
host = "localhost"
|
||||
port = 40007
|
||||
api_key = ""
|
||||
enabled = false
|
||||
# Sensitive value
|
||||
password = ""
|
||||
|
||||
[signups]
|
||||
# URL of your Terms of Service
|
||||
tos_url = "https://example.com/tos"
|
||||
# Whether to enable registrations or not
|
||||
registration = true
|
||||
rules = [
|
||||
"Do not harass others",
|
||||
"Be nice to people",
|
||||
"Don't spam",
|
||||
"Don't post illegal content",
|
||||
]
|
||||
|
||||
[oidc]
|
||||
# Run Lysand with this value missing to generate a new key
|
||||
jwt_key = "MC4CAQAwBQYDK2VwBCIEID+H5n9PY3zVKZQcq4jrnE1IiRd2EWWr8ApuHUXmuOzl;MCowBQYDK2VwAyEAzenliNkgpXYsh3gXTnAoUWzlCPjIOppmAVx2DBlLsC8="
|
||||
[registration]
|
||||
# Can users sign up freely?
|
||||
allow = true
|
||||
# NOT IMPLEMENTED
|
||||
require_approval = false
|
||||
# Message to show to users when registration is disabled
|
||||
# message = "ran out of spoons to moderate registrations, sorry"
|
||||
|
||||
[http]
|
||||
# URL that the instance will be accessible at
|
||||
base_url = "http://0.0.0.0:8080"
|
||||
# Address to bind to (0.0.0.0 is suggested for proxies)
|
||||
bind = "0.0.0.0"
|
||||
bind_port = "8080"
|
||||
bind_port = 8080
|
||||
|
||||
# Bans IPv4 or IPv6 IPs (wildcards, networks and ranges are supported)
|
||||
banned_ips = []
|
||||
# Banned user agents, regex format
|
||||
banned_user_agents = [
|
||||
# "curl\/7.68.0",
|
||||
# "wget\/1.20.3",
|
||||
]
|
||||
|
||||
[smtp]
|
||||
# URL to an eventual HTTP proxy
|
||||
# Will be used for all outgoing requests
|
||||
# proxy_address = "http://localhost:8118"
|
||||
|
||||
# TLS configuration. You should probably be using a reverse proxy instead of this
|
||||
# [http.tls]
|
||||
# key = "/path/to/key.pem"
|
||||
# cert = "/path/to/cert.pem"
|
||||
# Sensitive value
|
||||
# passphrase = "awawa"
|
||||
# ca = "/path/to/ca.pem"
|
||||
|
||||
[frontend]
|
||||
# Enable custom frontends (warning: not enabling this will make Versia Server only accessible via the Mastodon API)
|
||||
# Frontends also control the OpenID flow, so if you disable this, you will need to use the Mastodon frontend
|
||||
enabled = true
|
||||
# Path that frontend files are served from
|
||||
# Edit this property to serve custom frontends
|
||||
# If this is not set, Versia Server will also check
|
||||
# the VERSIA_FRONTEND_PATH environment variable
|
||||
# path = ""
|
||||
|
||||
[frontend.routes]
|
||||
# Special routes for your frontend, below are the defaults for Versia-FE
|
||||
# Can be set to a route already used by Versia Server, as long as it is on a different HTTP method
|
||||
# e.g. /oauth/authorize is a POST-only route, so you can serve a GET route at /oauth/authorize
|
||||
# home = "/"
|
||||
# login = "/oauth/authorize"
|
||||
# consent = "/oauth/consent"
|
||||
# register = "/register"
|
||||
# password_reset = "/oauth/reset"
|
||||
|
||||
[frontend.settings]
|
||||
# Arbitrary key/value pairs to be passed to the frontend
|
||||
# This can be used to set up custom themes, etc on supported frontends.
|
||||
# theme = "dark"
|
||||
|
||||
# NOT IMPLEMENTED
|
||||
[email]
|
||||
# Enable email sending
|
||||
send_emails = false
|
||||
|
||||
# If send_emails is true, the following settings are required
|
||||
# [email.smtp]
|
||||
# SMTP server to use for sending emails
|
||||
server = "smtp.example.com"
|
||||
port = 465
|
||||
username = "test@example.com"
|
||||
password = "password123"
|
||||
tls = true
|
||||
# server = "smtp.example.com"
|
||||
# port = 465
|
||||
# username = "test@example.com"
|
||||
# Sensitive value
|
||||
# password = "password123"
|
||||
# tls = true
|
||||
|
||||
[media]
|
||||
# Can be "s3" or "local", where "local" uploads the file to the local filesystem
|
||||
# If you need to change this value after setting up your instance, you must move all the files
|
||||
# from one backend to the other manually
|
||||
# Changing this value will not retroactively apply to existing data
|
||||
# Don't forget to fill in the s3 config :3
|
||||
backend = "local"
|
||||
# Whether to check the hash of media when uploading to avoid duplication
|
||||
deduplicate_media = true
|
||||
# If media backend is "local", this is the folder where the files will be stored
|
||||
local_uploads_folder = "uploads"
|
||||
# Can be any path
|
||||
uploads_path = "uploads"
|
||||
|
||||
[media.conversion]
|
||||
# Whether to automatically convert images to another format on upload
|
||||
convert_images = false
|
||||
# Can be: "jxl", "webp", "avif", "png", "jpg", "heif"
|
||||
# Can be: "image/jxl", "image/webp", "image/avif", "image/png", "image/jpeg", "image/heif", "image/gif"
|
||||
# JXL support will likely not work
|
||||
convert_to = "webp"
|
||||
convert_to = "image/webp"
|
||||
# Also convert SVG images?
|
||||
convert_vectors = false
|
||||
|
||||
[s3]
|
||||
# Can be left blank if you don't use the S3 media backend
|
||||
endpoint = "https://s3-us-west-2.amazonaws.com"
|
||||
access_key = ""
|
||||
secret_access_key = ""
|
||||
region = "us-west-2"
|
||||
bucket_name = "lysand"
|
||||
public_url = "https://cdn.example.com"
|
||||
|
||||
[email]
|
||||
# Sends an email to moderators when a report is received
|
||||
# NOT IMPLEMENTED
|
||||
send_on_report = false
|
||||
# Sends an email to moderators when a user is suspended
|
||||
# NOT IMPLEMENTED
|
||||
send_on_suspend = false
|
||||
# Sends an email to moderators when a user is unsuspended
|
||||
# NOT IMPLEMENTED
|
||||
send_on_unsuspend = false
|
||||
# [s3]
|
||||
# Can be left commented if you don't use the S3 media backend
|
||||
# endpoint = "https://s3.example.com"
|
||||
# Sensitive value
|
||||
# access_key = "XXXXX"
|
||||
# Sensitive value
|
||||
# secret_access_key = "XXX"
|
||||
# region = "us-east-1"
|
||||
# bucket_name = "versia"
|
||||
# public_url = "https://cdn.example.com"
|
||||
|
||||
[validation]
|
||||
# Self explanatory
|
||||
max_displayname_size = 50
|
||||
max_bio_size = 160
|
||||
max_note_size = 5000
|
||||
max_avatar_size = 5_000_000
|
||||
max_header_size = 5_000_000
|
||||
max_media_size = 40_000_000
|
||||
max_media_attachments = 10
|
||||
max_media_description_size = 1000
|
||||
max_poll_options = 20
|
||||
max_poll_option_size = 500
|
||||
min_poll_duration = 60
|
||||
max_poll_duration = 1893456000
|
||||
max_username_size = 30
|
||||
# An array of strings, defaults are from Akkoma
|
||||
username_blacklist = [
|
||||
".well-known",
|
||||
"~",
|
||||
# Checks user data
|
||||
# Does not retroactively apply to previously entered data
|
||||
[validation.accounts]
|
||||
max_displayname_characters = 50
|
||||
max_username_characters = 30
|
||||
max_bio_characters = 5000
|
||||
max_avatar_bytes = 5_000_000
|
||||
max_header_bytes = 5_000_000
|
||||
# Regex is allowed here
|
||||
disallowed_usernames = [
|
||||
"well-known",
|
||||
"about",
|
||||
"activities",
|
||||
"api",
|
||||
|
|
@ -137,12 +191,14 @@ username_blacklist = [
|
|||
"search",
|
||||
"mfa",
|
||||
]
|
||||
# Whether to blacklist known temporary email providers
|
||||
blacklist_tempmail = false
|
||||
# Additional email providers to blacklist
|
||||
email_blacklist = []
|
||||
# Valid URL schemes, otherwise the URL is parsed as text
|
||||
url_scheme_whitelist = [
|
||||
max_field_count = 10
|
||||
max_field_name_characters = 1000
|
||||
max_field_value_characters = 1000
|
||||
max_pinned_notes = 20
|
||||
|
||||
[validation.notes]
|
||||
max_characters = 5000
|
||||
allowed_url_schemes = [
|
||||
"http",
|
||||
"https",
|
||||
"ftp",
|
||||
|
|
@ -160,123 +216,259 @@ url_scheme_whitelist = [
|
|||
"mumble",
|
||||
"ssb",
|
||||
"gemini",
|
||||
] # NOT IMPLEMENTED
|
||||
|
||||
enforce_mime_types = false
|
||||
allowed_mime_types = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"video/webm",
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/ogg",
|
||||
"audio/wave",
|
||||
"audio/wav",
|
||||
"audio/x-wav",
|
||||
"audio/x-pn-wave",
|
||||
"audio/vnd.wave",
|
||||
"audio/ogg",
|
||||
"audio/vorbis",
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
"audio/webm",
|
||||
"audio/flac",
|
||||
"audio/aac",
|
||||
"audio/m4a",
|
||||
"audio/x-m4a",
|
||||
"audio/mp4",
|
||||
"audio/3gpp",
|
||||
"video/x-ms-asf",
|
||||
]
|
||||
max_attachments = 16
|
||||
|
||||
[defaults]
|
||||
# Default visibility for new notes
|
||||
visibility = "public"
|
||||
# Default language for new notes
|
||||
language = "en"
|
||||
# Default avatar, must be a valid URL or ""
|
||||
avatar = ""
|
||||
# Default header, must be a valid URL or ""
|
||||
header = ""
|
||||
[validation.media]
|
||||
max_bytes = 40_000_000
|
||||
max_description_characters = 1000
|
||||
# An empty array allows all MIME types
|
||||
allowed_mime_types = []
|
||||
|
||||
[activitypub]
|
||||
# Use ActivityPub Tombstones instead of deleting objects
|
||||
use_tombstones = true
|
||||
# Fetch all members of collections (followers, following, etc) when receiving them
|
||||
# WARNING: This can be a lot of data, and is not recommended
|
||||
fetch_all_collection_members = false # NOT IMPLEMENTED
|
||||
[validation.emojis]
|
||||
max_bytes = 1_000_000
|
||||
max_shortcode_characters = 100
|
||||
max_description_characters = 1000
|
||||
|
||||
# The following values must be instance domain names without "https" or glob patterns
|
||||
# Rejects all activities from these instances (fediblocking)
|
||||
reject_activities = []
|
||||
# Force posts from this instance to be followers only
|
||||
force_followers_only = [] # NOT IMPLEMENTED
|
||||
# Discard all reports from these instances
|
||||
discard_reports = [] # NOT IMPLEMENTED
|
||||
# Discard all deletes from these instances
|
||||
discard_deletes = []
|
||||
# Discard all updates (edits) from these instances
|
||||
discard_updates = []
|
||||
# Discard all banners from these instances
|
||||
discard_banners = [] # NOT IMPLEMENTED
|
||||
# Discard all avatars from these instances
|
||||
discard_avatars = [] # NOT IMPLEMENTED
|
||||
# Discard all follow requests from these instances
|
||||
discard_follows = []
|
||||
# Force set these instances' media as sensitive
|
||||
force_sensitive = [] # NOT IMPLEMENTED
|
||||
# Remove theses instances' media
|
||||
remove_media = [] # NOT IMPLEMENTED
|
||||
[validation.polls]
|
||||
max_options = 20
|
||||
max_option_characters = 500
|
||||
min_duration_seconds = 60
|
||||
# 100 days
|
||||
max_duration_seconds = 8_640_000
|
||||
|
||||
# Whether to verify HTTP signatures for every request (warning: can slow down your server
|
||||
# significantly depending on processing power)
|
||||
authorized_fetch = false
|
||||
[validation.emails]
|
||||
# Blocks over 10,000 common tempmail domains
|
||||
disallow_tempmail = false
|
||||
# Regex is allowed here
|
||||
disallowed_domains = []
|
||||
|
||||
[instance]
|
||||
name = "Lysand"
|
||||
description = "A test instance of Lysand"
|
||||
# URL to your instance logo (jpg files should be renamed to jpeg)
|
||||
logo = ""
|
||||
# URL to your instance banner (jpg files should be renamed to jpeg)
|
||||
banner = ""
|
||||
[validation.challenges]
|
||||
# "Challenges" (aka captchas) are a way to verify that a user is human
|
||||
# Versia Server's challenges use no external services, and are proof-of-work based
|
||||
# This means that they do not require any user interaction, instead
|
||||
# they require the user's computer to do a small amount of work
|
||||
# The difficulty of the challenge, higher is will take more time to solve
|
||||
difficulty = 50000
|
||||
# Challenge expiration time in seconds
|
||||
expiration = 300 # 5 minutes
|
||||
# Leave this empty to generate a new key
|
||||
# Sensitive value
|
||||
key = "YBpAV0KZOeM/MZ4kOb2E9moH9gCUr00Co9V7ncGRJ3wbd/a9tLDKKFdI0BtOcnlpfx0ZBh0+w3WSvsl0TsesTg=="
|
||||
|
||||
|
||||
[filters]
|
||||
# Drop notes with these regex filters (only applies to new activities)
|
||||
note_filters = [
|
||||
# Block content that matches these regular expressions
|
||||
[validation.filters]
|
||||
note_content = [
|
||||
# "(https?://)?(www\\.)?youtube\\.com/watch\\?v=[a-zA-Z0-9_-]+",
|
||||
# "(https?://)?(www\\.)?youtu\\.be/[a-zA-Z0-9_-]+",
|
||||
]
|
||||
# Drop users with these regex filters (only applies to new activities)
|
||||
username_filters = []
|
||||
# Drop users with these regex filters (only applies to new activities)
|
||||
displayname_filters = []
|
||||
# Drop users with these regex filters (only applies to new activities)
|
||||
bio_filters = []
|
||||
emoji_filters = [] # NOT IMPLEMENTED
|
||||
emoji_shortcode = []
|
||||
username = []
|
||||
displayname = []
|
||||
bio = []
|
||||
|
||||
[notifications]
|
||||
|
||||
# Web Push Notifications configuration.
|
||||
# Leave out to disable.
|
||||
[notifications.push]
|
||||
# Subject field embedded in the push notification
|
||||
# subject = "mailto:joe@example.com"
|
||||
#
|
||||
[notifications.push.vapid_keys]
|
||||
# VAPID keys for push notifications
|
||||
# Run Versia Server with those values missing to generate new keys
|
||||
# Sensitive value
|
||||
public = "BBanhyj2_xWwbTsWld3T49VcAoKZHrVJTzF1f6Av2JwQY_wUi3CF9vZ0WeEcACRj6EEqQ7N35CkUh5epF7n4P_s"
|
||||
# Sensitive value
|
||||
private = "Eujaz7NsF0rKZOVrAFL7mMpFdl96f591ERsRn81unq0"
|
||||
|
||||
[defaults]
|
||||
# Default visibility for new notes
|
||||
# Can be public, unlisted, private or direct
|
||||
# Private only sends to followers, unlisted doesn't show up in timelines
|
||||
visibility = "public"
|
||||
# Default language for new notes (ISO code)
|
||||
language = "en"
|
||||
# Default avatar, must be a valid URL or left out for a placeholder avatar
|
||||
# avatar = ""
|
||||
# Default header, must be a valid URL or left out for none
|
||||
# header = ""
|
||||
# A style name from https://www.dicebear.com/styles
|
||||
placeholder_style = "thumbs"
|
||||
|
||||
[queues]
|
||||
# Controls the delivery queue (for outbound federation)
|
||||
[queues.delivery]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
|
||||
# Controls the inbox processing queue (for inbound federation)
|
||||
[queues.inbox]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
|
||||
# Controls the fetch queue (for remote data refreshes)
|
||||
[queues.fetch]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
|
||||
# Controls the push queue (for push notification delivery)
|
||||
[queues.push]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
|
||||
# Controls the media queue (for media processing)
|
||||
[queues.media]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
|
||||
[federation]
|
||||
# This is a list of domain names, such as "mastodon.social" or "pleroma.site"
|
||||
# These changes will not retroactively apply to existing data before they were changed
|
||||
# For that, please use the CLI (in a later release)
|
||||
|
||||
# These instances will not be federated with
|
||||
blocked = []
|
||||
# These instances' data will only be shown to followers, not in public timelines
|
||||
followers_only = []
|
||||
|
||||
[federation.discard]
|
||||
# These objects will be discarded when received from these instances
|
||||
reports = []
|
||||
deletes = []
|
||||
updates = []
|
||||
media = []
|
||||
follows = []
|
||||
# If instance reactions are blocked, likes will also be discarded
|
||||
likes = []
|
||||
reactions = []
|
||||
banners = []
|
||||
avatars = []
|
||||
|
||||
# For bridge software, such as versia-pub/activitypub
|
||||
# Bridges must be hosted separately from the main Versia Server process
|
||||
# [federation.bridge]
|
||||
# Only versia-ap exists for now
|
||||
# software = "versia-ap"
|
||||
# If this is empty, any bridge with the correct token
|
||||
# will be able to send data to your instance
|
||||
# v4, v6, ranges and wildcards are supported
|
||||
# allowed_ips = ["192.168.1.0/24"]
|
||||
# Token for the bridge software
|
||||
# Bridge must have the same token!
|
||||
# Sensitive value
|
||||
# token = "mycooltoken"
|
||||
# url = "https://ap.versia.social"
|
||||
|
||||
[instance]
|
||||
name = "Versia"
|
||||
description = "A Versia Server instance"
|
||||
|
||||
# Paths to instance long description, terms of service, and privacy policy
|
||||
# These will be parsed as Markdown
|
||||
#
|
||||
# extended_description_path = "config/extended_description.md"
|
||||
# tos_path = "config/tos.md"
|
||||
# privacy_policy_path = "config/privacy_policy.md"
|
||||
|
||||
# Primary instance languages. ISO 639-1 codes.
|
||||
languages = ["en"]
|
||||
|
||||
[instance.contact]
|
||||
email = "staff@yourinstance.com"
|
||||
|
||||
[instance.branding]
|
||||
# logo = "https://cdn.example.com/logo.png"
|
||||
# banner = "https://cdn.example.com/banner.png"
|
||||
|
||||
# Used for federation. If left empty or missing, the server will generate one for you.
|
||||
[instance.keys]
|
||||
# Sensitive value
|
||||
public = "MCowBQYDK2VwAyEASN0V5OWRbhRCnuhxfRLqpUOfszHozvrLLVhlIYLNTZM="
|
||||
# Sensitive value
|
||||
private = "MC4CAQAwBQYDK2VwBCIEIKaxDGMaW71OcCGMY+GKTZPtLPNlTvMFe3G5qXVHPhQM"
|
||||
|
||||
[[instance.rules]]
|
||||
# Short description of the rule
|
||||
text = "No hate speech"
|
||||
# Longer version of the rule with additional information
|
||||
hint = "Hate speech includes slurs, threats, and harassment."
|
||||
|
||||
[[instance.rules]]
|
||||
text = "No spam"
|
||||
|
||||
# [[instance.rules]]
|
||||
# ...etc
|
||||
|
||||
[permissions]
|
||||
# Control default permissions for users
|
||||
# Note that an anonymous user having a permission will not allow them
|
||||
# to do things that require authentication (e.g. 'owner:notes' -> posting a note will need
|
||||
# auth, but viewing a note will not)
|
||||
# See https://server.versia.pub/api/roles#list-of-permissions for a list of all permissions
|
||||
|
||||
# Defaults to being able to login and manage their own content
|
||||
# anonymous = []
|
||||
|
||||
# Defaults to identical to anonymous
|
||||
# default = []
|
||||
|
||||
# Defaults to being able to manage all instance data, content, and users
|
||||
# admin = []
|
||||
|
||||
[logging]
|
||||
# Log all requests (warning: this is a lot of data)
|
||||
log_requests = true
|
||||
# Log request and their contents (warning: this is a lot of data)
|
||||
log_requests_verbose = false
|
||||
# For GDPR compliance, you can disable logging of IPs
|
||||
log_ip = false
|
||||
|
||||
# Log all filtered objects
|
||||
log_filters = true
|
||||
# Available levels: trace, debug, info, warning, error, fatal
|
||||
log_level = "info" # For console output
|
||||
|
||||
[ratelimits]
|
||||
# Amount to multiply every route's duration by
|
||||
duration_coeff = 1.0
|
||||
# Amount to multiply every route's max by
|
||||
max_coeff = 1.0
|
||||
# [logging.file]
|
||||
# path = "logs/versia.log"
|
||||
# log_level = "info"
|
||||
#
|
||||
# [logging.file.rotation]
|
||||
# max_size = 10_000_000 # 10 MB
|
||||
# max_files = 10 # Keep 10 rotated files
|
||||
#
|
||||
# https://sentry.io support
|
||||
# [logging.sentry]
|
||||
# dsn = "https://example.com"
|
||||
# debug = false
|
||||
# sample_rate = 1.0
|
||||
# traces_sample_rate = 1.0
|
||||
# Can also be regex
|
||||
# trace_propagation_targets = []
|
||||
# max_breadcrumbs = 100
|
||||
# environment = "production"
|
||||
# log_level = "info"
|
||||
|
||||
[custom_ratelimits]
|
||||
# Add in any API route in this style here
|
||||
"/api/v1/timelines/public" = { duration = 60, max = 200 }
|
||||
[authentication]
|
||||
# Run Versia Server with this value missing to generate a new key
|
||||
key = "ZWcwanRaQAqY3ChUro/Jey9XGQjzsxEed5iqTp4yFr8W6vEnXdz91F/Pu/uf7HBMbNeIK7V6aHsM0lq9onrO8Q=="
|
||||
|
||||
# The provider MUST support OpenID Connect with .well-known discovery
|
||||
# Most notably, GitHub does not support this
|
||||
# Redirect URLs in your OpenID provider can be set to this:
|
||||
# <base_url>/oauth/sso/<provider_id>/callback*
|
||||
# The asterisk is important, as it allows for any query parameters to be passed
|
||||
# Authentik for example uses regex so it can be set to (regex):
|
||||
# <base_url>/oauth/sso/<provider_id>/callback.*
|
||||
# [[authentication.openid_providers]]
|
||||
# name = "CPlusPatch ID"
|
||||
# id = "cpluspatch-id"
|
||||
# This MUST match the provider's issuer URI, including the trailing slash (or lack thereof)
|
||||
# url = "https://id.cpluspatch.com/application/o/versia-testing/"
|
||||
# client_id = "XXXX"
|
||||
# Sensitive value
|
||||
# client_secret = "XXXXX"
|
||||
# icon = "https://cpluspatch.com/images/icons/logo.svg"
|
||||
|
|
|
|||
22
.github/copilot-instructions.md
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
We use full TypeScript and ESM with Bun for our codebase. Please include relevant and detailed JSDoc comments for all functions and classes. Use explicit type annotations for all variables and function return values, such as:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Adds two numbers together.
|
||||
*
|
||||
* @param {number} a
|
||||
* @param {number} b
|
||||
* @returns {number}
|
||||
*/
|
||||
const add = (a: number, b: number): number => a + b;
|
||||
```
|
||||
|
||||
We always write TypeScript with double quotes and four spaces for indentation, so when your responses include TypeScript code, please follow those conventions.
|
||||
|
||||
Our codebase uses Drizzle as an ORM, which is exposed in the `@versia-server/kit/db` and `@versia-server/kit/tables` packages. This project uses a monorepo structure with Bun as the package manager.
|
||||
|
||||
The app has two modes: worker and API. The worker mode is used for background tasks, while the API mode serves HTTP requests. The entry point for the worker is `worker.ts`, and for the API, it is `api.ts`.
|
||||
|
||||
Run the typechecker with `bun run typecheck` to ensure that all TypeScript code is type-checked correctly. Run tests with `bun test` to ensure that all tests pass. Run the linter and formatter with `bun lint` to ensure that the code adheres to our style guidelines, and `bun lint --write` to automatically fix minor/formatting issues.
|
||||
|
||||
Cover all new functionality with tests, and ensure that all tests pass before submitting your code.
|
||||
27
.github/workflows/check.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
name: Check Types
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install NPM packages
|
||||
run: |
|
||||
bun install
|
||||
|
||||
- name: Run typechecks
|
||||
run: |
|
||||
bun run typecheck
|
||||
27
.github/workflows/circular-imports.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
name: Check Circular Imports
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install NPM packages
|
||||
run: |
|
||||
bun install
|
||||
|
||||
- name: Run typechecks
|
||||
run: |
|
||||
bun run detect-circular
|
||||
10
.github/workflows/codeql.yml
vendored
|
|
@ -9,7 +9,7 @@
|
|||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
name: "CodeQL Scan"
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -46,11 +46,11 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
|
|
@ -63,7 +63,7 @@ jobs:
|
|||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
|
@ -76,6 +76,6 @@ jobs:
|
|||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
|
|
|||
79
.github/workflows/docker-publish.yml
vendored
|
|
@ -1,79 +0,0 @@
|
|||
name: Docker
|
||||
|
||||
# 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" ]
|
||||
# Publish semver tags as releases.
|
||||
tags: [ 'v*.*.*' ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
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
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: all
|
||||
|
||||
# Set up BuildKit Docker container builder to be able to build
|
||||
# multi-platform images and export cache
|
||||
# https://github.com/docker/setup-buildx-action
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3 # 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 # 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@v5 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v5 # v5.0.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
98
.github/workflows/docker.yml
vendored
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
name: Build Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["*"]
|
||||
# Publish semver tags as releases.
|
||||
tags: ["v*.*.*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
|
||||
check:
|
||||
uses: ./.github/workflows/check.yml
|
||||
|
||||
tests:
|
||||
uses: ./.github/workflows/tests.yml
|
||||
|
||||
detect-circular:
|
||||
uses: ./.github/workflows/circular-imports.yml
|
||||
|
||||
build:
|
||||
if: ${{ success() }}
|
||||
needs: [lint, check, tests, detect-circular]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- container: worker
|
||||
image_name: ${{ github.repository_owner }}/worker
|
||||
dockerfile: Worker.Dockerfile
|
||||
- container: server
|
||||
image_name: ${{ github.repository_owner }}/server
|
||||
dockerfile: Dockerfile
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: all
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ matrix.image_name }}
|
||||
tags: |
|
||||
type=schedule
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
|
||||
- name: Get the commit hash
|
||||
run: echo "GIT_COMMIT=$(git rev-parse --short ${{ github.sha }})" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_COMMIT=${{ env.GIT_COMMIT }}
|
||||
file: ${{ matrix.dockerfile }}
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
56
.github/workflows/docs.yml
vendored
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
name: Deploy Docs to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
||||
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- name: Setup Pages
|
||||
|
||||
uses: actions/configure-pages@v4
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Build with VitePress
|
||||
run: bun run --filter="@versia-server/api" docs:build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: packages/api/docs/.vitepress/dist
|
||||
|
||||
# Deployment job
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
27
.github/workflows/lint.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
name: Lint & Format
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install NPM packages
|
||||
run: |
|
||||
bun install
|
||||
|
||||
- name: Run linting
|
||||
run: |
|
||||
bunx @biomejs/biome ci .
|
||||
8
.github/workflows/mirror.yml
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
name: Mirror to Codeberg
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
name: Mirror
|
||||
uses: versia-pub/.github/.github/workflows/mirror.yml@main
|
||||
secrets: inherit
|
||||
25
.github/workflows/nix-flake.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
name: Nix Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: ["*"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: "write"
|
||||
contents: "read"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
extra-conf: accept-flake-config = true
|
||||
- uses: DeterminateSystems/magic-nix-cache-action@main
|
||||
- uses: DeterminateSystems/flake-checker-action@main
|
||||
- name: Build default package
|
||||
run: nix build .
|
||||
- name: Check flakes
|
||||
run: nix flake check --allow-import-from-derivation
|
||||
48
.github/workflows/publish.yml
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
name: Build & Publish Packages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: "Package to publish"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- client
|
||||
- sdk
|
||||
tag:
|
||||
description: "NPM tag to use"
|
||||
required: true
|
||||
type: choice
|
||||
default: nightly
|
||||
options:
|
||||
- latest
|
||||
- nightly
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# For provenance generation
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
environment: NPM Deploy
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Publish to NPM
|
||||
working-directory: packages/${{ inputs.package }}
|
||||
run: bun publish --provenance --tag ${{ inputs.tag }} --access public
|
||||
env:
|
||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish to JSR
|
||||
working-directory: packages/${{ inputs.package }}
|
||||
run: bunx jsr publish --allow-slow-types --allow-dirty
|
||||
36
.github/workflows/test-publish.yml
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
name: Test Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# For provenance generation
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
# Build job
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
environment: NPM Deploy
|
||||
strategy:
|
||||
matrix:
|
||||
package: ["sdk", "client"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Publish to NPM
|
||||
working-directory: packages/${{ matrix.package }}
|
||||
env:
|
||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: bun publish --dry-run --access public
|
||||
|
||||
- name: Publish to JSR
|
||||
working-directory: packages/${{ matrix.package }}
|
||||
run: bunx jsr publish --allow-slow-types --allow-dirty --dry-run
|
||||
32
.github/workflows/tests.yml
vendored
|
|
@ -1,40 +1,44 @@
|
|||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["*"]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: ["main"]
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: ghcr.io/lysand-org/postgres:main
|
||||
image: postgres:17-alpine
|
||||
ports:
|
||||
- 5432:5432
|
||||
env:
|
||||
POSTGRES_DB: lysand
|
||||
POSTGRES_USER: lysand
|
||||
POSTGRES_PASSWORD: lysand
|
||||
POSTGRES_DB: versia
|
||||
POSTGRES_USER: versia
|
||||
POSTGRES_PASSWORD: versia
|
||||
volumes:
|
||||
- lysand-data:/var/lib/postgresql/data
|
||||
- versia-data:/var/lib/postgresql/data
|
||||
options: --health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:latest
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Install NPM packages
|
||||
run: |
|
||||
|
|
@ -46,4 +50,4 @@ jobs:
|
|||
|
||||
- name: Run tests
|
||||
run: |
|
||||
bun test
|
||||
bun run test
|
||||
|
|
|
|||
18
.gitignore
vendored
|
|
@ -117,6 +117,10 @@ out
|
|||
.nuxt
|
||||
dist
|
||||
|
||||
# Nix build output
|
||||
|
||||
result
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
|
@ -175,8 +179,12 @@ log.txt
|
|||
*.log
|
||||
build
|
||||
config/extended_description_test.md
|
||||
glitch-old
|
||||
glitch
|
||||
glitch.tar.gz
|
||||
glitch-dev
|
||||
*.pem
|
||||
*.pem
|
||||
oclif.manifest.json
|
||||
.direnv/
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# Vitepress Docs
|
||||
|
||||
*/.vitepress/dist
|
||||
*/.vitepress/cache
|
||||
7
.madgerc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"detectiveOptions": {
|
||||
"ts": {
|
||||
"skipTypeImports": true
|
||||
}
|
||||
}
|
||||
}
|
||||
13
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"biomejs.biome",
|
||||
"ms-vscode-remote.remote-containers",
|
||||
"oven.bun-vscode",
|
||||
"vivaxy.vscode-conventional-commits",
|
||||
"EditorConfig.EditorConfig",
|
||||
"tamasfe.even-better-toml",
|
||||
"YoavBls.pretty-ts-errors",
|
||||
"eamodio.gitlens"
|
||||
],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
49
.vscode/launch.json
vendored
|
|
@ -1,15 +1,48 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"name": "vscode-jest-tests.v2.lysand",
|
||||
"request": "launch",
|
||||
"args": ["test", "${jest.testFile}"],
|
||||
"cwd": "/home/jessew/Dev/lysand",
|
||||
"console": "integratedTerminal",
|
||||
"type": "bun",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"disableOptimisticBPs": true,
|
||||
"program": "/home/jessew/.bun/bin/bun"
|
||||
"request": "launch",
|
||||
"name": "Debug File",
|
||||
"program": "${file}",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"stopOnEntry": false,
|
||||
"watchMode": false
|
||||
},
|
||||
{
|
||||
"type": "bun",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"request": "launch",
|
||||
"name": "Run File",
|
||||
"program": "${file}",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"watchMode": false
|
||||
},
|
||||
{
|
||||
"type": "bun",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"request": "attach",
|
||||
"name": "Attach Bun",
|
||||
"url": "ws://localhost:6499/",
|
||||
"stopOnEntry": false
|
||||
},
|
||||
{
|
||||
"type": "bun",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"request": "launch",
|
||||
"name": "Run index.ts",
|
||||
"program": "${workspaceFolder}/index.ts",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"watchMode": true
|
||||
},
|
||||
{
|
||||
"type": "bun",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"request": "launch",
|
||||
"name": "Run tests",
|
||||
"program": "test"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
14
.vscode/settings.json
vendored
|
|
@ -1,13 +1,15 @@
|
|||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"jest.jestCommandLine": "/home/jessew/.bun/bin/bun test",
|
||||
"jest.rootPath": ".",
|
||||
"conventionalCommits.scopes": [
|
||||
"database",
|
||||
"frontend",
|
||||
"build",
|
||||
"api",
|
||||
"cli",
|
||||
"federation"
|
||||
]
|
||||
"federation",
|
||||
"config",
|
||||
"worker",
|
||||
"media",
|
||||
"packages/client",
|
||||
"packages/sdk"
|
||||
],
|
||||
"languageToolLinter.languageTool.ignoredWordsInWorkspace": ["versia"]
|
||||
}
|
||||
|
|
|
|||
268
API.md
|
|
@ -1,268 +0,0 @@
|
|||
# API
|
||||
|
||||
The Lysand project uses the Mastodon API to interact with clients. However, the moderation API is custom-made for Lysand Server, as it allows for more fine-grained control over the server's behavior.
|
||||
|
||||
## Flags, ModTags and ModNotes
|
||||
|
||||
Flags are used by Lysand Server to automatically attribute tags to a status or account based on rules. ModTags and ModNotes are used by moderators to manually tag and take notes on statuses and accounts.
|
||||
|
||||
The difference between flags and modtags is that flags are automatically attributed by the server, while modtags are manually attributed by moderators.
|
||||
|
||||
### Flag Types
|
||||
|
||||
- `content_filter`: (Statuses only) The status contains content that was filtered by the server's content filter.
|
||||
- `bio_filter`: (Accounts only) The account's bio contains content that was filtered by the server's content filter.
|
||||
- `emoji_filter`: The status or account contains an emoji that was filtered by the server's content filter.
|
||||
- `reported`: The status or account was previously reported by a user.
|
||||
- `suspended`: The status or account was previously suspended by a moderator.
|
||||
- `silenced`: The status or account was previously silenced by a moderator.
|
||||
|
||||
### ModTag Types
|
||||
|
||||
ModTag do not have set types and can be anything. Lysand Server autosuggest previously used tags when a moderator is adding a new tag to avoid duplicates.
|
||||
|
||||
### Data Format
|
||||
|
||||
```ts
|
||||
type Flag = {
|
||||
id: string,
|
||||
// One of the following two fields will be present
|
||||
flaggedStatus?: Status,
|
||||
flaggedUser?: User,
|
||||
flagType: string,
|
||||
createdAt: string,
|
||||
}
|
||||
|
||||
type ModTag = {
|
||||
id: string,
|
||||
// One of the following two fields will be present
|
||||
taggedStatus?: Status,
|
||||
taggedUser?: User,
|
||||
mod: User,
|
||||
tag: string,
|
||||
createdAt: string,
|
||||
}
|
||||
|
||||
type ModNote = {
|
||||
id: string,
|
||||
// One of the following two fields will be present
|
||||
notedStatus?: Status,
|
||||
notedUser?: User,
|
||||
mod: User,
|
||||
note: string,
|
||||
createdAt: string,
|
||||
}
|
||||
```
|
||||
|
||||
The `User` and `Status` types are the same as the ones in the Mastodon API.
|
||||
|
||||
## Moderation API Routes
|
||||
|
||||
### `GET /api/v1/moderation/accounts/:id`
|
||||
|
||||
Returns full moderation data and flags for the account with the given ID.
|
||||
|
||||
Output format:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: string, // Same ID as in account field
|
||||
flags: Flag[],
|
||||
modtags: ModTag[],
|
||||
modnotes: ModNote[],
|
||||
account: User,
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/moderation/statuses/:id`
|
||||
|
||||
Returns full moderation data and flags for the status with the given ID.
|
||||
|
||||
Output format:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: string, // Same ID as in status field
|
||||
flags: Flag[],
|
||||
modtags: ModTag[],
|
||||
modnotes: ModNote[],
|
||||
status: Status,
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/v1/moderation/accounts/:id/modtags`
|
||||
|
||||
Params:
|
||||
- `tag`: string
|
||||
|
||||
Adds a modtag to the account with the given ID
|
||||
|
||||
### `POST /api/v1/moderation/statuses/:id/modtags`
|
||||
|
||||
Params:
|
||||
- `tag`: string
|
||||
|
||||
Adds a modtag to the status with the given ID
|
||||
|
||||
### `POST /api/v1/moderation/accounts/:id/modnotes`
|
||||
|
||||
Params:
|
||||
- `note`: string
|
||||
|
||||
Adds a modnote to the account with the given ID
|
||||
|
||||
### `POST /api/v1/moderation/statuses/:id/modnotes`
|
||||
|
||||
Params:
|
||||
- `note`: string
|
||||
|
||||
Adds a modnote to the status with the given ID
|
||||
|
||||
### `DELETE /api/v1/moderation/accounts/:id/modtags/:modtag_id`
|
||||
|
||||
Deletes the modtag with the given ID from the account with the given ID
|
||||
|
||||
### `DELETE /api/v1/moderation/statuses/:id/modtags/:modtag_id`
|
||||
|
||||
Deletes the modtag with the given ID from the status with the given ID
|
||||
|
||||
### `DELETE /api/v1/moderation/accounts/:id/modnotes/:modnote_id`
|
||||
|
||||
Deletes the modnote with the given ID from the account with the given ID
|
||||
|
||||
### `DELETE /api/v1/moderation/statuses/:id/modnotes/:modnote_id`
|
||||
|
||||
Deletes the modnote with the given ID from the status with the given ID
|
||||
|
||||
### `GET /api/v1/moderation/modtags`
|
||||
|
||||
Returns a list of all modtags previously used by moderators
|
||||
|
||||
Output format:
|
||||
|
||||
```ts
|
||||
{
|
||||
tags: string[],
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/moderation/accounts/flags/search`
|
||||
|
||||
Allows moderators to search for accounts based on their flags, this can also include status flags
|
||||
|
||||
Params:
|
||||
- `limit`: Number
|
||||
- `min_id`: String. Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.
|
||||
- `max_id`: String. All results returned will be lesser than this ID. In effect, sets an upper bound on results.
|
||||
- `since_id`: String. All results returned will be greater than this ID. In effect, sets a lower bound on results.
|
||||
- `flags`: String (optional). Comma-separated list of flag types to filter by. Can be left out to return accounts with at least one flag
|
||||
- `flag_count`: Number (optional). Minimum number of flags to filter by
|
||||
- `include_statuses`: Boolean (optional). If true, includes status flags in the search results
|
||||
- `account_id`: Array of strings (optional). Filters accounts by account ID
|
||||
|
||||
This method returns a `Link` header the same way Mastodon does, to allow for pagination.
|
||||
|
||||
Output format:
|
||||
|
||||
```ts
|
||||
{
|
||||
accounts: {
|
||||
account: User,
|
||||
modnotes: ModNote[],
|
||||
flags: Flag[],
|
||||
statuses?: {
|
||||
status: Status,
|
||||
modnotes: ModNote[],
|
||||
flags: Flag[],
|
||||
}[],
|
||||
}[],
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/moderation/statuses/flags/search`
|
||||
|
||||
Allows moderators to search for statuses based on their flags
|
||||
|
||||
Params:
|
||||
- `limit`: Number
|
||||
- `min_id`: String. Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.
|
||||
- `max_id`: String. All results returned will be lesser than this ID. In effect, sets an upper bound on results.
|
||||
- `since_id`: String. All results returned will be greater than this ID. In effect, sets a lower bound on results.
|
||||
- `flags`: String (optional). Comma-separated list of flag types to filter by. Can be left out to return statuses with at least one flag
|
||||
- `flag_count`: Number (optional). Minimum number of flags to filter by
|
||||
- `account_id`: Array of strings (optional). Filters statuses by account ID
|
||||
|
||||
This method returns a `Link` header the same way Mastodon does, to allow for pagination.
|
||||
|
||||
Output format:
|
||||
|
||||
```ts
|
||||
{
|
||||
statuses: {
|
||||
status: Status,
|
||||
modnotes: ModNote[],
|
||||
flags: Flag[],
|
||||
}[],
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/moderation/accounts/modtags/search`
|
||||
|
||||
Allows moderators to search for accounts based on their modtags
|
||||
|
||||
Params:
|
||||
- `limit`: Number
|
||||
- `min_id`: String. Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.
|
||||
- `max_id`: String. All results returned will be lesser than this ID. In effect, sets an upper bound on results.
|
||||
- `since_id`: String. All results returned will be greater than this ID. In effect, sets a lower bound on results.
|
||||
- `tags`: String (optional). Comma-separated list of tags to filter by. Can be left out to return accounts with at least one tag
|
||||
- `tag_count`: Number (optional). Minimum number of tags to filter by
|
||||
- `include_statuses`: Boolean (optional). If true, includes status tags in the search results
|
||||
- `account_id`: Array of strings (optional). Filters accounts by account ID
|
||||
|
||||
This method returns a `Link` header the same way Mastodon does, to allow for pagination.
|
||||
|
||||
Output format:
|
||||
|
||||
```ts
|
||||
{
|
||||
accounts: {
|
||||
account: User,
|
||||
modnotes: ModNote[],
|
||||
modtags: ModTag[],
|
||||
statuses?: {
|
||||
status: Status,
|
||||
modnotes: ModNote[],
|
||||
modtags: ModTag[],
|
||||
}[],
|
||||
}[],
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/v1/moderation/statuses/modtags/search`
|
||||
|
||||
Allows moderators to search for statuses based on their modtags
|
||||
|
||||
Params:
|
||||
- `limit`: Number
|
||||
- `min_id`: String. Returns results immediately newer than this ID. In effect, sets a cursor at this ID and paginates forward.
|
||||
- `max_id`: String. All results returned will be lesser than this ID. In effect, sets an upper bound on results.
|
||||
- `since_id`: String. All results returned will be greater than this ID. In effect, sets a lower bound on results.
|
||||
- `tags`: String (optional). Comma-separated list of tags to filter by. Can be left out to return statuses with at least one tag
|
||||
- `tag_count`: Number (optional). Minimum number of tags to filter by
|
||||
- `account_id`: Array of strings (optional). Filters statuses by account ID
|
||||
- `include_statuses`: Boolean (optional). If true, includes status tags in the search results
|
||||
|
||||
This method returns a `Link` header the same way Mastodon does, to allow for pagination.
|
||||
|
||||
Output format:
|
||||
|
||||
```ts
|
||||
{
|
||||
statuses: {
|
||||
status: Status,
|
||||
modnotes: ModNote[],
|
||||
modtags: ModTag[],
|
||||
}[],
|
||||
}
|
||||
```
|
||||
184
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
# `0.9.0` (upcoming)
|
||||
|
||||
## Features
|
||||
|
||||
### API
|
||||
|
||||
- [x] 🥺 Emoji Reactions are now available! You can react to any note with custom emojis.
|
||||
- [x] 🔎 Added support for [batch account data API](https://docs.joinmastodon.org/methods/accounts/#index).
|
||||
|
||||
### Backend
|
||||
|
||||
- [x] 🚀 Upgraded Bun to `1.3.2`
|
||||
|
||||
# `0.8.0` • Federation 2: Electric Boogaloo
|
||||
|
||||
## Backwards Compatibility
|
||||
|
||||
Versia Server `0.8.0` is **not** backwards-compatible with `0.7.0`. This release includes some breaking changes to the database schema and configuration file.
|
||||
|
||||
Please see [Database Changes](#database-changes) and [New Configuration](#new-configuration) for more information.
|
||||
|
||||
## Features
|
||||
|
||||
### Federation
|
||||
|
||||
- [x] 🦄 Updated to [`Versia 0.5`](https://versia.pub/changelog).
|
||||
- [x] 📦 Added support for new Versia features:
|
||||
- [x] [**Instance Messaging Extension**](https://versia.pub/extensions/instance-messaging)
|
||||
- [x] [**Shared Inboxes**](https://versia.pub/federation#inboxes)
|
||||
- [x] 🔗 Changed entity URIs to be more readable (`example.org/objects/:id` → `example.org/{notes,likes,...}/:id`)
|
||||
|
||||
### API
|
||||
|
||||
- [x] 📲 Added [Push Notifications](https://docs.joinmastodon.org/methods/push) support.
|
||||
- [x] 📖 Overhauled OpenAPI schemas to match [Mastodon API docs](https://docs.joinmastodon.org)
|
||||
- [x] 👷 Improved [**Roles API**](https://server.versia.pub/api/roles) to allow for full role control (create, update, delete, assign).
|
||||
- [x] ✏️ `<div>` and `<span>` tags are now allowed in Markdown.
|
||||
- [x] 🔥 Removed nonstandard `/api/v1/accounts/id` endpoint (the same functionality was already possible with other endpoints).
|
||||
- [x] ✨️ Implemented rate limiting support for API endpoints.
|
||||
- [x] 🔒 Implemented `is_indexable` and `is_hiding_collections` fields to the [**Accounts API**](https://docs.joinmastodon.org/methods/accounts/#update_credentials).
|
||||
- [x] ✨️ Muting other users now lets you specify a duration, after which the mute will be automatically removed.
|
||||
- [x] 📰 All accounts now have an RSS/Atom feed attached to them.
|
||||
|
||||
### CLI
|
||||
|
||||
- [x] ⌨️ New commands!
|
||||
- [x] ✨️ `cli user token` to generate API tokens.
|
||||
- [x] 👷 Error messages are now prettier!
|
||||
|
||||
### Frontend
|
||||
|
||||
The way frontend is built and served has been changed. In the past, it was required to have a second process serving a frontend, which `versia-server` would proxy requests to. This is no longer the case.
|
||||
|
||||
Versia Server now serves static files directly from a configurable path, and `versia-fe` has been updated to support this.
|
||||
|
||||
### Backend
|
||||
|
||||
- [x] 🚀 Upgraded Bun to `1.2.13`
|
||||
- [x] 🔥 Removed dependency on the `pg_uuidv7` extension. Versia Server can now be used with "vanilla" PostgreSQL.
|
||||
- [x] 🖼️ Simplified media pipeline: this will improve S3 performance
|
||||
- [x] 📈 It is now possible to disable media proxying for your CDN (offloading considerable bandwidth to your more optimized CDN).
|
||||
- [x] 👷 Outbound federation, inbox processing, data fetching and media processing are now handled by a queue system.
|
||||
- [x] 🌐 An administration panel is available at `/admin/queues` to monitor and manage queues.
|
||||
- [x] 🔥 Removed support for **from-source** installations, as Versia Server is designed around containerization and maintaining support was a large burden.
|
||||
- [x] ❄️ A [**Nix**](https://nixos.org/) package is now available for this project, packaged as a [Flake](https://wiki.nixos.org/wiki/Flakes). A **NixOS** module is also provided.
|
||||
|
||||
## New Configuration
|
||||
|
||||
Configuration parsing and validation has been overhauled. Unfortunately, this means that since a bunch of options have been renamed, you'll need to redownload [the default configuration file](config/config.example.toml) and reapply your changes.
|
||||
|
||||
## Database Changes
|
||||
|
||||
Various media-related attributes have been merged into a single `Medias` table. This will require a migration in order to preserve the old data.
|
||||
|
||||
Since very few instances are running `0.7.0`, we have decided to "rawdog it" instead of making a proper migration script (as that would take a ton of time that we don't have).
|
||||
|
||||
In the case that you've been running secret instances in the shadows, let us know and we'll help you out.
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- 🐛 All URIs in custom Markdown text are now correctly proxied.
|
||||
- 🐛 Fixed several issues with the [ActivityPub Federation Bridge](https://github.com/versia-pub/activitypub) preventing it from operating properly.
|
||||
- 🐛 Fixed incorrect content-type on some media when using S3.
|
||||
- 🐛 All media content-type is now correctly fetched, instead of guessed from the file extension as before.
|
||||
- 🐛 Fixed OpenAPI schema generation and `/docs` endpoint.
|
||||
- 🐛 Logs folder is now automatically created if it doesn't exist.
|
||||
- 🐛 Media hosted on the configured S3 bucket and on the local filesystem is no longer unnecessarily proxied.
|
||||
- 🐛 Likes and Shares now federate properly.
|
||||
|
||||
# `0.7.0` • The Auth and APIs Update
|
||||
|
||||
> [!WARNING]
|
||||
> This release marks the rename of the project from `Lysand` to `Versia`.
|
||||
|
||||
## Backwards Compatibility
|
||||
|
||||
Versia Server `0.7.0` is backwards compatible with `0.6.0`. However, some new features may not be available to older clients. Notably, `versia-fe` has had major improvements and will not work with `0.6.0`.
|
||||
|
||||
## Features
|
||||
|
||||
- Upgraded Bun to `1.1.34`. This brings performance upgrades and better stability.
|
||||
- Added support for the [ActivityPub Federation Bridge](https://github.com/versia-pub/activitypub).
|
||||
- Added support for the [Sonic](https://github.com/valeriansaliou/sonic) search indexer.
|
||||
- Note deletions are now federated.
|
||||
- Note edits are now federated.
|
||||
- Added support for [Sentry](https://sentry.io).
|
||||
- Added option for more federation debug logging.
|
||||
- Added [**Roles API**](https://server.versia.pub/api/roles).
|
||||
- Added [**Permissions API**](https://server.versia.pub/api/roles) and enabled it for every route.
|
||||
- Added [**TOS and Privacy Policy**](https://server.versia.pub/api/mastodon) endpoints.
|
||||
- Added [**Challenge API**](https://server.versia.pub/api/challenges). (basically CAPTCHAS). This can be enabled/disabled by administrators. No `versia-fe` support yet.
|
||||
- Added ability to refetch user data from remote instances.
|
||||
- Added ability to change the `username` of a user. ([Mastodon API extension](https://server.versia.pub/api/mastodon#api-v1-accounts-update-credentials)).
|
||||
- Added an endpoint to get a user by its username.
|
||||
- Add OpenID Connect registration support. Admins can now disable username/password registration entirely and still allow users to sign up via OpenID Connect.
|
||||
- Add option to never convert vector images to a raster format.
|
||||
- Refactor logging system to be more robust and easier to use. Log files are now automatically rotated.
|
||||
- Add support for HTTP proxies.
|
||||
- Add support for serving Versia over a Tor hidden service.
|
||||
- Add global server error handler, to properly return 500 error messages to clients.
|
||||
- Sign all federation HTTP requests.
|
||||
- Add JSON schema for configuration file.
|
||||
- Rewrite federation stack
|
||||
- Updated federation to Versia 0.4
|
||||
- Implement OAuth2 token revocation
|
||||
- Add new **Plugin API**
|
||||
|
||||
## Plugin System
|
||||
|
||||
A new plugin system for extending Versia Server has been added in this release!
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> This is an internal feature and is not documented. Support for third-party plugins will be given on a "if we have time" basis, until the API is fully stabilized and documented
|
||||
|
||||
Plugins using this framework support:
|
||||
|
||||
- [x] Plugin hotswapping and hotreloading
|
||||
- [x] Manifest files (JSON, JSON5, JSONC supported) with metadata (JSON schema provided)
|
||||
- [x] Installation by dropping a folder into the plugins/ directory
|
||||
- [x] Support for plugins having their own NPM dependencies
|
||||
- [x] Support for storing plugins' configuration in the main config.toml (single source of truth)
|
||||
- [x] Schema-based strict config validation (plugins can specify their own schemas)
|
||||
- [x] Full type-safety
|
||||
- [x] Custom hooks
|
||||
- [x] FFI compatibility (with `bun:ffi` or Node's FFI)
|
||||
- [x] Custom API route registration or overriding or middlewaring
|
||||
- [x] Automatic OpenAPI schema generation for all installed plugins
|
||||
- [x] End-to-end and unit testing supported
|
||||
- [x] Automatic user input validation for API routes with schemas (specify a schema for the route and the server will take care of validating everything)
|
||||
- [x] Access to internal database abstractions
|
||||
- [x] Support for sending raw SQL to database (type-safe!)
|
||||
- [x] Plugin autoload on startup with override controls (enable/disable)
|
||||
|
||||
As a demonstration of the power of this system and an effort to modularize the codebase further, OpenID functionality has been moved to a plugin. This plugin is required for login.
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- Fix favouriting/unfavouriting sometimes returning negative counts.
|
||||
- Non-images will now properly be uploaded to object storage.
|
||||
- Make account searches case-insensitive
|
||||
- Fix image decoding error when passing media through proxy.
|
||||
- OpenID Connect now correctly remembers and passes `state` parameter.
|
||||
- OpenID Connect will not reject some correct but weird redirect URIs.
|
||||
- Markdown posts will not have invisible anchor tags anymore (this messed up accessibility).
|
||||
- Reverse proxies incorrectly reporting an HTTPS request as HTTP will now be handled correctly during OpenID Connect flows.
|
||||
- API Relationships will now correctly return `requested_by`.
|
||||
- Make process wait for Ctrl+C to exit on error, instead of exiting immediately. This fixes some issues with Docker restarting endlessly.
|
||||
- Animated media will now stay animated when uploaded.
|
||||
- Some instance metadata will no longer be missing from `/api/v2/instabnce` endpoint. In fact, it will now be more complete than Mastodon's implementation.
|
||||
- The Origin HTTP header will no longer be used to determine the origin of a request. This was a security issue.
|
||||
- New notes will no longer incorrectly be federated to _all_ remote users at once.
|
||||
- Fix [Elk Client](https://elk.zone/) not being able to log in.
|
||||
|
||||
## Removals
|
||||
|
||||
- Remove old logging system, to be replaced by a new one.
|
||||
- Removed Meilisearch support, in favor of Sonic. Follow instructions in the [installation guide](https://server.versia.pub/setup/installation) to set up Sonic.
|
||||
- Removed explicit Glitch-FE support. Glitch-FE will still work, but must be hosted separately like any other frontend.
|
||||
|
||||
## Miscellaneous
|
||||
|
||||
- Remove Node.js from Docker build.
|
||||
- Update all dependencies.
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement:
|
||||
|
||||
- CPlusPatch
|
||||
- Matrix: @jesse:cpluspatch.dev
|
||||
- E-Mail: contact@cpluspatch.com
|
||||
- AprilThePimk
|
||||
- Matrix: @aprl:uwu.is
|
||||
- E-Mail: aprl+fossawareness@acab.dev
|
||||
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
|
|
@ -1,18 +1,17 @@
|
|||
# Contributing to Lysand
|
||||
# Contributing to Versia
|
||||
|
||||
> [!NOTE]
|
||||
> This document was authored by [@CPlusPatch](https://github.com/CPlusPatch).
|
||||
|
||||
Thank you for your interest in contributing to Lysand! We welcome contributions from everyone, regardless of their level of experience or expertise.
|
||||
Thank you for your interest in contributing to Versia Server! We welcome contributions from everyone, regardless of their level of experience or expertise.
|
||||
|
||||
# Tech Stack
|
||||
|
||||
Lysand is built using the following technologies:
|
||||
Versia Server is built using the following technologies:
|
||||
|
||||
- [Bun](https://bun.sh) - A JavaScript runtime similar to Node.js, but faster and with more features
|
||||
- [PostgreSQL](https://www.postgresql.org/) - A relational database
|
||||
- [`pg_uuidv7`](https://github.com/fboulnois/pg_uuidv7) - A PostgreSQL extension that provides a UUIDv7 data type
|
||||
- [Nuxt](https://nuxt.com/) - A Vue.js framework, used for the frontend
|
||||
- [Docker](https://www.docker.com/) - A containerization platform, used for development and deployment
|
||||
- [Sharp](https://sharp.pixelplumbing.com/) - An image processing library, used for fast image resizing and converting
|
||||
- [TypeScript](https://www.typescriptlang.org/) - A typed superset of JavaScript
|
||||
|
|
@ -21,26 +20,54 @@ Lysand is built using the following technologies:
|
|||
|
||||
To get started, please follow these steps:
|
||||
|
||||
1. Fork the repository, clone it on your local system and make your own branch
|
||||
2. Install the [Bun](https://bun.sh) runtime:
|
||||
1. Install the [Bun](https://bun.sh) runtime:
|
||||
```sh
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
```
|
||||
1. Clone this repository
|
||||
2. Clone this repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/lysand-org/lysand.git
|
||||
git clone https://github.com/versia-pub/server.git
|
||||
```
|
||||
|
||||
2. Install the dependencies
|
||||
3. Install the dependencies
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
3. Set up a PostgreSQL database (you need a special extension, please look at [the database documentation](database.md))
|
||||
1. Set up a PostgreSQL database (you need a special extension, please look at [the database documentation](https://server.versia.pub/setup/database))
|
||||
|
||||
4. Copy the `config/config.toml.example` file to `config/config.toml` and edit it to set up the database connection and other settings.
|
||||
2. Copy the `config/config.example.toml` file to `config/config.toml` and edit it to set up the database connection and other settings.
|
||||
|
||||
## HTTPS development
|
||||
|
||||
To develop with HTTPS, you need to generate a self-signed certificate. We will use [`mkcert`](https://github.com/FiloSottile/mkcert) for this purpose.
|
||||
|
||||
1. Install `mkcert`:
|
||||
2. Generate a certificate for the domain you are using:
|
||||
```sh
|
||||
mkcert -install
|
||||
# You can change the domain to whatever you want, but it must resolve via /etc/hosts
|
||||
# *.localhost domains are automatically aliased to localhost by DNS
|
||||
mkcert -key-file config/versia.localhost-key.pem -cert-file config/versia.localhost.pem versia.localhost
|
||||
```
|
||||
3. Edit the config to use your database and HTTPS certificates, e.g:
|
||||
```toml
|
||||
[http]
|
||||
base_url = "https://versia.localhost:9900"
|
||||
bind = "versia.localhost"
|
||||
bind_port = 9900 # Change the port to whatever you want
|
||||
|
||||
[http.tls]
|
||||
enabled = true
|
||||
key = "config/versia.localhost-key.pem"
|
||||
cert = "config/versia.localhost.pem"
|
||||
passphrase = ""
|
||||
ca = ""
|
||||
```
|
||||
|
||||
Now, running the server will use the certificate you generated.
|
||||
|
||||
## Testing your changes
|
||||
|
||||
|
|
@ -51,48 +78,46 @@ bun dev
|
|||
|
||||
If your port number is lower than 1024, you may need to run the command as root.
|
||||
|
||||
### Running the FE
|
||||
|
||||
To start the frontend server, run:
|
||||
```sh
|
||||
bun fe:dev
|
||||
```
|
||||
|
||||
This should be run in a separate process as the server.
|
||||
|
||||
## Running tests
|
||||
|
||||
To run the tests, run:
|
||||
```sh
|
||||
bun test
|
||||
bun run test
|
||||
```
|
||||
|
||||
The tests are located in the `tests/` directory and follow a Jest-like syntax. The server should be shut down before running the tests.
|
||||
The `bun test` command will cause errors due to Bun bugs ([oven-sh/bun#7823](https://github.com/oven-sh/bun/issues/7823)). Use the `test` script instead.
|
||||
|
||||
The tests are located all around the codebase (filename `*.test.ts`) and follow a Jest-like syntax. The server should be shut down before running the tests.
|
||||
|
||||
## Code style
|
||||
|
||||
We use [Biome](https://biomejs.dev) to enforce a consistent code style. To check if your code is compliant, run:
|
||||
|
||||
```sh
|
||||
bunx @biomejs/biome check .
|
||||
bun lint
|
||||
```
|
||||
|
||||
To automatically fix the issues, run:
|
||||
```sh
|
||||
bunx @biomejs/biome check . --apply
|
||||
bun lint --write
|
||||
```
|
||||
|
||||
You can also install the Biome Visual Studio Code extension and have it format your code automatically on save.
|
||||
|
||||
### ESLint rules
|
||||
### TypeScript
|
||||
|
||||
Linting should not be ignored, except if they are false positives, in which case you can use a comment to disable the rule for the line or the file. If you need to disable a rule, please add a comment explaining why.
|
||||
|
||||
TypeScript errors should be ignored with `// @ts-expect-error` comments, as well as with a reason for being ignored.
|
||||
|
||||
To scan for all TypeScript errors, run:
|
||||
```sh
|
||||
bun typecheck
|
||||
```
|
||||
|
||||
### Commit messages
|
||||
|
||||
We use [Conventional Commits](https://www.conventionalcommits.org) for our commit messages. This allows us to automatically generate the changelog and the version number.
|
||||
We use [Conventional Commits](https://www.conventionalcommits.org) for our commit messages. This allows us to automatically generate the changelog and the version number, while also making it easier to understand what changes were made in each commit.
|
||||
|
||||
### Pull requests
|
||||
|
||||
|
|
@ -106,11 +131,11 @@ Tests **should** be written for all API routes and all functions that are not tr
|
|||
|
||||
#### Adding per-route tests
|
||||
|
||||
To add tests for a route, create a `route_file_name.test.ts` file in the same directory as the route itself. See [this example](/server/api/api/v1/timelines/home.test.ts) for help writing tests.
|
||||
To add tests for a route, create a `route_file_name.test.ts` file in the same directory as the route itself. See [this example](/api/api/v1/timelines/home.test.ts) for help writing tests.
|
||||
|
||||
### Writing documentation
|
||||
|
||||
Documentation for the Lysand protocol is available on [lysand.org](https://lysand.org/). If you are thinking of modifying the protocol, please make sure to send a pull request over there to get it approved and merged before you send your pull request here.
|
||||
Documentation for the Versia protocol is available on [versia.pub](https://versia.pub/). If you are thinking of modifying the protocol, please make sure to send a pull request over there to get it approved and merged before you send your pull request here.
|
||||
|
||||
This project should not need much documentation, but if you think that something needs to be documented, please add it to the README, docs or contribution guide.
|
||||
|
||||
|
|
@ -121,11 +146,11 @@ If you find a bug, please open an issue on GitHub. Please make sure to include t
|
|||
- The steps to reproduce the bug
|
||||
- The expected behavior
|
||||
- The actual behavior
|
||||
- The version of Lysand you are using
|
||||
- The version of Versia Server you are using
|
||||
- The version of Bun you are using
|
||||
- The version of PostgreSQL you are using
|
||||
- Your operating system and version
|
||||
|
||||
# License
|
||||
|
||||
Lysand is licensed under the [AGPLv3 or later](https://www.gnu.org/licenses/agpl-3.0.en.html) license. By contributing to Lysand, you agree to license your contributions under the same license.
|
||||
Versia Server is licensed under the [AGPLv3 or later](https://www.gnu.org/licenses/agpl-3.0.en.html) license. By contributing to Versia, you agree to license your contributions under the same license.
|
||||
|
|
|
|||
38
Dockerfile
|
|
@ -1,7 +1,5 @@
|
|||
# Bun doesn't run well on Musl but this seems to work
|
||||
FROM imbios/bun-node:1.1.7-20-alpine as base
|
||||
|
||||
RUN apk add --no-cache libstdc++
|
||||
# Node is required for building the project
|
||||
FROM imbios/bun-node:latest-23-alpine AS base
|
||||
|
||||
# Install dependencies into temp directory
|
||||
# This will cache them and speed up future builds
|
||||
|
|
@ -10,40 +8,44 @@ FROM base AS install
|
|||
RUN mkdir -p /temp
|
||||
COPY . /temp
|
||||
WORKDIR /temp
|
||||
RUN bun install --frozen-lockfile --production
|
||||
RUN bun install --production
|
||||
|
||||
FROM base as build
|
||||
FROM base AS build
|
||||
|
||||
# Copy the project
|
||||
RUN mkdir -p /temp
|
||||
COPY . /temp
|
||||
# Copy dependencies
|
||||
COPY --from=install /temp/node_modules /temp/node_modules
|
||||
|
||||
# Build the project
|
||||
WORKDIR /temp
|
||||
RUN bun run prod-build
|
||||
RUN bun run build api
|
||||
WORKDIR /temp/dist
|
||||
|
||||
# Copy production dependencies and source code into final image
|
||||
FROM oven/bun:1.1.7-alpine
|
||||
FROM oven/bun:1.3.2-alpine
|
||||
|
||||
RUN apk add --no-cache libstdc++
|
||||
|
||||
# Create app directory
|
||||
# Install libstdc++ for Bun and create app directory
|
||||
RUN mkdir -p /app
|
||||
|
||||
COPY --from=build /temp/dist /app/dist
|
||||
COPY entrypoint.sh /app
|
||||
|
||||
LABEL org.opencontainers.image.authors "Gaspard Wierzbinski (https://cpluspatch.dev)"
|
||||
LABEL org.opencontainers.image.source "https://github.com/lysand-org/lysand"
|
||||
LABEL org.opencontainers.image.vendor "Lysand Org"
|
||||
LABEL org.opencontainers.image.licenses "AGPL-3.0-or-later"
|
||||
LABEL org.opencontainers.image.title "Lysand Server"
|
||||
LABEL org.opencontainers.image.description "Lysand Server docker image"
|
||||
LABEL org.opencontainers.image.authors="Gaspard Wierzbinski (https://cpluspatch.com)"
|
||||
LABEL org.opencontainers.image.source="https://github.com/versia-pub/server"
|
||||
LABEL org.opencontainers.image.vendor="Versia Pub"
|
||||
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
|
||||
LABEL org.opencontainers.image.title="Versia Server"
|
||||
LABEL org.opencontainers.image.description="Versia Server Docker image"
|
||||
|
||||
# Set current Git commit hash as an environment variable
|
||||
ARG GIT_COMMIT
|
||||
ENV GIT_COMMIT=$GIT_COMMIT
|
||||
|
||||
# CD to app
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENTRYPOINT [ "/bin/sh", "/app/entrypoint.sh" ]
|
||||
# Run migrations and start the server
|
||||
CMD [ "start" ]
|
||||
CMD [ "bun", "run", "api.js" ]
|
||||
|
|
|
|||
110
README.md
|
|
@ -1,30 +1,52 @@
|
|||
<p align="center">
|
||||
<a href="https://lysand.org"><img src="https://cdn.lysand.org/logo-long-dark.webp" alt="Lysand Logo" height="110"></a>
|
||||
</p>
|
||||
<div align="center">
|
||||
<a href="https://versia.pub">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://cdn.versia.pub/branding/logo-dark.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://cdn.versia.pub/branding/logo-light.svg">
|
||||
<img src="https://cdn.versia.pub/branding/logo-dark.svg" alt="Versia Logo" height="110" />
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
      [](code_of_conduct.md)
|
||||
|
||||
<h2 align="center">
|
||||
<strong><code>Versia Server</code></strong>
|
||||
</h2>
|
||||
|
||||
<div align="center">
|
||||
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons/typescript/typescript-original.svg" height="42" width="52" alt="TypeScript logo">
|
||||
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/postgresql/postgresql-original.svg" height="42" width="52" alt="PostgreSQL logo">
|
||||
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/docker/docker-original.svg" height="42" width="52" alt="Docker logo">
|
||||
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/bun/bun-original.svg" height="42" width="52" alt="Bun logo">
|
||||
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/vscode/vscode-original.svg" height="42" width="52" alt="VSCode logo">
|
||||
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/sentry/sentry-original.svg" height="42" width="52" alt="Sentry logo">
|
||||
<img src="https://cdn.jsdelivr.net/gh/devicons/devicon/icons/linux/linux-original.svg" height="42" width="52" alt="Linux logo">
|
||||
</div>
|
||||
|
||||
|
||||
<br/>
|
||||
|
||||
## What is this?
|
||||
|
||||
**Lysand Server** is a federated social network server based on the [Lysand](https://lysand.org) protocol. It is currently in beta phase, with basic federation and almost complete Mastodon API support.
|
||||
**Versia Server** (formerly Lysand Server) is a federated social network server based on the [Versia](https://versia.pub) protocol. It is currently in beta phase, with basic federation and almost complete Mastodon API support.
|
||||
|
||||
### Goals
|
||||
|
||||
- **Privacy**: Lysand is designed to be as private as possible. Unnecessary data is not stored, and data that is stored is done so securely.
|
||||
- **Configurability**: High configurability is a key feature of Lysand. Almost every aspect of the server can be configured to suit your needs. If you feel like something is missing, please open an issue.
|
||||
- **Security**: Lysand is designed with security in mind. It is built with modern security practices and technologies, and is constantly updated to ensure the highest level of security.
|
||||
- **Performance**: Efficiency and speed are a key focus of Lysand. The design is non-monolithic, and is built to be as fast as possible.
|
||||
- **Mastodon API compatibility**: Lysand is designed to be compatible with the Mastodon API, with Glitch-SOC extensions.
|
||||
- **Privacy**: Versia Server is designed to be as private as possible. Unnecessary data is not stored, and data that is stored is done so securely.
|
||||
- **Configurability**: High configurability is a key feature of Versia Server. Almost every aspect of the server can be configured to suit your needs. If you feel like something is missing, please open an issue.
|
||||
- **Security**: Versia Server is designed with security in mind. It is built with modern security practices and technologies, and is constantly updated to ensure the highest level of security.
|
||||
- **Performance**: Efficiency and speed are a key focus of Versia Server. The design is non-monolithic, and is built to be as fast as possible.
|
||||
- **Mastodon API compatibility**: Versia Server is designed to be compatible with the Mastodon API, with [`glitch-soc`](https://github.com/glitch-soc/mastodon) extensions.
|
||||
|
||||
### Anti-Goals
|
||||
|
||||
- **Monolithic design**: Modularity and scaling is important to this project. This means that it is not a single, monolithic application, but rather a collection of smaller, more focused applications. (API layer, queue, database, frontend, etc.)
|
||||
- **Complexity**: Both in code and in function, Lysand should be as simple as possible. This does not mean adding no features or functionality, but rather that the features and functionality that are added should be well-written and easy to understand.
|
||||
- **Bloat**: Lysand should not be bloated with unnecessary features, packages, dependencies or code. It should be as lightweight as possible, while still being feature-rich.
|
||||
- **Complexity**: Both in code and in function, Versia Server should be as simple as possible. This does not mean adding no features or functionality, but rather that the features and functionality that are added should be well-written and easy to understand.
|
||||
- **Bloat**: Versia Server should not be bloated with unnecessary features, packages, dependencies or code. It should be as lightweight as possible, while still being feature-rich.
|
||||
|
||||
## Features
|
||||
|
||||
- [x] Federation (partial)
|
||||
- [x] Versia Working Draft 4 federation (partial)
|
||||
- [x] Hyper fast (thousands of HTTP requests per second)
|
||||
- [x] S3 or local media storage
|
||||
- [x] Deduplication of uploaded files
|
||||
|
|
@ -32,19 +54,35 @@
|
|||
- [x] Configurable defaults
|
||||
- [x] Full regex-based filters for posts, users and media
|
||||
- [x] Custom emoji support
|
||||
- [x] Users can upload their own emojis for themselves
|
||||
- [x] Automatic image conversion to WebP or other formats
|
||||
- [x] Scripting-compatible CLI with JSON and CSV outputs
|
||||
- [x] Markdown support just about everywhere: posts, profiles, profile fields, etc. Code blocks, tables, and more are supported.
|
||||
- [ ] Moderation tools
|
||||
- [x] Mastodon API support (partial)
|
||||
- [ ] Advanced moderation tools (work in progress)
|
||||
- [x] Fully compliant Mastodon API support (partial)
|
||||
- [x] Glitch-SOC extensions
|
||||
- [x] Full compatibility with many clients such as Megalodon
|
||||
- [x] Ability to use your own frontends
|
||||
- [x] Non-monolithic architecture, microservices can be hosted in infinite amounts on infinite servers
|
||||
- [x] Ability to use all your threads
|
||||
- [x] Support for SSO providers, as well as SSO-only registration.
|
||||
- [x] Fully written in TypeScript and thoroughly unit tested
|
||||
- [x] Automatic signed container builds for easy deployment
|
||||
- [x] Docker and Podman supported
|
||||
- [x] Invisible, Proof-of-Work local CAPTCHA for API requests
|
||||
- [x] Advanced Roles and Permissions API.
|
||||
- [x] HTTP proxy support
|
||||
- [x] Tor hidden service support
|
||||
- [x] Sentry logging support
|
||||
- [x] Ability to change the domain name in a single config change, without any database edits
|
||||
|
||||
## Screenshots
|
||||
|
||||
You can visit [social.lysand.org](https://social.lysand.org) to see a live instance of Lysand with Lysand-FE.
|
||||
You can visit [social.lysand.org](https://social.lysand.org) to see a live instance of Versia Server with Versia-FE.
|
||||
|
||||
## How do I run it?
|
||||
|
||||
Please see the [installation guide](docs/installation.md) for more information on how to install Lysand.
|
||||
Please see the [installation guide](https://server.versia.pub/setup/installation) for more information on how to install Versia.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
|
@ -53,13 +91,15 @@ Contributions are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) fil
|
|||
## Federation
|
||||
|
||||
The following extensions are currently supported or being worked on:
|
||||
- `org.lysand:custom_emojis`: Custom emojis
|
||||
- `org.lysand:polls`: Polls
|
||||
- `org.lysand:microblogging`: Microblogging
|
||||
- `pub.versia:custom_emojis`: Custom emojis
|
||||
- `pub.versia:instance_messaging`: Instance Messaging
|
||||
- `pub.versia:likes`: Likes
|
||||
- `pub.versia:share`: Share
|
||||
- `pub.versia:reactions`: Reactions
|
||||
|
||||
## API
|
||||
|
||||
Lysand implements the Mastodon API (as well as Glitch-Soc extensions). The API is currently almost fully complete, with some fringe functionality still being worked on.
|
||||
Versia Server implements the Mastodon API (as well as `glitch-soc` extensions). The API is currently almost fully complete, with some fringe functionality still being worked on.
|
||||
|
||||
Working endpoints are:
|
||||
|
||||
|
|
@ -171,10 +211,10 @@ Working endpoints are:
|
|||
- [ ] `/api/v2/suggestions`
|
||||
- [x] `/oauth/authorize`
|
||||
- [x] `/oauth/token`
|
||||
- [ ] `/oauth/revoke`
|
||||
- Admin API
|
||||
- [x] `/oauth/revoke`
|
||||
- Admin API
|
||||
|
||||
### Main work to do
|
||||
### Main work to do for API
|
||||
|
||||
- [ ] Announcements
|
||||
- [ ] Polls
|
||||
|
|
@ -190,6 +230,28 @@ Working endpoints are:
|
|||
- [ ] Reports
|
||||
- [ ] Admin API
|
||||
|
||||
## Versia Server API
|
||||
|
||||
For Versia Server's own custom API, please see the [API documentation](https://server.versia.pub/api/emojis).
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [AGPL-3.0-or-later](LICENSE).
|
||||
|
||||
All Versia assets (icon, logo, banners, etc) are licensed under [CC-BY-NC-SA-4.0](https://creativecommons.org/licenses/by-nc-sa/4.0)
|
||||
|
||||
## Thanks!
|
||||
|
||||
Thanks to [**Fastly**](https://fastly.com) for providing us with support and resources to build Versia!
|
||||
|
||||
<br />
|
||||
|
||||
<p align="center">
|
||||
<a href="https://fastly.com">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/fastly-red.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/fastly-red.svg">
|
||||
<img src="assets/fastly-red.svg" alt="Fastly Logo" height="110" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
|
|
|||
22
SECURITY.md
|
|
@ -1,22 +0,0 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
For now, only the released latest version of Lysand is supported for security updates. This will change as Lysand exits alpha status.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you find a vulnerability, please report it to [@CPlusPatch](https://github.com/CPlusPatch) at the following contact endpoints:
|
||||
|
||||
- [Matrix](https://matrix.to/#/@jesse:cpluspatch.dev)
|
||||
- [E-mail](mailto:contact@cpluspatch.com)
|
||||
|
||||
Please do not report vulnerabilities publicly until they have been patched. If you would like to be credited for your discovery, please include your name and/or GitHub username in your report.
|
||||
|
||||
## Vulnerability Disclosure Policy
|
||||
|
||||
Lysand is an open-source project, and as such, we welcome security researchers to audit our code and report vulnerabilities. We will do our best to patch vulnerabilities as quickly as possible, and will credit researchers for their discoveries if they wish to be credited.
|
||||
|
||||
For security reasons, we ask that you do not publicly disclose vulnerabilities until they have been patched. We will do our best to patch vulnerabilities as quickly as possible, and will credit researchers for their discoveries if they wish to be credited.
|
||||
|
||||
Thank you for helping to keep Lysand secure! :3
|
||||
51
Worker.Dockerfile
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Node is required for building the project
|
||||
FROM imbios/bun-node:latest-23-alpine AS base
|
||||
|
||||
# Install dependencies into temp directory
|
||||
# This will cache them and speed up future builds
|
||||
FROM base AS install
|
||||
|
||||
RUN mkdir -p /temp
|
||||
COPY . /temp
|
||||
WORKDIR /temp
|
||||
RUN bun install --production
|
||||
|
||||
FROM base AS build
|
||||
|
||||
# Copy the project
|
||||
RUN mkdir -p /temp
|
||||
COPY . /temp
|
||||
# Copy dependencies
|
||||
COPY --from=install /temp/node_modules /temp/node_modules
|
||||
|
||||
# Build the project
|
||||
WORKDIR /temp
|
||||
RUN bun run build worker
|
||||
WORKDIR /temp/dist
|
||||
|
||||
# Copy production dependencies and source code into final image
|
||||
FROM oven/bun:1.3.2-alpine
|
||||
|
||||
# Install libstdc++ for Bun and create app directory
|
||||
RUN mkdir -p /app
|
||||
|
||||
COPY --from=build /temp/dist /app/dist
|
||||
COPY entrypoint.sh /app
|
||||
|
||||
LABEL org.opencontainers.image.authors="Gaspard Wierzbinski (https://cpluspatch.com)"
|
||||
LABEL org.opencontainers.image.source="https://github.com/versia-pub/server"
|
||||
LABEL org.opencontainers.image.vendor="Versia Pub"
|
||||
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
|
||||
LABEL org.opencontainers.image.title="Versia Server Worker"
|
||||
LABEL org.opencontainers.image.description="Versia Server Worker Docker image"
|
||||
|
||||
# Set current Git commit hash as an environment variable
|
||||
ARG GIT_COMMIT
|
||||
ENV GIT_COMMIT=$GIT_COMMIT
|
||||
|
||||
# CD to app
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENTRYPOINT [ "/bin/sh", "/app/entrypoint.sh" ]
|
||||
# Run migrations and start the server
|
||||
CMD [ "bun", "run", "worker.js" ]
|
||||
19
api.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import process from "node:process";
|
||||
import { appFactory } from "@versia-server/api";
|
||||
import { config } from "@versia-server/config";
|
||||
import { Youch } from "youch";
|
||||
import { createServer } from "@/server.ts";
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
process.exit();
|
||||
});
|
||||
|
||||
process.on("uncaughtException", async (error) => {
|
||||
const youch = new Youch();
|
||||
|
||||
console.error(await youch.toANSI(error));
|
||||
});
|
||||
|
||||
await import("@versia-server/api/setup");
|
||||
|
||||
createServer(config, await appFactory());
|
||||
1
assets/fastly-black.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 198.27"><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><g id="Fastly_Logo_-_Red" data-name="Fastly Logo - Red"><g id="Fastly_Logo_-_Red-2" data-name="Fastly Logo - Red"><polygon points="348.44 20.35 348.44 153.94 388.57 153.94 388.57 133.53 375.31 133.53 375.31 0 348.44 0 348.44 20.35"/><path d="M0,133.53H13.64V69.08H0V51.35l13.64-2.24V31.17C13.64,9.43,18.37,0,46.09,0A115.17,115.17,0,0,1,65.38,2L61.7,23.85a49.78,49.78,0,0,0-9-.78c-9.76,0-12.23,1-12.23,10.51V49.11H60.79v20H40.51v64.45H54v20.4H0Z"/><path d="M334.78,127.08a53.11,53.11,0,0,1-10.54.84c-11.06.27-10.1-3.36-10.1-13.78V69.08h21v-20h-21V0H287.27V119.71c0,23.5,5.8,34.23,31.08,34.23,6,0,14.21-1.54,20.42-2.87Z"/><path d="M501.7,133.63a10.14,10.14,0,1,1-10.19,10.14,10.14,10.14,0,0,1,10.19-10.14m0,18.68a8.55,8.55,0,0,0,8.51-8.54,8.5,8.5,0,1,0-8.51,8.54m1.88-3.56-2.05-3h-1.42v3h-2.29v-10H502c2.46,0,4,1.24,4,3.45a3,3,0,0,1-2.08,3.09l2.49,3.42Zm-3.47-5h1.82c1,0,1.74-.4,1.74-1.5s-.7-1.45-1.68-1.45h-1.88Z"/><path d="M253.72,69V65.46A115.8,115.8,0,0,0,233.14,64c-12.5,0-14,6.63-14,10.23,0,5.08,1.74,7.83,15.29,10.79,19.8,4.45,39.69,9.09,39.69,33.64,0,23.29-12,35.32-37.21,35.32-16.88,0-33.26-3.63-45.76-6.8V127.08h20.35v3.56c8.75,1.69,17.93,1.52,22.73,1.52,13.34,0,15.49-7.17,15.49-11,0-5.29-3.82-7.83-16.32-10.37-23.56-4-42.25-12.07-42.25-36,0-22.65,15.14-31.54,40.37-31.54,17.09,0,30.08,2.65,42.59,5.83V69Z"/><path d="M127.84,85.09,118,93.69a5.25,5.25,0,1,0,3.19,3.2l8.72-9.75Z"/><path d="M171.25,127.07V43.46H144.37V51a55,55,0,0,0-18.11-6.77v-9.1h3.28V28.28H102.48v6.83h3.28v9.17a55.32,55.32,0,1,0,38.76,101.87l4.77,7.78h28.38V127.07Zm-26.64-26.83A28.42,28.42,0,0,1,117.73,127v-3.18h-3.22V127a28.43,28.43,0,0,1-26.68-26.89H91V96.91H87.85a28.42,28.42,0,0,1,26.66-26.65v3.16h3.22V70.25A28.42,28.42,0,0,1,144.61,97h-3.2v3.22Z"/><path d="M456.58,49.11H512v20H498.75l-34,83.62c-9.74,23.48-25.74,45.59-50.1,45.59a93.67,93.67,0,0,1-19.5-2l2.43-24.39a68.7,68.7,0,0,0,10.63,1.1c11.3,0,24-7,28-19.19L401.82,69.06H388.57v-20H444v20H430.78l19.51,48h0l19.51-48H456.58Z"/></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
1
assets/fastly-red.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 198.27"><defs><style>.cls-1{fill:#ff282d;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><g id="Fastly_Logo_-_Red" data-name="Fastly Logo - Red"><g id="Fastly_Logo_-_Red-2" data-name="Fastly Logo - Red"><polygon class="cls-1" points="348.44 20.35 348.44 153.94 388.57 153.94 388.57 133.53 375.31 133.53 375.31 0 348.44 0 348.44 20.35"/><path class="cls-1" d="M0,133.53H13.64V69.08H0V51.35l13.64-2.24V31.17C13.64,9.43,18.37,0,46.09,0A115.17,115.17,0,0,1,65.38,2L61.7,23.85a49.78,49.78,0,0,0-9-.78c-9.76,0-12.23,1-12.23,10.51V49.11H60.79v20H40.51v64.45H54v20.4H0Z"/><path class="cls-1" d="M334.78,127.08a53.11,53.11,0,0,1-10.54.84c-11.06.27-10.1-3.36-10.1-13.78V69.08h21v-20h-21V0H287.27V119.71c0,23.5,5.8,34.23,31.08,34.23,6,0,14.21-1.54,20.42-2.87Z"/><path class="cls-1" d="M501.7,133.63a10.14,10.14,0,1,1-10.19,10.14,10.14,10.14,0,0,1,10.19-10.14m0,18.68a8.55,8.55,0,0,0,8.51-8.54,8.5,8.5,0,1,0-8.51,8.54m1.88-3.56-2.05-3h-1.42v3h-2.29v-10H502c2.46,0,4,1.24,4,3.45a3,3,0,0,1-2.08,3.09l2.49,3.42Zm-3.47-5h1.82c1,0,1.74-.4,1.74-1.5s-.7-1.45-1.68-1.45h-1.88Z"/><path class="cls-1" d="M253.72,69V65.46A115.8,115.8,0,0,0,233.14,64c-12.5,0-14,6.63-14,10.23,0,5.08,1.74,7.83,15.29,10.79,19.8,4.45,39.69,9.09,39.69,33.64,0,23.29-12,35.32-37.21,35.32-16.88,0-33.26-3.63-45.76-6.8V127.08h20.35v3.56c8.75,1.69,17.93,1.52,22.73,1.52,13.34,0,15.49-7.17,15.49-11,0-5.29-3.82-7.83-16.32-10.37-23.56-4-42.25-12.07-42.25-36,0-22.65,15.14-31.54,40.37-31.54,17.09,0,30.08,2.65,42.59,5.83V69Z"/><path class="cls-1" d="M127.84,85.09,118,93.69a5.25,5.25,0,1,0,3.19,3.2l8.72-9.75Z"/><path class="cls-1" d="M171.25,127.07V43.46H144.37V51a55,55,0,0,0-18.11-6.77v-9.1h3.28V28.28H102.48v6.83h3.28v9.17a55.32,55.32,0,1,0,38.76,101.87l4.77,7.78h28.38V127.07Zm-26.64-26.83A28.42,28.42,0,0,1,117.73,127v-3.18h-3.22V127a28.43,28.43,0,0,1-26.68-26.89H91V96.91H87.85a28.42,28.42,0,0,1,26.66-26.65v3.16h3.22V70.25A28.42,28.42,0,0,1,144.61,97h-3.2v3.22Z"/><path class="cls-1" d="M456.58,49.11H512v20H498.75l-34,83.62c-9.74,23.48-25.74,45.59-50.1,45.59a93.67,93.67,0,0,1-19.5-2l2.43-24.39a68.7,68.7,0,0,0,10.63,1.1c11.3,0,24-7,28-19.19L401.82,69.06H388.57v-20H444v20H430.78l19.51,48h0l19.51-48H456.58Z"/></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
1
assets/fastly-white.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 198.27"><defs><style>.cls-1{fill:#fff;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><g id="Fastly_Logo_-_Red" data-name="Fastly Logo - Red"><g id="Fastly_Logo_-_Red-2" data-name="Fastly Logo - Red"><polygon class="cls-1" points="348.44 20.35 348.44 153.94 388.57 153.94 388.57 133.53 375.31 133.53 375.31 0 348.44 0 348.44 20.35"/><path class="cls-1" d="M0,133.53H13.64V69.08H0V51.35l13.64-2.24V31.17C13.64,9.43,18.37,0,46.09,0A115.17,115.17,0,0,1,65.38,2L61.7,23.85a49.78,49.78,0,0,0-9-.78c-9.76,0-12.23,1-12.23,10.51V49.11H60.79v20H40.51v64.45H54v20.4H0Z"/><path class="cls-1" d="M334.78,127.08a53.11,53.11,0,0,1-10.54.84c-11.06.27-10.1-3.36-10.1-13.78V69.08h21v-20h-21V0H287.27V119.71c0,23.5,5.8,34.23,31.08,34.23,6,0,14.21-1.54,20.42-2.87Z"/><path class="cls-1" d="M501.7,133.63a10.14,10.14,0,1,1-10.19,10.14,10.14,10.14,0,0,1,10.19-10.14m0,18.68a8.55,8.55,0,0,0,8.51-8.54,8.5,8.5,0,1,0-8.51,8.54m1.88-3.56-2.05-3h-1.42v3h-2.29v-10H502c2.46,0,4,1.24,4,3.45a3,3,0,0,1-2.08,3.09l2.49,3.42Zm-3.47-5h1.82c1,0,1.74-.4,1.74-1.5s-.7-1.45-1.68-1.45h-1.88Z"/><path class="cls-1" d="M253.72,69V65.46A115.8,115.8,0,0,0,233.14,64c-12.5,0-14,6.63-14,10.23,0,5.08,1.74,7.83,15.29,10.79,19.8,4.45,39.69,9.09,39.69,33.64,0,23.29-12,35.32-37.21,35.32-16.88,0-33.26-3.63-45.76-6.8V127.08h20.35v3.56c8.75,1.69,17.93,1.52,22.73,1.52,13.34,0,15.49-7.17,15.49-11,0-5.29-3.82-7.83-16.32-10.37-23.56-4-42.25-12.07-42.25-36,0-22.65,15.14-31.54,40.37-31.54,17.09,0,30.08,2.65,42.59,5.83V69Z"/><path class="cls-1" d="M127.84,85.09,118,93.69a5.25,5.25,0,1,0,3.19,3.2l8.72-9.75Z"/><path class="cls-1" d="M171.25,127.07V43.46H144.37V51a55,55,0,0,0-18.11-6.77v-9.1h3.28V28.28H102.48v6.83h3.28v9.17a55.32,55.32,0,1,0,38.76,101.87l4.77,7.78h28.38V127.07Zm-26.64-26.83A28.42,28.42,0,0,1,117.73,127v-3.18h-3.22V127a28.43,28.43,0,0,1-26.68-26.89H91V96.91H87.85a28.42,28.42,0,0,1,26.66-26.65v3.16h3.22V70.25A28.42,28.42,0,0,1,144.61,97h-3.2v3.22Z"/><path class="cls-1" d="M456.58,49.11H512v20H498.75l-34,83.62c-9.74,23.48-25.74,45.59-50.1,45.59a93.67,93.67,0,0,1-19.5-2l2.43-24.39a68.7,68.7,0,0,0,10.63,1.1c11.3,0,24-7,28-19.19L401.82,69.06H388.57v-20H444v20H430.78l19.51,48h0l19.51-48H456.58Z"/></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 22 KiB |
BIN
assets/main.webp
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
|
@ -1,18 +0,0 @@
|
|||
const timeBefore = performance.now();
|
||||
|
||||
const requests: Promise<Response>[] = [];
|
||||
|
||||
// Repeat 1000 times
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
requests.push(
|
||||
fetch("https://mastodon.social", {
|
||||
method: "GET",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(requests);
|
||||
|
||||
const timeAfter = performance.now();
|
||||
|
||||
console.log(`Time taken: ${timeAfter - timeBefore}ms`);
|
||||
|
|
@ -1 +0,0 @@
|
|||
//
|
||||
46
benchmarks/timeline.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { Status } from "@versia/client/schemas";
|
||||
import {
|
||||
fakeRequest,
|
||||
getTestStatuses,
|
||||
getTestUsers,
|
||||
} from "@versia-server/tests";
|
||||
import { bench, run } from "mitata";
|
||||
import type { z } from "zod";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
||||
await getTestStatuses(40, users[0]);
|
||||
|
||||
const testTimeline = async (): Promise<void> => {
|
||||
const response = await fakeRequest("/api/v1/timelines/home", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
const objects = (await response.json()) as z.infer<typeof Status>[];
|
||||
|
||||
if (objects.length !== 20) {
|
||||
throw new Error("Invalid response (not 20 objects)");
|
||||
}
|
||||
};
|
||||
|
||||
const testInstance = async (): Promise<void> => {
|
||||
const response = await fakeRequest("/api/v2/instance", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
const object = (await response.json()) as Record<string, unknown>;
|
||||
|
||||
if (typeof object !== "object") {
|
||||
throw new Error("Invalid response (not an object)");
|
||||
}
|
||||
};
|
||||
|
||||
bench("timeline", testTimeline).range("amount", 1, 1000);
|
||||
bench("instance", testInstance).range("amount", 1, 1000);
|
||||
|
||||
await run();
|
||||
|
||||
await deleteUsers();
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
/**
|
||||
* Usage: TOKEN=your_token_here bun benchmark:timeline <request_count>
|
||||
*/
|
||||
|
||||
import chalk from "chalk";
|
||||
import { config } from "config-manager";
|
||||
|
||||
const token = process.env.TOKEN;
|
||||
const requestCount = Number(process.argv[2]) || 100;
|
||||
|
||||
if (!token) {
|
||||
console.log(
|
||||
`${chalk.red(
|
||||
"✗",
|
||||
)} No token provided. Provide one via the TOKEN environment variable.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fetchTimeline = () =>
|
||||
fetch(new URL("/api/v1/timelines/home", config.http.base_url), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}).then((res) => res.ok);
|
||||
|
||||
const timeNow = performance.now();
|
||||
|
||||
const requests = Array.from({ length: requestCount }, () => fetchTimeline());
|
||||
|
||||
Promise.all(requests)
|
||||
.then((results) => {
|
||||
const timeTaken = performance.now() - timeNow;
|
||||
if (results.every((t) => t)) {
|
||||
console.log(`${chalk.green("✓")} All requests succeeded`);
|
||||
} else {
|
||||
console.log(
|
||||
`${chalk.red("✗")} ${
|
||||
results.filter((t) => !t).length
|
||||
} requests failed`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`${chalk.green("✓")} ${
|
||||
requests.length
|
||||
} requests fulfilled in ${chalk.bold(
|
||||
(timeTaken / 1000).toFixed(5),
|
||||
)}s`,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(`${chalk.red("✗")} ${err}`);
|
||||
process.exit(1);
|
||||
});
|
||||
174
biome.json
|
|
@ -1,20 +1,178 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.6.4/schema.json",
|
||||
"organizeImports": {
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.4/schema.json",
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vcs": {
|
||||
"clientKind": "git",
|
||||
"enabled": true,
|
||||
"ignore": ["node_modules", "dist", "glitch", "glitch-dev"]
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
},
|
||||
"ignore": ["node_modules", "dist", "glitch", "glitch-dev"]
|
||||
"style": {
|
||||
"useNamingConvention": {
|
||||
"level": "warn",
|
||||
"options": {
|
||||
"requireAscii": false,
|
||||
"strictCase": false,
|
||||
"conventions": [
|
||||
{
|
||||
"selector": {
|
||||
"kind": "typeProperty"
|
||||
},
|
||||
"formats": [
|
||||
"camelCase",
|
||||
"CONSTANT_CASE",
|
||||
"PascalCase",
|
||||
"snake_case"
|
||||
]
|
||||
},
|
||||
{
|
||||
"selector": {
|
||||
"kind": "objectLiteralProperty",
|
||||
"scope": "any"
|
||||
},
|
||||
"formats": [
|
||||
"camelCase",
|
||||
"CONSTANT_CASE",
|
||||
"PascalCase",
|
||||
"snake_case"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"useLiteralEnumMembers": "error",
|
||||
"noNegationElse": "error",
|
||||
"noYodaExpression": "error",
|
||||
"useBlockStatements": "error",
|
||||
"useCollapsedElseIf": "error",
|
||||
"useConsistentArrayType": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"syntax": "shorthand"
|
||||
}
|
||||
},
|
||||
"useConsistentBuiltinInstantiation": "error",
|
||||
"useExplicitLengthCheck": "error",
|
||||
"useForOf": "error",
|
||||
"useNodeAssertStrict": "error",
|
||||
"useShorthandAssign": "error",
|
||||
"useThrowNewError": "error",
|
||||
"useThrowOnlyError": "error",
|
||||
"useNodejsImportProtocol": "error",
|
||||
"useAsConstAssertion": "error",
|
||||
"useEnumInitializers": "error",
|
||||
"useSelfClosingElements": "error",
|
||||
"useConst": "error",
|
||||
"useSingleVarDeclarator": "error",
|
||||
"noUnusedTemplateLiteral": "error",
|
||||
"useNumberNamespace": "error",
|
||||
"useAtIndex": "warn",
|
||||
"noInferrableTypes": "error",
|
||||
"useCollapsedIf": "warn",
|
||||
"useExponentiationOperator": "error",
|
||||
"useTemplate": "error",
|
||||
"noParameterAssign": "error",
|
||||
"noNonNullAssertion": "error",
|
||||
"useDefaultParameterLast": "error",
|
||||
"useConsistentMemberAccessibility": {
|
||||
"level": "warn",
|
||||
"options": {
|
||||
"accessibility": "explicit"
|
||||
}
|
||||
},
|
||||
"useImportType": "error",
|
||||
"useExportType": "error",
|
||||
"noUselessElse": "error",
|
||||
"noProcessEnv": "error",
|
||||
"useShorthandFunctionType": "error",
|
||||
"useArrayLiterals": "error",
|
||||
"noCommonJs": "warn",
|
||||
"noExportedImports": "warn",
|
||||
"noSubstr": "warn",
|
||||
"useTrimStartEnd": "warn",
|
||||
"noRestrictedImports": {
|
||||
"options": {
|
||||
"paths": {
|
||||
"~/packages/": "Use the appropriate package instead of importing from the packages directory directly."
|
||||
}
|
||||
},
|
||||
"level": "error"
|
||||
}
|
||||
},
|
||||
"performance": {
|
||||
"noDynamicNamespaceImportAccess": "warn"
|
||||
},
|
||||
"correctness": {
|
||||
"useImportExtensions": "error",
|
||||
"noConstantMathMinMaxClamp": "error",
|
||||
"noUndeclaredDependencies": "error",
|
||||
"noUnusedFunctionParameters": "error",
|
||||
"noUnusedImports": "error",
|
||||
"noUnusedPrivateClassMembers": "error"
|
||||
},
|
||||
"nursery": {
|
||||
"noFloatingPromises": "error"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "error",
|
||||
"noImportantStyles": "off",
|
||||
"noUselessStringConcat": "error",
|
||||
"useDateNow": "error",
|
||||
"noUselessStringRaw": "warn",
|
||||
"noUselessEscapeInRegex": "warn",
|
||||
"useSimplifiedLogicExpression": "error",
|
||||
"useWhile": "error",
|
||||
"useNumericLiterals": "error",
|
||||
"noArguments": "error",
|
||||
"noCommaOperator": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noDuplicateTestHooks": "error",
|
||||
"noOctalEscape": "error",
|
||||
"noTemplateCurlyInString": "warn",
|
||||
"noEmptyBlockStatements": "error",
|
||||
"useAdjacentOverloadSignatures": "warn",
|
||||
"useGuardForIn": "warn",
|
||||
"noDuplicateElseIf": "warn",
|
||||
"noEvolvingTypes": "error",
|
||||
"noIrregularWhitespace": "warn",
|
||||
"noExportsInTest": "error",
|
||||
"noVar": "error",
|
||||
"useAwait": "error",
|
||||
"useErrorMessage": "error",
|
||||
"useNumberToFixedDigitsArgument": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["**/packages/client/versia/client.ts"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": {
|
||||
"useNamingConvention": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 4,
|
||||
"ignore": ["node_modules", "dist", "glitch", "glitch-dev"]
|
||||
"indentWidth": 4
|
||||
},
|
||||
"javascript": {
|
||||
"globals": ["HTMLRewriter", "BufferEncoding"]
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
78
build.ts
|
|
@ -1,45 +1,55 @@
|
|||
// Delete dist directory
|
||||
import { $ } from "bun";
|
||||
import { routes } from "~routes";
|
||||
import process from "node:process";
|
||||
import { $, build, file, write } from "bun";
|
||||
import manifest from "./package.json" with { type: "json" };
|
||||
|
||||
console.log(`Building at ${process.cwd()}`);
|
||||
console.log("Building...");
|
||||
|
||||
await $`rm -rf dist && mkdir dist`;
|
||||
|
||||
await Bun.build({
|
||||
entrypoints: [
|
||||
`${process.cwd()}/index.ts`,
|
||||
`${process.cwd()}/cli.ts`,
|
||||
// Force Bun to include endpoints
|
||||
...Object.values(routes),
|
||||
],
|
||||
outdir: `${process.cwd()}/dist`,
|
||||
const type = process.argv[2] as "api" | "worker";
|
||||
|
||||
if (type !== "api" && type !== "worker") {
|
||||
throw new Error("Invalid build type. Use 'api' or 'worker'.");
|
||||
}
|
||||
|
||||
const packages = Object.keys(manifest.dependencies)
|
||||
.filter((dep) => dep.startsWith("@versia"))
|
||||
.filter((dep) => dep !== "@versia-server/tests");
|
||||
|
||||
await build({
|
||||
entrypoints: [`./${type}.ts`],
|
||||
outdir: "dist",
|
||||
target: "bun",
|
||||
splitting: true,
|
||||
minify: false,
|
||||
external: ["bullmq", "frontend"],
|
||||
}).then((output) => {
|
||||
if (!output.success) {
|
||||
console.log(output.logs);
|
||||
}
|
||||
minify: true,
|
||||
external: [...packages],
|
||||
});
|
||||
|
||||
// Fix for wrong Bun file resolution, replaces node_modules with ./node_modules inside all dynamic imports
|
||||
// I apologize for this
|
||||
await $`sed -i 's|import("node_modules/|import("./node_modules/|g' dist/*.js`;
|
||||
await $`sed -i 's|import"node_modules/|import"./node_modules/|g' dist/**/*.js`;
|
||||
// Replace /temp/node_modules with ./node_modules
|
||||
await $`sed -i 's|/temp/node_modules|./node_modules|g' dist/**/*.js`;
|
||||
console.log("Copying files...");
|
||||
|
||||
// Copy Drizzle migrations to dist
|
||||
await $`cp -r drizzle dist/drizzle`;
|
||||
// Copy each package into dist/node_modules
|
||||
for (const pkg of packages) {
|
||||
const directory = pkg.split("/")[1] || pkg;
|
||||
await $`mkdir -p dist/node_modules/${pkg}`;
|
||||
// Copy the built package files
|
||||
await $`cp -rL packages/${directory}/{dist,package.json} dist/node_modules/${pkg}`;
|
||||
|
||||
// Copy Sharp to dist
|
||||
await $`mkdir -p dist/node_modules/@img`;
|
||||
await $`cp -r node_modules/@img/sharp-libvips-linux-* dist/node_modules/@img`;
|
||||
await $`cp -r node_modules/@img/sharp-linux-* dist/node_modules/@img`;
|
||||
// Rewrite package.json "exports" field to point to the dist directory and use .js extension
|
||||
const packageJsonPath = `dist/node_modules/${pkg}/package.json`;
|
||||
const packageJson = await file(packageJsonPath).json();
|
||||
for (const [key, value] of Object.entries(packageJson.exports) as [
|
||||
string,
|
||||
{ import?: string },
|
||||
][]) {
|
||||
if (value.import) {
|
||||
packageJson.exports[key] = {
|
||||
import: value.import
|
||||
.replace("./", "./dist/")
|
||||
.replace(/\.ts$/, ".js"),
|
||||
};
|
||||
}
|
||||
}
|
||||
await write(packageJsonPath, JSON.stringify(packageJson, null, 4));
|
||||
}
|
||||
|
||||
// Copy the Bee Movie script from pages
|
||||
await $`cp beemovie.txt dist/beemovie.txt`;
|
||||
|
||||
console.log("Built!");
|
||||
console.log("Build complete!");
|
||||
|
|
|
|||
|
|
@ -1,2 +1,8 @@
|
|||
[install.scopes]
|
||||
"@jsr" = "https://npm.jsr.io"
|
||||
|
||||
[test]
|
||||
preload = ["./packages/tests/setup.ts"]
|
||||
|
||||
[install]
|
||||
linker = "hoisted"
|
||||
|
|
|
|||
17
classes/media/media-hasher.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* @packageDocumentation
|
||||
* @module MediaManager/Utils
|
||||
*/
|
||||
|
||||
import { SHA256 } from "bun";
|
||||
|
||||
/**
|
||||
* Generates a SHA-256 hash for a given file.
|
||||
* @param file - The file to hash.
|
||||
* @returns A promise that resolves to the SHA-256 hash of the file in hex format.
|
||||
*/
|
||||
export const getMediaHash = async (file: File): Promise<string> => {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const hash = new SHA256().update(arrayBuffer).digest("hex");
|
||||
return hash;
|
||||
};
|
||||
63
classes/media/preprocessors/blurhash.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, expect, it } from "bun:test";
|
||||
import { mockModule } from "@versia-server/tests";
|
||||
import sharp from "sharp";
|
||||
import { calculateBlurhash } from "./blurhash.ts";
|
||||
|
||||
describe("BlurhashPreprocessor", () => {
|
||||
it("should calculate blurhash for a valid image", async () => {
|
||||
const inputBuffer = await sharp({
|
||||
create: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
channels: 3,
|
||||
background: { r: 255, g: 0, b: 0 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const inputFile = new File([inputBuffer as BlobPart], "test.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
const result = await calculateBlurhash(inputFile);
|
||||
|
||||
expect(result).toBeTypeOf("string");
|
||||
expect(result).not.toBe("");
|
||||
});
|
||||
|
||||
it("should return null blurhash for an invalid image", async () => {
|
||||
const invalidFile = new File(["invalid image data"], "invalid.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
const result = await calculateBlurhash(invalidFile);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle errors during blurhash calculation", async () => {
|
||||
const inputBuffer = await sharp({
|
||||
create: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
channels: 3,
|
||||
background: { r: 255, g: 0, b: 0 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const inputFile = new File([inputBuffer as BlobPart], "test.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
using __ = await mockModule("blurhash", () => ({
|
||||
encode: (): void => {
|
||||
throw new Error("Test error");
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await calculateBlurhash(inputFile);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
37
classes/media/preprocessors/blurhash.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { encode } from "blurhash";
|
||||
import sharp from "sharp";
|
||||
|
||||
export const calculateBlurhash = async (file: File): Promise<string | null> => {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const metadata = await sharp(arrayBuffer).metadata();
|
||||
|
||||
return new Promise<string | null>((resolve) => {
|
||||
sharp(arrayBuffer)
|
||||
.raw()
|
||||
.ensureAlpha()
|
||||
.toBuffer((err, buffer) => {
|
||||
if (err) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolve(
|
||||
encode(
|
||||
new Uint8ClampedArray(buffer),
|
||||
metadata?.width ?? 0,
|
||||
metadata?.height ?? 0,
|
||||
4,
|
||||
4,
|
||||
) as string,
|
||||
);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
134
classes/media/preprocessors/image-conversion.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { describe, expect, it } from "bun:test";
|
||||
import sharp from "sharp";
|
||||
import { convertImage } from "./image-conversion.ts";
|
||||
|
||||
describe("ImageConversionPreprocessor", () => {
|
||||
it("should convert a JPEG image to WebP", async () => {
|
||||
const inputBuffer = await sharp({
|
||||
create: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
channels: 3,
|
||||
background: { r: 255, g: 0, b: 0 },
|
||||
},
|
||||
})
|
||||
.jpeg()
|
||||
.toBuffer();
|
||||
|
||||
const inputFile = new File([inputBuffer as BlobPart], "test.jpg", {
|
||||
type: "image/jpeg",
|
||||
});
|
||||
const result = await convertImage(inputFile, "image/webp");
|
||||
|
||||
expect(result.type).toBe("image/webp");
|
||||
expect(result.name).toBe("test.webp");
|
||||
|
||||
const resultBuffer = await result.arrayBuffer();
|
||||
const metadata = await sharp(resultBuffer).metadata();
|
||||
expect(metadata.format).toBe("webp");
|
||||
});
|
||||
|
||||
it("should not convert SVG when convert_vector is false", async () => {
|
||||
const svgContent =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" fill="red"/></svg>';
|
||||
const inputFile = new File([svgContent], "test.svg", {
|
||||
type: "image/svg+xml",
|
||||
});
|
||||
const result = await convertImage(inputFile, "image/webp");
|
||||
|
||||
expect(result).toBe(inputFile);
|
||||
});
|
||||
|
||||
it("should convert SVG when convert_vector is true", async () => {
|
||||
const svgContent =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" fill="red"/></svg>';
|
||||
const inputFile = new File([svgContent], "test.svg", {
|
||||
type: "image/svg+xml",
|
||||
});
|
||||
const result = await convertImage(inputFile, "image/webp", {
|
||||
convertVectors: true,
|
||||
});
|
||||
|
||||
expect(result.type).toBe("image/webp");
|
||||
expect(result.name).toBe("test.webp");
|
||||
});
|
||||
|
||||
it("should not convert unsupported file types", async () => {
|
||||
const inputFile = new File(["test content"], "test.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
const result = await convertImage(inputFile, "image/webp");
|
||||
|
||||
expect(result).toBe(inputFile);
|
||||
});
|
||||
|
||||
it("should throw an error for unsupported output format", async () => {
|
||||
const inputBuffer = await sharp({
|
||||
create: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
channels: 3,
|
||||
background: { r: 255, g: 0, b: 0 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const inputFile = new File([inputBuffer as BlobPart], "test.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
|
||||
await expect(convertImage(inputFile, "image/bmp")).rejects.toThrow(
|
||||
"Unsupported output format: image/bmp",
|
||||
);
|
||||
});
|
||||
|
||||
it("should convert animated GIF to WebP while preserving animation", async () => {
|
||||
// Create a simple animated GIF
|
||||
const inputBuffer = await sharp({
|
||||
create: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
channels: 4,
|
||||
background: { r: 255, g: 0, b: 0, alpha: 1 },
|
||||
},
|
||||
})
|
||||
.gif()
|
||||
.toBuffer();
|
||||
|
||||
const inputFile = new File([inputBuffer as BlobPart], "animated.gif", {
|
||||
type: "image/gif",
|
||||
});
|
||||
const result = await convertImage(inputFile, "image/webp");
|
||||
|
||||
expect(result.type).toBe("image/webp");
|
||||
expect(result.name).toBe("animated.webp");
|
||||
|
||||
const resultBuffer = await result.arrayBuffer();
|
||||
const metadata = await sharp(resultBuffer).metadata();
|
||||
expect(metadata.format).toBe("webp");
|
||||
});
|
||||
|
||||
it("should handle files with spaces in the name", async () => {
|
||||
const inputBuffer = await sharp({
|
||||
create: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
channels: 3,
|
||||
background: { r: 255, g: 0, b: 0 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const inputFile = new File(
|
||||
[inputBuffer as BlobPart],
|
||||
"test image with spaces.png",
|
||||
{ type: "image/png" },
|
||||
);
|
||||
const result = await convertImage(inputFile, "image/webp");
|
||||
|
||||
expect(result.type).toBe("image/webp");
|
||||
expect(result.name).toBe("test image with spaces.webp");
|
||||
});
|
||||
});
|
||||
109
classes/media/preprocessors/image-conversion.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* @packageDocumentation
|
||||
* @module MediaManager/Preprocessors
|
||||
*/
|
||||
|
||||
import sharp from "sharp";
|
||||
|
||||
/**
|
||||
* Supported input media formats.
|
||||
*/
|
||||
const supportedInputFormats = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"image/svg+xml",
|
||||
"image/gif",
|
||||
"image/tiff",
|
||||
];
|
||||
|
||||
/**
|
||||
* Supported output media formats.
|
||||
*/
|
||||
const supportedOutputFormats = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"image/gif",
|
||||
"image/tiff",
|
||||
];
|
||||
|
||||
/**
|
||||
* Checks if a file is convertible.
|
||||
* @param file - The file to check.
|
||||
* @returns True if the file is convertible, false otherwise.
|
||||
*/
|
||||
const isConvertible = (
|
||||
file: File,
|
||||
options?: { convertVectors?: boolean },
|
||||
): boolean => {
|
||||
if (file.type === "image/svg+xml" && !options?.convertVectors) {
|
||||
return false;
|
||||
}
|
||||
return supportedInputFormats.includes(file.type);
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the filename from a path.
|
||||
* @param path - The path to extract the filename from.
|
||||
* @returns The extracted filename.
|
||||
*/
|
||||
const extractFilenameFromPath = (path: string): string => {
|
||||
const pathParts = path.split(/(?<!\\)\//);
|
||||
return pathParts.at(-1) as string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces the file extension in the filename.
|
||||
* @param fileName - The original filename.
|
||||
* @param newExtension - The new extension.
|
||||
* @returns The filename with the new extension.
|
||||
*/
|
||||
const getReplacedFileName = (fileName: string, newExtension: string): string =>
|
||||
extractFilenameFromPath(fileName).replace(/\.[^/.]+$/, `.${newExtension}`);
|
||||
|
||||
/**
|
||||
* Converts an image file to the format specified in the configuration.
|
||||
*
|
||||
* @param file - The image file to convert.
|
||||
* @param targetFormat - The target format to convert to.
|
||||
* @returns The converted image file.
|
||||
*/
|
||||
export const convertImage = async (
|
||||
file: File,
|
||||
targetFormat: string,
|
||||
options?: {
|
||||
convertVectors?: boolean;
|
||||
},
|
||||
): Promise<File> => {
|
||||
if (!isConvertible(file, options)) {
|
||||
return file;
|
||||
}
|
||||
|
||||
if (!supportedOutputFormats.includes(targetFormat)) {
|
||||
throw new Error(`Unsupported output format: ${targetFormat}`);
|
||||
}
|
||||
|
||||
const sharpCommand = sharp(await file.arrayBuffer(), {
|
||||
animated: true,
|
||||
});
|
||||
const commandName = targetFormat.split("/")[1] as
|
||||
| "jpeg"
|
||||
| "png"
|
||||
| "webp"
|
||||
| "avif"
|
||||
| "gif"
|
||||
| "tiff";
|
||||
const convertedBuffer = await sharpCommand[commandName]().toBuffer();
|
||||
|
||||
return new File(
|
||||
[convertedBuffer as BlobPart],
|
||||
getReplacedFileName(file.name, commandName),
|
||||
{
|
||||
type: targetFormat,
|
||||
lastModified: Date.now(),
|
||||
},
|
||||
);
|
||||
};
|
||||
36
cli/index.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { completionsPlugin } from "@clerc/plugin-completions";
|
||||
import { friendlyErrorPlugin } from "@clerc/plugin-friendly-error";
|
||||
import { helpPlugin } from "@clerc/plugin-help";
|
||||
import { notFoundPlugin } from "@clerc/plugin-not-found";
|
||||
import { versionPlugin } from "@clerc/plugin-version";
|
||||
import { setupDatabase } from "@versia-server/kit/db";
|
||||
import { searchManager } from "@versia-server/kit/search";
|
||||
import { Clerc } from "clerc";
|
||||
import pkg from "../package.json" with { type: "json" };
|
||||
import { rebuildIndexCommand } from "./index/rebuild.ts";
|
||||
import { refetchInstanceCommand } from "./instance/refetch.ts";
|
||||
import { createUserCommand } from "./user/create.ts";
|
||||
import { deleteUserCommand } from "./user/delete.ts";
|
||||
import { refetchUserCommand } from "./user/refetch.ts";
|
||||
import { generateTokenCommand } from "./user/token.ts";
|
||||
|
||||
await setupDatabase(false);
|
||||
await searchManager.connect(true);
|
||||
|
||||
Clerc.create()
|
||||
.scriptName("cli")
|
||||
.name("Versia Server CLI")
|
||||
.description("CLI interface for Versia Server")
|
||||
.version(pkg.version)
|
||||
.use(helpPlugin())
|
||||
.use(versionPlugin())
|
||||
.use(completionsPlugin())
|
||||
.use(notFoundPlugin())
|
||||
.use(friendlyErrorPlugin())
|
||||
.command(createUserCommand)
|
||||
.command(deleteUserCommand)
|
||||
.command(generateTokenCommand)
|
||||
.command(refetchUserCommand)
|
||||
.command(rebuildIndexCommand)
|
||||
.command(refetchInstanceCommand)
|
||||
.parse();
|
||||
62
cli/index/rebuild.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { config } from "@versia-server/config";
|
||||
import { SonicIndexType, searchManager } from "@versia-server/kit/search";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import ora from "ora";
|
||||
|
||||
export const rebuildIndexCommand = defineCommand(
|
||||
{
|
||||
name: "index rebuild",
|
||||
description: "Rebuild the search index.",
|
||||
parameters: ["<type>"],
|
||||
flags: {
|
||||
"batch-size": {
|
||||
description: "Number of records to process at once",
|
||||
type: Number,
|
||||
alias: "b",
|
||||
default: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (context) => {
|
||||
const { "batch-size": batchSize } = context.flags;
|
||||
const { type } = context.parameters;
|
||||
|
||||
if (!config.search.enabled) {
|
||||
throw new Error(
|
||||
"Search is not enabled in the instance configuration.",
|
||||
);
|
||||
}
|
||||
|
||||
const spinner = ora("Rebuilding search indexes").start();
|
||||
|
||||
switch (type) {
|
||||
case "accounts":
|
||||
await searchManager.rebuildSearchIndexes(
|
||||
[SonicIndexType.Accounts],
|
||||
batchSize,
|
||||
(progress) => {
|
||||
spinner.text = `Rebuilding search indexes (${(progress * 100).toFixed(2)}%)`;
|
||||
},
|
||||
);
|
||||
break;
|
||||
case "statuses":
|
||||
await searchManager.rebuildSearchIndexes(
|
||||
[SonicIndexType.Statuses],
|
||||
batchSize,
|
||||
(progress) => {
|
||||
spinner.text = `Rebuilding search indexes (${(progress * 100).toFixed(2)}%)`;
|
||||
},
|
||||
);
|
||||
break;
|
||||
default: {
|
||||
throw new Error(
|
||||
"Invalid index type. Can be 'accounts' or 'statuses'.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
spinner.succeed("Search indexes rebuilt");
|
||||
},
|
||||
);
|
||||
37
cli/instance/refetch.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { Instance } from "@versia-server/kit/db";
|
||||
import { FetchJobType, fetchQueue } from "@versia-server/kit/queues/fetch";
|
||||
import { Instances } from "@versia-server/kit/tables";
|
||||
import chalk from "chalk";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export const refetchInstanceCommand = defineCommand(
|
||||
{
|
||||
name: "instance refetch",
|
||||
description: "Refetches metadata from remote instances.",
|
||||
parameters: ["<url_or_host>"],
|
||||
},
|
||||
async (context) => {
|
||||
const { urlOrHost } = context.parameters;
|
||||
|
||||
const host = URL.canParse(urlOrHost)
|
||||
? new URL(urlOrHost).host
|
||||
: urlOrHost;
|
||||
|
||||
const instance = await Instance.fromSql(eq(Instances.baseUrl, host));
|
||||
|
||||
if (!instance) {
|
||||
throw new Error(`Instance ${chalk.gray(host)} not found.`);
|
||||
}
|
||||
|
||||
await fetchQueue.add(FetchJobType.Instance, {
|
||||
uri: new URL(`https://${instance.data.baseUrl}`).origin,
|
||||
});
|
||||
|
||||
console.info(
|
||||
`Refresh job enqueued for ${chalk.gray(instance.data.baseUrl)}.`,
|
||||
);
|
||||
},
|
||||
);
|
||||
90
cli/user/create.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { config } from "@versia-server/config";
|
||||
import { User } from "@versia-server/kit/db";
|
||||
import { searchManager } from "@versia-server/kit/search";
|
||||
import { Users } from "@versia-server/kit/tables";
|
||||
import chalk from "chalk";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { renderUnicodeCompact } from "uqr";
|
||||
|
||||
export const createUserCommand = defineCommand(
|
||||
{
|
||||
name: "user create",
|
||||
description: "Create a new user.",
|
||||
parameters: ["<username>"],
|
||||
flags: {
|
||||
password: {
|
||||
description: "Password for the new user",
|
||||
type: String,
|
||||
alias: "p",
|
||||
},
|
||||
email: {
|
||||
description: "Email for the new user",
|
||||
type: String,
|
||||
alias: "e",
|
||||
},
|
||||
admin: {
|
||||
description: "Make the new user an admin",
|
||||
type: Boolean,
|
||||
alias: "a",
|
||||
},
|
||||
},
|
||||
},
|
||||
async (context) => {
|
||||
const { admin, email, password } = context.flags;
|
||||
const { username } = context.parameters;
|
||||
|
||||
if (!/^[a-z0-9_-]+$/.test(username)) {
|
||||
throw new Error("Username must be alphanumeric and lowercase.");
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await User.fromSql(
|
||||
and(eq(Users.username, username), isNull(Users.instanceId)),
|
||||
);
|
||||
|
||||
if (existingUser) {
|
||||
throw new Error(`User ${chalk.gray(username)} is taken.`);
|
||||
}
|
||||
|
||||
const user = await User.register(username, {
|
||||
email,
|
||||
password,
|
||||
isAdmin: admin,
|
||||
});
|
||||
|
||||
// Add to search index
|
||||
await searchManager.addUser(user);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Failed to create user.");
|
||||
}
|
||||
|
||||
console.info(`User ${chalk.gray(username)} created.`);
|
||||
|
||||
if (!password) {
|
||||
const token = await user.resetPassword();
|
||||
|
||||
const link = new URL(
|
||||
`${config.frontend.routes.password_reset}?${new URLSearchParams(
|
||||
{
|
||||
token,
|
||||
},
|
||||
)}`,
|
||||
config.http.base_url,
|
||||
);
|
||||
|
||||
console.info(`Password reset link for ${chalk.gray(username)}:`);
|
||||
console.info(chalk.blue(link.href));
|
||||
|
||||
const qrcode = renderUnicodeCompact(link.href, {
|
||||
border: 2,
|
||||
});
|
||||
|
||||
// Pad all lines of QR code with spaces
|
||||
console.info(`\n ${qrcode.replaceAll("\n", "\n ")}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
60
cli/user/delete.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import confirm from "@inquirer/confirm";
|
||||
import chalk from "chalk";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import { retrieveUser } from "../utils.ts";
|
||||
|
||||
export const deleteUserCommand = defineCommand(
|
||||
{
|
||||
name: "user delete",
|
||||
alias: "user rm",
|
||||
description:
|
||||
"Delete a user from the database. Can use username or handle.",
|
||||
parameters: ["<username_or_handle>"],
|
||||
flags: {
|
||||
confirm: {
|
||||
description: "Ask for confirmation before deleting the user",
|
||||
type: Boolean,
|
||||
alias: "c",
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (context) => {
|
||||
const { confirm: confirmFlag } = context.flags;
|
||||
const { usernameOrHandle } = context.parameters;
|
||||
|
||||
const user = await retrieveUser(usernameOrHandle);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`User ${chalk.gray(usernameOrHandle)} not found.`);
|
||||
}
|
||||
|
||||
console.info(`About to delete user ${chalk.gray(user.data.username)}!`);
|
||||
console.info(`Username: ${chalk.blue(user.data.username)}`);
|
||||
console.info(`Display Name: ${chalk.blue(user.data.displayName)}`);
|
||||
console.info(`Created At: ${chalk.blue(user.data.createdAt)}`);
|
||||
console.info(
|
||||
`Instance: ${chalk.blue(user.data.instance?.baseUrl || "Local")}`,
|
||||
);
|
||||
|
||||
if (confirmFlag) {
|
||||
const choice = await confirm({
|
||||
message: `Are you sure you want to delete this user? ${chalk.red(
|
||||
"This is irreversible.",
|
||||
)}`,
|
||||
});
|
||||
|
||||
if (!choice) {
|
||||
throw new Error("Operation aborted.");
|
||||
}
|
||||
}
|
||||
|
||||
await user.delete();
|
||||
|
||||
console.info(
|
||||
`User ${chalk.gray(user.data.username)} has been deleted.`,
|
||||
);
|
||||
},
|
||||
);
|
||||
43
cli/user/refetch.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { User } from "@versia-server/kit/db";
|
||||
import chalk from "chalk";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import ora from "ora";
|
||||
import { retrieveUser } from "../utils.ts";
|
||||
|
||||
export const refetchUserCommand = defineCommand(
|
||||
{
|
||||
name: "user refetch",
|
||||
description: "Refetches user data from their remote instance.",
|
||||
parameters: ["<handle>"],
|
||||
},
|
||||
async (context) => {
|
||||
const { handle } = context.parameters;
|
||||
|
||||
const user = await retrieveUser(handle);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`User ${chalk.gray(handle)} not found.`);
|
||||
}
|
||||
|
||||
if (user.local) {
|
||||
throw new Error(
|
||||
"This user is local and as such cannot be refetched.",
|
||||
);
|
||||
}
|
||||
|
||||
const spinner = ora("Refetching user").start();
|
||||
|
||||
try {
|
||||
await User.fromVersia(user.uri);
|
||||
} catch (error) {
|
||||
spinner.fail(
|
||||
`Failed to refetch user ${chalk.gray(user.data.username)}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
spinner.succeed(`User ${chalk.gray(user.data.username)} refetched.`);
|
||||
},
|
||||
);
|
||||
50
cli/user/token.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { Client, Token } from "@versia-server/kit/db";
|
||||
import { randomUUIDv7 } from "bun";
|
||||
import chalk from "chalk";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { defineCommand, type Root } from "clerc";
|
||||
import { randomString } from "@/math.ts";
|
||||
import { retrieveUser } from "../utils.ts";
|
||||
|
||||
export const generateTokenCommand = defineCommand(
|
||||
{
|
||||
name: "user token",
|
||||
description: "Generates a new access token for a user.",
|
||||
parameters: ["<username>"],
|
||||
},
|
||||
async (context) => {
|
||||
const { username } = context.parameters;
|
||||
|
||||
const user = await retrieveUser(username);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`User ${chalk.gray(username)} not found.`);
|
||||
}
|
||||
|
||||
const application = await Client.insert({
|
||||
id:
|
||||
user.id +
|
||||
Buffer.from(
|
||||
crypto.getRandomValues(new Uint8Array(32)),
|
||||
).toString("base64"),
|
||||
name: "Versia",
|
||||
redirectUris: [],
|
||||
scopes: ["openid", "profile", "email"],
|
||||
secret: "",
|
||||
});
|
||||
|
||||
const token = await Token.insert({
|
||||
id: randomUUIDv7(),
|
||||
accessToken: randomString(64, "base64url"),
|
||||
scopes: ["read", "write", "follow"],
|
||||
userId: user.id,
|
||||
clientId: application.id,
|
||||
});
|
||||
|
||||
console.info(
|
||||
`Token generated for user ${chalk.gray(user.data.username)}.`,
|
||||
);
|
||||
console.info(`Access Token: ${chalk.blue(token.data.accessToken)}`);
|
||||
},
|
||||
);
|
||||
23
cli/utils.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Instance, User } from "@versia-server/kit/db";
|
||||
import { parseUserAddress } from "@versia-server/kit/parsers";
|
||||
import { Users } from "@versia-server/kit/tables";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
|
||||
export const retrieveUser = async (
|
||||
usernameOrHandle: string,
|
||||
): Promise<User | null> => {
|
||||
const { username, domain } = parseUserAddress(usernameOrHandle);
|
||||
|
||||
const instance = domain ? await Instance.resolveFromHost(domain) : null;
|
||||
|
||||
const user = await User.fromSql(
|
||||
and(
|
||||
eq(Users.username, username),
|
||||
instance
|
||||
? eq(Users.instanceId, instance.data.id)
|
||||
: isNull(Users.instanceId),
|
||||
),
|
||||
);
|
||||
|
||||
return user;
|
||||
};
|
||||
1
config/config
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../config
|
||||
|
|
@ -1,76 +1,69 @@
|
|||
# Lysand Config
|
||||
# All of these values can be changed via the CLI (they will be saved in a file named config.internal.toml
|
||||
# in the same directory as this one)
|
||||
# Changing this file does not require a restart, but might take a few seconds to apply
|
||||
# This file will be merged with the CLI configuration, taking precedence over it
|
||||
# You can change the URL to the commit/tag you are using
|
||||
#:schema https://raw.githubusercontent.com/versia-pub/server/main/config/config.schema.json
|
||||
|
||||
[database]
|
||||
# Main PostgreSQL database connection
|
||||
# All values marked as "sensitive" can be set to "PATH:/path/to/file" to read the value from a file (e.g. a secret manager)
|
||||
|
||||
|
||||
[postgres]
|
||||
# PostgreSQL database configuration
|
||||
host = "localhost"
|
||||
port = 5432
|
||||
username = "lysand"
|
||||
password = "lysand"
|
||||
database = "lysand"
|
||||
username = "versia"
|
||||
# Sensitive value
|
||||
password = "mycoolpassword"
|
||||
database = "versia"
|
||||
|
||||
# Additional read-only replicas
|
||||
# [[postgres.replicas]]
|
||||
# host = "other-host"
|
||||
# port = 5432
|
||||
# username = "versia"
|
||||
# password = "mycoolpassword2"
|
||||
# database = "replica1"
|
||||
|
||||
[redis.queue]
|
||||
# Redis instance for storing the federation queue
|
||||
# A Redis database used for managing queues.
|
||||
# Required for federation
|
||||
host = "localhost"
|
||||
port = 6379
|
||||
password = ""
|
||||
# Sensitive value
|
||||
# password = "test"
|
||||
database = 0
|
||||
|
||||
[redis.cache]
|
||||
# Redis instance to be used as a timeline cache
|
||||
# A Redis database used for caching SQL queries.
|
||||
# Optional, can be the same as the queue instance
|
||||
host = "localhost"
|
||||
port = 6379
|
||||
password = ""
|
||||
database = 1
|
||||
# [redis.cache]
|
||||
# host = "localhost"
|
||||
# port = 6380
|
||||
# database = 1
|
||||
# password = ""
|
||||
|
||||
# Search and indexing configuration
|
||||
[search]
|
||||
# Enable indexing and searching?
|
||||
enabled = false
|
||||
|
||||
[meilisearch]
|
||||
# If Meilisearch is not configured, search will not be enabled
|
||||
host = "localhost"
|
||||
port = 7700
|
||||
api_key = "______________________________"
|
||||
enabled = false
|
||||
# Optional if search is disabled
|
||||
# [search.sonic]
|
||||
# host = "localhost"
|
||||
# port = 7700
|
||||
# Sensitive value
|
||||
# password = "test"
|
||||
|
||||
[signups]
|
||||
# URL of your Terms of Service
|
||||
tos_url = "https://my-site.com/tos"
|
||||
# Whether to enable registrations or not
|
||||
registration = true
|
||||
rules = [
|
||||
"Do not harass others",
|
||||
"Be nice to people",
|
||||
"Don't spam",
|
||||
"Don't post illegal content",
|
||||
]
|
||||
|
||||
[oidc]
|
||||
# Run Lysand with this value missing to generate a new key
|
||||
jwt_key = ""
|
||||
|
||||
# Delete this section if you don't want to use custom OAuth providers
|
||||
# This is an example configuration
|
||||
# The provider MUST support OpenID Connect with .well-known discovery
|
||||
# Most notably, GitHub does not support this
|
||||
[[oidc.providers]]
|
||||
# Test with custom Authentik instance
|
||||
name = "CPlusPatch ID"
|
||||
id = "cpluspatch-id"
|
||||
url = "https://id.cpluspatch.com/application/o/lysand-testing/"
|
||||
client_id = "______________________________"
|
||||
client_secret = "__________________________________"
|
||||
icon = "https://cpluspatch.com/images/icons/logo.svg"
|
||||
[registration]
|
||||
# Can users sign up freely?
|
||||
allow = true
|
||||
# NOT IMPLEMENTED
|
||||
require_approval = false
|
||||
# Message to show to users when registration is disabled
|
||||
# message = "ran out of spoons to moderate registrations, sorry"
|
||||
|
||||
[http]
|
||||
# The full URL Lysand will be reachable by (paths are not supported)
|
||||
base_url = "https://lysand.social"
|
||||
# Address to bind to
|
||||
# URL that the instance will be accessible at
|
||||
base_url = "https://example.com"
|
||||
# Address to bind to (0.0.0.0 is suggested for proxies)
|
||||
bind = "0.0.0.0"
|
||||
bind_port = "8080"
|
||||
bind_port = 8080
|
||||
|
||||
# Bans IPv4 or IPv6 IPs (wildcards, networks and ranges are supported)
|
||||
banned_ips = []
|
||||
|
|
@ -80,111 +73,105 @@ banned_user_agents = [
|
|||
# "wget\/1.20.3",
|
||||
]
|
||||
|
||||
[http.tls]
|
||||
# If these values are set, Lysand will use these files for TLS
|
||||
enabled = false
|
||||
key = "config/privatekey.pem"
|
||||
cert = "config/certificate.pem"
|
||||
passphrase = ""
|
||||
ca = ""
|
||||
# URL to an eventual HTTP proxy
|
||||
# Will be used for all outgoing requests
|
||||
# proxy_address = "http://localhost:8118"
|
||||
|
||||
[http.bait]
|
||||
# Enable the bait feature (sends fake data to those who are flagged)
|
||||
enabled = false
|
||||
# Path to file of bait data (if not provided, Lysand will send the entire Bee Movie script)
|
||||
send_file = ""
|
||||
# IPs to send bait data to (wildcards, networks and ranges are supported)
|
||||
bait_ips = ["127.0.0.1", "::1"]
|
||||
# User agents to send bait data to (regex format)
|
||||
bait_user_agents = ["curl", "wget"]
|
||||
# TLS configuration. You should probably be using a reverse proxy instead of this
|
||||
# [http.tls]
|
||||
# key = "/path/to/key.pem"
|
||||
# cert = "/path/to/cert.pem"
|
||||
# Sensitive value
|
||||
# passphrase = "awawa"
|
||||
# ca = "/path/to/ca.pem"
|
||||
|
||||
[frontend]
|
||||
# Enable custom frontends (warning: not enabling this or Glitch will make Lysand only accessible via the Mastodon API)
|
||||
# Frontends also control the OAuth flow, so if you disable this, you will need to use the Mastodon frontend
|
||||
# Enable custom frontends (warning: not enabling this will make Versia Server only accessible via the Mastodon API)
|
||||
# Frontends also control the OpenID flow, so if you disable this, you will need to use the Mastodon frontend
|
||||
enabled = true
|
||||
# The URL to reach the frontend at (should be on a local network)
|
||||
url = "http://localhost:3000"
|
||||
# Path that frontend files are served from
|
||||
# Edit this property to serve custom frontends
|
||||
# If this is not set, Versia Server will also check
|
||||
# the VERSIA_FRONTEND_PATH environment variable
|
||||
# path = ""
|
||||
|
||||
[frontend.glitch]
|
||||
# Enable the Glitch frontend integration
|
||||
enabled = false
|
||||
# Glitch assets folder
|
||||
assets = "glitch"
|
||||
# Server the assets were ripped from (and any eventual CDNs)
|
||||
server = ["https://glitch.social", "https://static.glitch.social"]
|
||||
[frontend.routes]
|
||||
# Special routes for your frontend, below are the defaults for Versia-FE
|
||||
# Can be set to a route already used by Versia Server, as long as it is on a different HTTP method
|
||||
# e.g. /oauth/authorize is a POST-only route, so you can serve a GET route at /oauth/authorize
|
||||
# home = "/"
|
||||
# login = "/oauth/authorize"
|
||||
# consent = "/oauth/consent"
|
||||
# register = "/register"
|
||||
# password_reset = "/oauth/reset"
|
||||
|
||||
[smtp]
|
||||
[frontend.settings]
|
||||
# Arbitrary key/value pairs to be passed to the frontend
|
||||
# This can be used to set up custom themes, etc on supported frontends.
|
||||
# theme = "dark"
|
||||
|
||||
# NOT IMPLEMENTED
|
||||
[email]
|
||||
# Enable email sending
|
||||
send_emails = false
|
||||
|
||||
# If send_emails is true, the following settings are required
|
||||
# [email.smtp]
|
||||
# SMTP server to use for sending emails
|
||||
server = "smtp.example.com"
|
||||
port = 465
|
||||
username = "test@example.com"
|
||||
password = "____________"
|
||||
tls = true
|
||||
# Disable all email functions (this will allow people to sign up without verifying
|
||||
# their email)
|
||||
enabled = false
|
||||
# server = "smtp.example.com"
|
||||
# port = 465
|
||||
# username = "test@example.com"
|
||||
# Sensitive value
|
||||
# password = "password123"
|
||||
# tls = true
|
||||
|
||||
[media]
|
||||
# Can be "s3" or "local", where "local" uploads the file to the local filesystem
|
||||
# If you need to change this value after setting up your instance, you must move all the files
|
||||
# from one backend to the other manually (the CLI will have an option to do this later)
|
||||
# TODO: Add CLI command to move files
|
||||
backend = "local"
|
||||
# Whether to check the hash of media when uploading to avoid duplication
|
||||
deduplicate_media = true
|
||||
# Changing this value will not retroactively apply to existing data
|
||||
# Don't forget to fill in the s3 config :3
|
||||
backend = "s3"
|
||||
# If media backend is "local", this is the folder where the files will be stored
|
||||
# Can be any path
|
||||
local_uploads_folder = "uploads"
|
||||
uploads_path = "uploads"
|
||||
|
||||
[media.conversion]
|
||||
# Whether to automatically convert images to another format on upload
|
||||
convert_images = false
|
||||
# Can be: "jxl", "webp", "avif", "png", "jpg", "heif"
|
||||
convert_images = true
|
||||
# Can be: "image/jxl", "image/webp", "image/avif", "image/png", "image/jpeg", "image/heif", "image/gif"
|
||||
# JXL support will likely not work
|
||||
convert_to = "webp"
|
||||
convert_to = "image/webp"
|
||||
# Also convert SVG images?
|
||||
convert_vectors = false
|
||||
|
||||
[s3]
|
||||
# Can be left blank if you don't use the S3 media backend
|
||||
endpoint = "myhostname.banana.com"
|
||||
access_key = "_____________"
|
||||
secret_access_key = "_________________"
|
||||
region = ""
|
||||
bucket_name = "lysand"
|
||||
public_url = "https://cdn.test.com"
|
||||
|
||||
[email]
|
||||
# Sends an email to moderators when a report is received
|
||||
send_on_report = false
|
||||
# Sends an email to moderators when a user is suspended
|
||||
send_on_suspend = false
|
||||
# Sends an email to moderators when a user is unsuspended
|
||||
send_on_unsuspend = false
|
||||
# Verify user emails when signing up (except via OIDC)
|
||||
verify_email = false
|
||||
# [s3]
|
||||
# Can be left commented if you don't use the S3 media backend
|
||||
# endpoint = "https://s3.example.com"
|
||||
# Sensitive value
|
||||
# access_key = "XXXXX"
|
||||
# Sensitive value
|
||||
# secret_access_key = "XXX"
|
||||
# region = "us-east-1"
|
||||
# bucket_name = "versia"
|
||||
# public_url = "https://cdn.example.com"
|
||||
# Adds a prefix to the uploaded files
|
||||
# path = "versia"
|
||||
# Use path-style URLs during upload (e.g. https://s3.example.com/versia)
|
||||
# instead of the default virtual-hosted style (e.g. https://versia.s3.example.com)
|
||||
# This is required for some S3-compatible services, such as MinIO
|
||||
# path_style = true
|
||||
|
||||
[validation]
|
||||
# Checks user data
|
||||
# Does not retroactively apply to previously entered data
|
||||
max_displayname_size = 50
|
||||
max_bio_size = 160
|
||||
max_note_size = 5000
|
||||
max_avatar_size = 5_000_000
|
||||
max_header_size = 5_000_000
|
||||
max_media_size = 40_000_000
|
||||
max_media_attachments = 10
|
||||
max_media_description_size = 1000
|
||||
max_poll_options = 20
|
||||
max_poll_option_size = 500
|
||||
min_poll_duration = 60
|
||||
max_poll_duration = 1893456000
|
||||
max_username_size = 30
|
||||
max_field_count = 10
|
||||
max_field_name_size = 1000
|
||||
max_field_value_size = 1000
|
||||
# Forbidden usernames, defaults are from Akkoma
|
||||
username_blacklist = [
|
||||
".well-known",
|
||||
"~",
|
||||
[validation.accounts]
|
||||
max_displayname_characters = 50
|
||||
max_username_characters = 30
|
||||
max_bio_characters = 5000
|
||||
max_avatar_bytes = 5_000_000
|
||||
max_header_bytes = 5_000_000
|
||||
# Regex is allowed here
|
||||
disallowed_usernames = [
|
||||
"well-known",
|
||||
"about",
|
||||
"activities",
|
||||
"api",
|
||||
|
|
@ -210,12 +197,14 @@ username_blacklist = [
|
|||
"search",
|
||||
"mfa",
|
||||
]
|
||||
# Whether to blacklist known temporary email providers
|
||||
blacklist_tempmail = false
|
||||
# Additional email providers to blacklist (list of domains)
|
||||
email_blacklist = []
|
||||
# Valid URL schemes, otherwise the URL is parsed as text
|
||||
url_scheme_whitelist = [
|
||||
max_field_count = 10
|
||||
max_field_name_characters = 1000
|
||||
max_field_value_characters = 1000
|
||||
max_pinned_notes = 20
|
||||
|
||||
[validation.notes]
|
||||
max_characters = 5000
|
||||
allowed_url_schemes = [
|
||||
"http",
|
||||
"https",
|
||||
"ftp",
|
||||
|
|
@ -234,40 +223,71 @@ url_scheme_whitelist = [
|
|||
"ssb",
|
||||
"gemini",
|
||||
]
|
||||
# Only allow those MIME types of data to be uploaded
|
||||
# This can easily be spoofed, but if it is spoofed it will appear broken
|
||||
# to normal clients until despoofed
|
||||
enforce_mime_types = false
|
||||
allowed_mime_types = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/heic",
|
||||
"image/heif",
|
||||
"image/webp",
|
||||
"image/avif",
|
||||
"video/webm",
|
||||
"video/mp4",
|
||||
"video/quicktime",
|
||||
"video/ogg",
|
||||
"audio/wave",
|
||||
"audio/wav",
|
||||
"audio/x-wav",
|
||||
"audio/x-pn-wave",
|
||||
"audio/vnd.wave",
|
||||
"audio/ogg",
|
||||
"audio/vorbis",
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
"audio/webm",
|
||||
"audio/flac",
|
||||
"audio/aac",
|
||||
"audio/m4a",
|
||||
"audio/x-m4a",
|
||||
"audio/mp4",
|
||||
"audio/3gpp",
|
||||
"video/x-ms-asf",
|
||||
max_attachments = 16
|
||||
|
||||
[validation.media]
|
||||
max_bytes = 40_000_000
|
||||
max_description_characters = 1000
|
||||
# An empty array allows all MIME types
|
||||
allowed_mime_types = []
|
||||
|
||||
[validation.emojis]
|
||||
max_bytes = 1_000_000
|
||||
max_shortcode_characters = 100
|
||||
max_description_characters = 1000
|
||||
|
||||
[validation.polls]
|
||||
max_options = 20
|
||||
max_option_characters = 500
|
||||
min_duration_seconds = 60
|
||||
# 100 days
|
||||
max_duration_seconds = 8_640_000
|
||||
|
||||
[validation.emails]
|
||||
# Blocks over 10,000 common tempmail domains
|
||||
disallow_tempmail = false
|
||||
# Regex is allowed here
|
||||
disallowed_domains = []
|
||||
|
||||
# [validation.challenges]
|
||||
# "Challenges" (aka captchas) are a way to verify that a user is human
|
||||
# Versia Server's challenges use no external services, and are proof-of-work based
|
||||
# This means that they do not require any user interaction, instead
|
||||
# they require the user's computer to do a small amount of work
|
||||
# The difficulty of the challenge, higher is will take more time to solve
|
||||
# difficulty = 50000
|
||||
# Challenge expiration time in seconds
|
||||
# expiration = 300 # 5 minutes
|
||||
# Leave this empty to generate a new key
|
||||
# Sensitive value
|
||||
# key = ""
|
||||
|
||||
# Block content that matches these regular expressions
|
||||
[validation.filters]
|
||||
note_content = [
|
||||
# "(https?://)?(www\\.)?youtube\\.com/watch\\?v=[a-zA-Z0-9_-]+",
|
||||
# "(https?://)?(www\\.)?youtu\\.be/[a-zA-Z0-9_-]+",
|
||||
]
|
||||
emoji_shortcode = []
|
||||
username = []
|
||||
displayname = []
|
||||
bio = []
|
||||
|
||||
[notifications]
|
||||
|
||||
# Web Push Notifications configuration.
|
||||
# Leave out to disable.
|
||||
# [notifications.push]
|
||||
# Subject field embedded in the push notification
|
||||
# subject = "mailto:joe@example.com"
|
||||
#
|
||||
# [notifications.push.vapid_keys]
|
||||
# VAPID keys for push notifications
|
||||
# Run Versia Server with those values missing to generate new keys
|
||||
# Sensitive value
|
||||
# public = ""
|
||||
# Sensitive value
|
||||
# private = ""
|
||||
|
||||
[defaults]
|
||||
# Default visibility for new notes
|
||||
|
|
@ -276,17 +296,53 @@ allowed_mime_types = [
|
|||
visibility = "public"
|
||||
# Default language for new notes (ISO code)
|
||||
language = "en"
|
||||
# Default avatar, must be a valid URL or "" for a placeholder avatar
|
||||
avatar = ""
|
||||
# Default header, must be a valid URL or "" for none
|
||||
header = ""
|
||||
# Default avatar, must be a valid URL or left out for a placeholder avatar
|
||||
# avatar = ""
|
||||
# Default header, must be a valid URL or left out for none
|
||||
# header = ""
|
||||
# A style name from https://www.dicebear.com/styles
|
||||
placeholder_style = "thumbs"
|
||||
|
||||
[queues]
|
||||
# Controls the delivery queue (for outbound federation)
|
||||
[queues.delivery]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
|
||||
# Controls the inbox processing queue (for inbound federation)
|
||||
[queues.inbox]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
|
||||
# Controls the fetch queue (for remote data refreshes)
|
||||
[queues.fetch]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
|
||||
# Controls the push queue (for push notification delivery)
|
||||
[queues.push]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
|
||||
# Controls the media queue (for media processing)
|
||||
[queues.media]
|
||||
# Time in seconds to remove completed jobs
|
||||
remove_after_complete_seconds = 31536000
|
||||
# Time in seconds to remove failed jobs
|
||||
remove_after_failure_seconds = 31536000
|
||||
|
||||
[federation]
|
||||
# This is a list of domain names, such as "mastodon.social" or "pleroma.site"
|
||||
# These changes will not retroactively apply to existing data before they were changed
|
||||
# For that, please use the CLI
|
||||
# For that, please use the CLI (in a later release)
|
||||
|
||||
# These instances will not be federated with
|
||||
blocked = []
|
||||
|
|
@ -306,59 +362,119 @@ reactions = []
|
|||
banners = []
|
||||
avatars = []
|
||||
|
||||
# For bridge software, such as versia-pub/activitypub
|
||||
# Bridges must be hosted separately from the main Versia Server process
|
||||
# [federation.bridge]
|
||||
# Only versia-ap exists for now
|
||||
# software = "versia-ap"
|
||||
# If this is empty, any bridge with the correct token
|
||||
# will be able to send data to your instance
|
||||
# v4, v6, ranges and wildcards are supported
|
||||
# allowed_ips = ["192.168.1.0/24"]
|
||||
# Token for the bridge software
|
||||
# Bridge must have the same token!
|
||||
# Sensitive value
|
||||
# token = "mycooltoken"
|
||||
# url = "https://ap.versia.social"
|
||||
|
||||
[instance]
|
||||
name = "Lysand"
|
||||
description = "A test instance of Lysand"
|
||||
# Path to a file containing a longer description of your instance
|
||||
# This will be parsed as Markdown
|
||||
extended_description_path = ""
|
||||
# URL to your instance logo (jpg files should be renamed to jpeg)
|
||||
logo = ""
|
||||
# URL to your instance banner (jpg files should be renamed to jpeg)
|
||||
banner = ""
|
||||
name = "Versia"
|
||||
description = "A Versia Server instance"
|
||||
|
||||
# Paths to instance long description, terms of service, and privacy policy
|
||||
# These will be parsed as Markdown
|
||||
#
|
||||
# extended_description_path = "config/extended_description.md"
|
||||
# tos_path = "config/tos.md"
|
||||
# privacy_policy_path = "config/privacy_policy.md"
|
||||
|
||||
[filters]
|
||||
# Regex filters for federated and local data
|
||||
# Does not apply retroactively (try the CLI for that)
|
||||
# Primary instance languages. ISO 639-1 codes.
|
||||
languages = ["en"]
|
||||
|
||||
# Note contents
|
||||
note_content = [
|
||||
# "(https?://)?(www\\.)?youtube\\.com/watch\\?v=[a-zA-Z0-9_-]+",
|
||||
# "(https?://)?(www\\.)?youtu\\.be/[a-zA-Z0-9_-]+",
|
||||
]
|
||||
emoji = []
|
||||
# These will drop users matching the filters
|
||||
username = []
|
||||
displayname = []
|
||||
bio = []
|
||||
[instance.contact]
|
||||
# email = "staff@yourinstance.com"
|
||||
|
||||
[instance.branding]
|
||||
# logo = "https://cdn.example.com/logo.png"
|
||||
# banner = "https://cdn.example.com/banner.png"
|
||||
|
||||
# Used for federation. If left empty or missing, the server will generate one for you.
|
||||
# [instance.keys]
|
||||
# Sensitive value
|
||||
# public = ""
|
||||
# Sensitive value
|
||||
# private = ""
|
||||
|
||||
[[instance.rules]]
|
||||
# Short description of the rule
|
||||
text = "No hate speech"
|
||||
# Longer version of the rule with additional information
|
||||
hint = "Hate speech includes slurs, threats, and harassment."
|
||||
|
||||
[[instance.rules]]
|
||||
text = "No spam"
|
||||
|
||||
# [[instance.rules]]
|
||||
# ...etc
|
||||
|
||||
[permissions]
|
||||
# Control default permissions for users
|
||||
# Note that an anonymous user having a permission will not allow them
|
||||
# to do things that require authentication (e.g. 'owner:notes' -> posting a note will need
|
||||
# auth, but viewing a note will not)
|
||||
# See https://server.versia.pub/api/roles#list-of-permissions for a list of all permissions
|
||||
|
||||
# Defaults to being able to login and manage their own content
|
||||
# anonymous = []
|
||||
|
||||
# Defaults to identical to anonymous
|
||||
# default = []
|
||||
|
||||
# Defaults to being able to manage all instance data, content, and users
|
||||
# admin = []
|
||||
|
||||
[logging]
|
||||
# Log all requests (warning: this is a lot of data)
|
||||
log_requests = false
|
||||
# Log request and their contents (warning: this is a lot of data)
|
||||
log_requests_verbose = false
|
||||
# Available levels: debug, info, warning, error, critical
|
||||
log_level = "info"
|
||||
# For GDPR compliance, you can disable logging of IPs
|
||||
log_ip = false
|
||||
|
||||
# Log all filtered objects
|
||||
log_filters = true
|
||||
# Available levels: trace, debug, info, warning, error, fatal
|
||||
log_level = "info" # For console output
|
||||
|
||||
[logging.storage]
|
||||
# Path to logfile for requests
|
||||
requests = "logs/requests.log"
|
||||
# [logging.file]
|
||||
# path = "logs/versia.log"
|
||||
# log_level = "info"
|
||||
#
|
||||
# [logging.file.rotation]
|
||||
# max_size = 10_000_000 # 10 MB
|
||||
# max_files = 10 # Keep 10 rotated files
|
||||
#
|
||||
# https://sentry.io support
|
||||
# [logging.sentry]
|
||||
# dsn = "https://example.com"
|
||||
# debug = false
|
||||
# sample_rate = 1.0
|
||||
# traces_sample_rate = 1.0
|
||||
# Can also be regex
|
||||
# trace_propagation_targets = []
|
||||
# max_breadcrumbs = 100
|
||||
# environment = "production"
|
||||
# log_level = "info"
|
||||
|
||||
[ratelimits]
|
||||
# These settings apply to every route at once
|
||||
# Amount to multiply every route's duration by
|
||||
duration_coeff = 1.0
|
||||
# Amount to multiply every route's max requests per [duration] by
|
||||
max_coeff = 1.0
|
||||
[authentication]
|
||||
# Run Versia Server with this value missing to generate a new key
|
||||
# key = ""
|
||||
|
||||
[custom_ratelimits]
|
||||
# Add in any API route in this style here
|
||||
# Applies before the global ratelimit changes
|
||||
"/api/v1/accounts/:id/block" = { duration = 30, max = 60 }
|
||||
"/api/v1/timelines/public" = { duration = 60, max = 200 }
|
||||
# The provider MUST support OpenID Connect with .well-known discovery
|
||||
# Most notably, GitHub does not support this
|
||||
# Redirect URLs in your OpenID provider can be set to this:
|
||||
# <base_url>/oauth/sso/<provider_id>/callback*
|
||||
# The asterisk is important, as it allows for any query parameters to be passed
|
||||
# Authentik for example uses regex so it can be set to (regex):
|
||||
# <base_url>/oauth/sso/<provider_id>/callback.*
|
||||
# [[authentication.openid_providers]]
|
||||
# name = "CPlusPatch ID"
|
||||
# id = "cpluspatch-id"
|
||||
# This MUST match the provider's issuer URI, including the trailing slash (or lack thereof)
|
||||
# url = "https://id.cpluspatch.com/application/o/versia-testing/"
|
||||
# client_id = "XXXX"
|
||||
# Sensitive value
|
||||
# client_secret = "XXXXX"
|
||||
# icon = "https://cpluspatch.com/images/icons/logo.svg"
|
||||
|
|
|
|||
2851
config/config.schema.json
Normal file
|
|
@ -1,10 +0,0 @@
|
|||
// import { Queue } from "bullmq";
|
||||
|
||||
/* const federationQueue = new Queue("federation", {
|
||||
connection: {
|
||||
host: config.redis.queue.host,
|
||||
port: Number(config.redis.queue.port),
|
||||
password: config.redis.queue.password || undefined,
|
||||
db: config.redis.queue.database || undefined,
|
||||
},
|
||||
}); */
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { db } from "~drizzle/db";
|
||||
import type { Applications } from "~drizzle/schema";
|
||||
import type { Application as APIApplication } from "~types/mastodon/application";
|
||||
|
||||
export type Application = InferSelectModel<typeof Applications>;
|
||||
|
||||
/**
|
||||
* Retrieves the application associated with the given access token.
|
||||
* @param token The access token to retrieve the application for.
|
||||
* @returns The application associated with the given access token, or null if no such application exists.
|
||||
*/
|
||||
export const getFromToken = async (
|
||||
token: string,
|
||||
): Promise<Application | null> => {
|
||||
const result = await db.query.Tokens.findFirst({
|
||||
where: (tokens, { eq }) => eq(tokens.accessToken, token),
|
||||
with: {
|
||||
application: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result?.application || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts this application to an API application.
|
||||
* @returns The API application representation of this application.
|
||||
*/
|
||||
export const applicationToAPI = (app: Application): APIApplication => {
|
||||
return {
|
||||
name: app.name,
|
||||
website: app.website,
|
||||
vapid_key: app.vapidKey,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
import { proxyUrl } from "@response";
|
||||
import type { Config } from "config-manager";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type * as Lysand from "lysand-types";
|
||||
import { MediaBackendType } from "media-manager";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Attachments } from "~drizzle/schema";
|
||||
import type { AsyncAttachment as APIAsyncAttachment } from "~types/mastodon/async_attachment";
|
||||
import type { Attachment as APIAttachment } from "~types/mastodon/attachment";
|
||||
|
||||
export type Attachment = InferSelectModel<typeof Attachments>;
|
||||
|
||||
export const attachmentToAPI = (
|
||||
attachment: Attachment,
|
||||
): APIAsyncAttachment | APIAttachment => {
|
||||
let type = "unknown";
|
||||
|
||||
if (attachment.mimeType.startsWith("image/")) {
|
||||
type = "image";
|
||||
} else if (attachment.mimeType.startsWith("video/")) {
|
||||
type = "video";
|
||||
} else if (attachment.mimeType.startsWith("audio/")) {
|
||||
type = "audio";
|
||||
}
|
||||
|
||||
return {
|
||||
id: attachment.id,
|
||||
type: type as "image" | "video" | "audio" | "unknown",
|
||||
url: proxyUrl(attachment.url) ?? "",
|
||||
remote_url: proxyUrl(attachment.remoteUrl),
|
||||
preview_url: proxyUrl(attachment.thumbnailUrl || attachment.url),
|
||||
text_url: null,
|
||||
meta: {
|
||||
width: attachment.width || undefined,
|
||||
height: attachment.height || undefined,
|
||||
fps: attachment.fps || undefined,
|
||||
size:
|
||||
attachment.width && attachment.height
|
||||
? `${attachment.width}x${attachment.height}`
|
||||
: undefined,
|
||||
duration: attachment.duration || undefined,
|
||||
length: attachment.size?.toString() || undefined,
|
||||
aspect:
|
||||
attachment.width && attachment.height
|
||||
? attachment.width / attachment.height
|
||||
: undefined,
|
||||
original: {
|
||||
width: attachment.width || undefined,
|
||||
height: attachment.height || undefined,
|
||||
size:
|
||||
attachment.width && attachment.height
|
||||
? `${attachment.width}x${attachment.height}`
|
||||
: undefined,
|
||||
aspect:
|
||||
attachment.width && attachment.height
|
||||
? attachment.width / attachment.height
|
||||
: undefined,
|
||||
},
|
||||
// Idk whether size or length is the right value
|
||||
},
|
||||
description: attachment.description,
|
||||
blurhash: attachment.blurhash,
|
||||
};
|
||||
};
|
||||
|
||||
export const attachmentToLysand = (
|
||||
attachment: Attachment,
|
||||
): Lysand.ContentFormat => {
|
||||
return {
|
||||
[attachment.mimeType]: {
|
||||
content: attachment.url,
|
||||
blurhash: attachment.blurhash ?? undefined,
|
||||
description: attachment.description ?? undefined,
|
||||
duration: attachment.duration ?? undefined,
|
||||
fps: attachment.fps ?? undefined,
|
||||
height: attachment.height ?? undefined,
|
||||
size: attachment.size ?? undefined,
|
||||
hash: attachment.sha256
|
||||
? {
|
||||
sha256: attachment.sha256,
|
||||
}
|
||||
: undefined,
|
||||
width: attachment.width ?? undefined,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const attachmentFromLysand = async (
|
||||
attachmentToConvert: Lysand.ContentFormat,
|
||||
): Promise<InferSelectModel<typeof Attachments>> => {
|
||||
const key = Object.keys(attachmentToConvert)[0];
|
||||
const value = attachmentToConvert[key];
|
||||
|
||||
const result = await db
|
||||
.insert(Attachments)
|
||||
.values({
|
||||
mimeType: key,
|
||||
url: value.content,
|
||||
description: value.description || undefined,
|
||||
duration: value.duration || undefined,
|
||||
fps: value.fps || undefined,
|
||||
height: value.height || undefined,
|
||||
size: value.size || undefined,
|
||||
width: value.width || undefined,
|
||||
sha256: value.hash?.sha256 || undefined,
|
||||
blurhash: value.blurhash || undefined,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
};
|
||||
|
||||
export const getUrl = (name: string, config: Config) => {
|
||||
if (config.media.backend === MediaBackendType.LOCAL) {
|
||||
return new URL(`/media/${name}`, config.http.base_url).toString();
|
||||
}
|
||||
if (config.media.backend === MediaBackendType.S3) {
|
||||
return new URL(`/${name}`, config.s3.public_url).toString();
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
import { proxyUrl } from "@response";
|
||||
import { type InferSelectModel, and, eq } from "drizzle-orm";
|
||||
import type * as Lysand from "lysand-types";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Emojis, Instances } from "~drizzle/schema";
|
||||
import type { Emoji as APIEmoji } from "~types/mastodon/emoji";
|
||||
import { addInstanceIfNotExists } from "./Instance";
|
||||
|
||||
export type EmojiWithInstance = InferSelectModel<typeof Emojis> & {
|
||||
instance: InferSelectModel<typeof Instances> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Used for parsing emojis from local text
|
||||
* @param text The text to parse
|
||||
* @returns An array of emojis
|
||||
*/
|
||||
export const parseEmojis = async (text: string) => {
|
||||
const regex = /:[a-zA-Z0-9_]+:/g;
|
||||
const matches = text.match(regex);
|
||||
if (!matches) return [];
|
||||
const emojis = await db.query.Emojis.findMany({
|
||||
where: (emoji, { eq, or }) =>
|
||||
or(
|
||||
...matches
|
||||
.map((match) => match.replace(/:/g, ""))
|
||||
.map((match) => eq(emoji.shortcode, match)),
|
||||
),
|
||||
with: {
|
||||
instance: true,
|
||||
},
|
||||
});
|
||||
|
||||
return emojis;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets an emoji from the database, and fetches it from the remote instance if it doesn't exist.
|
||||
* @param emoji Emoji to fetch
|
||||
* @param host Host to fetch the emoji from if remote
|
||||
* @returns The emoji
|
||||
*/
|
||||
export const fetchEmoji = async (
|
||||
emojiToFetch: Lysand.Emoji,
|
||||
host?: string,
|
||||
): Promise<EmojiWithInstance> => {
|
||||
const existingEmoji = await db
|
||||
.select()
|
||||
.from(Emojis)
|
||||
.innerJoin(Instances, eq(Emojis.instanceId, Instances.id))
|
||||
.where(
|
||||
and(
|
||||
eq(Emojis.shortcode, emojiToFetch.name),
|
||||
host ? eq(Instances.baseUrl, host) : undefined,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingEmoji[0])
|
||||
return {
|
||||
...existingEmoji[0].Emojis,
|
||||
instance: existingEmoji[0].Instances,
|
||||
};
|
||||
|
||||
const foundInstance = host ? await addInstanceIfNotExists(host) : null;
|
||||
|
||||
const result = (
|
||||
await db
|
||||
.insert(Emojis)
|
||||
.values({
|
||||
shortcode: emojiToFetch.name,
|
||||
url: Object.entries(emojiToFetch.url)[0][1].content,
|
||||
alt:
|
||||
emojiToFetch.alt ||
|
||||
Object.entries(emojiToFetch.url)[0][1].description ||
|
||||
undefined,
|
||||
contentType: Object.keys(emojiToFetch.url)[0],
|
||||
visibleInPicker: true,
|
||||
instanceId: foundInstance?.id,
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
return {
|
||||
...result,
|
||||
instance: foundInstance,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts the emoji to an APIEmoji object.
|
||||
* @returns The APIEmoji object.
|
||||
*/
|
||||
export const emojiToAPI = (emoji: EmojiWithInstance): APIEmoji => {
|
||||
return {
|
||||
shortcode: emoji.shortcode,
|
||||
static_url: proxyUrl(emoji.url) ?? "", // TODO: Add static version
|
||||
url: proxyUrl(emoji.url) ?? "",
|
||||
visible_in_picker: emoji.visibleInPicker,
|
||||
category: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const emojiToLysand = (emoji: EmojiWithInstance): Lysand.Emoji => {
|
||||
return {
|
||||
name: emoji.shortcode,
|
||||
url: {
|
||||
[emoji.contentType]: {
|
||||
content: emoji.url,
|
||||
description: emoji.alt || undefined,
|
||||
},
|
||||
},
|
||||
alt: emoji.alt || undefined,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import { config } from "config-manager";
|
||||
import type * as Lysand from "lysand-types";
|
||||
import type { User } from "~packages/database-interface/user";
|
||||
|
||||
export const localObjectURI = (id: string) => `/objects/${id}`;
|
||||
|
||||
export const objectToInboxRequest = async (
|
||||
object: Lysand.Entity,
|
||||
author: User,
|
||||
userToSendTo: User,
|
||||
): Promise<Request> => {
|
||||
if (userToSendTo.isLocal() || !userToSendTo.getUser().endpoints?.inbox) {
|
||||
throw new Error("UserToSendTo has no inbox or is a local user");
|
||||
}
|
||||
|
||||
if (author.isRemote()) {
|
||||
throw new Error("Author is a remote user");
|
||||
}
|
||||
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
Buffer.from(author.getUser().privateKey ?? "", "base64"),
|
||||
"Ed25519",
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(JSON.stringify(object)),
|
||||
);
|
||||
|
||||
const userInbox = new URL(userToSendTo.getUser().endpoints?.inbox ?? "");
|
||||
|
||||
const date = new Date();
|
||||
|
||||
const signature = await crypto.subtle.sign(
|
||||
"Ed25519",
|
||||
privateKey,
|
||||
new TextEncoder().encode(
|
||||
`(request-target): post ${userInbox.pathname}\n` +
|
||||
`host: ${userInbox.host}\n` +
|
||||
`date: ${date.toISOString()}\n` +
|
||||
`digest: SHA-256=${Buffer.from(new Uint8Array(digest)).toString(
|
||||
"base64",
|
||||
)}\n`,
|
||||
),
|
||||
);
|
||||
|
||||
const signatureBase64 = Buffer.from(new Uint8Array(signature)).toString(
|
||||
"base64",
|
||||
);
|
||||
|
||||
return new Request(userInbox, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Date: date.toISOString(),
|
||||
Origin: new URL(config.http.base_url).host,
|
||||
Signature: `keyId="${author.getUri()}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`,
|
||||
},
|
||||
body: JSON.stringify(object),
|
||||
});
|
||||
};
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import type * as Lysand from "lysand-types";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Instances } from "~drizzle/schema";
|
||||
|
||||
/**
|
||||
* Represents an instance in the database.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Adds an instance to the database if it doesn't already exist.
|
||||
* @param url
|
||||
* @returns Either the database instance if it already exists, or a newly created instance.
|
||||
*/
|
||||
export const addInstanceIfNotExists = async (url: string) => {
|
||||
const origin = new URL(url).origin;
|
||||
const host = new URL(url).host;
|
||||
|
||||
const found = await db.query.Instances.findFirst({
|
||||
where: (instance, { eq }) => eq(instance.baseUrl, host),
|
||||
});
|
||||
|
||||
if (found) return found;
|
||||
|
||||
console.log(`Fetching instance metadata for ${origin}`);
|
||||
|
||||
// Fetch the instance configuration
|
||||
const metadata = (await fetch(new URL("/.well-known/lysand", origin)).then(
|
||||
(res) => res.json(),
|
||||
)) as Lysand.ServerMetadata;
|
||||
|
||||
if (metadata.type !== "ServerMetadata") {
|
||||
throw new Error("Invalid instance metadata (wrong type)");
|
||||
}
|
||||
|
||||
if (!(metadata.name && metadata.version)) {
|
||||
throw new Error("Invalid instance metadata (missing name or version)");
|
||||
}
|
||||
|
||||
return (
|
||||
await db
|
||||
.insert(Instances)
|
||||
.values({
|
||||
baseUrl: host,
|
||||
name: metadata.name,
|
||||
version: metadata.version,
|
||||
logo: metadata.logo,
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
};
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
import { config } from "config-manager";
|
||||
import { type InferSelectModel, and, eq } from "drizzle-orm";
|
||||
import type * as Lysand from "lysand-types";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Likes, Notifications } from "~drizzle/schema";
|
||||
import type { Note } from "~packages/database-interface/note";
|
||||
import type { User } from "~packages/database-interface/user";
|
||||
|
||||
export type Like = InferSelectModel<typeof Likes>;
|
||||
|
||||
/**
|
||||
* Represents a Like entity in the database.
|
||||
*/
|
||||
export const likeToLysand = (like: Like): Lysand.Like => {
|
||||
return {
|
||||
id: like.id,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: to be rewritten
|
||||
author: (like as any).liker?.uri,
|
||||
type: "Like",
|
||||
created_at: new Date(like.createdAt).toISOString(),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: to be rewritten
|
||||
object: (like as any).liked?.uri,
|
||||
uri: new URL(`/objects/${like.id}`, config.http.base_url).toString(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a like
|
||||
* @param user User liking the status
|
||||
* @param note Status being liked
|
||||
*/
|
||||
export const createLike = async (user: User, note: Note) => {
|
||||
await db.insert(Likes).values({
|
||||
likedId: note.id,
|
||||
likerId: user.id,
|
||||
});
|
||||
|
||||
if (note.getAuthor().getUser().instanceId === user.getUser().instanceId) {
|
||||
// Notify the user that their post has been favourited
|
||||
await db.insert(Notifications).values({
|
||||
accountId: user.id,
|
||||
type: "favourite",
|
||||
notifiedId: note.getAuthor().id,
|
||||
noteId: note.id,
|
||||
});
|
||||
} else {
|
||||
// TODO: Add database jobs for federating this
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a like
|
||||
* @param user User deleting their like
|
||||
* @param note Status being unliked
|
||||
*/
|
||||
export const deleteLike = async (user: User, note: Note) => {
|
||||
await db
|
||||
.delete(Likes)
|
||||
.where(and(eq(Likes.likedId, note.id), eq(Likes.likerId, user.id)));
|
||||
|
||||
// Notify the user that their post has been favourited
|
||||
await db
|
||||
.delete(Notifications)
|
||||
.where(
|
||||
and(
|
||||
eq(Notifications.accountId, user.id),
|
||||
eq(Notifications.type, "favourite"),
|
||||
eq(Notifications.notifiedId, note.getAuthor().id),
|
||||
eq(Notifications.noteId, note.id),
|
||||
),
|
||||
);
|
||||
|
||||
if (user.isLocal() && note.getAuthor().isRemote()) {
|
||||
// User is local, federate the delete
|
||||
// TODO: Federate this
|
||||
}
|
||||
};
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { db } from "~drizzle/db";
|
||||
import type { Notifications } from "~drizzle/schema";
|
||||
import { Note } from "~packages/database-interface/note";
|
||||
import { User } from "~packages/database-interface/user";
|
||||
import type { Notification as APINotification } from "~types/mastodon/notification";
|
||||
import type { StatusWithRelations } from "./Status";
|
||||
import {
|
||||
type UserWithRelations,
|
||||
transformOutputToUserWithRelations,
|
||||
userExtrasTemplate,
|
||||
userRelations,
|
||||
} from "./User";
|
||||
|
||||
export type Notification = InferSelectModel<typeof Notifications>;
|
||||
|
||||
export type NotificationWithRelations = Notification & {
|
||||
status: StatusWithRelations | null;
|
||||
account: UserWithRelations;
|
||||
};
|
||||
|
||||
export const findManyNotifications = async (
|
||||
query: Parameters<typeof db.query.Notifications.findMany>[0],
|
||||
): Promise<NotificationWithRelations[]> => {
|
||||
const output = await db.query.Notifications.findMany({
|
||||
...query,
|
||||
with: {
|
||||
...query?.with,
|
||||
account: {
|
||||
with: {
|
||||
...userRelations,
|
||||
},
|
||||
extras: userExtrasTemplate("Notifications_account"),
|
||||
},
|
||||
},
|
||||
extras: {
|
||||
...query?.extras,
|
||||
},
|
||||
});
|
||||
|
||||
return await Promise.all(
|
||||
output.map(async (notif) => ({
|
||||
...notif,
|
||||
account: transformOutputToUserWithRelations(notif.account),
|
||||
status: (await Note.fromId(notif.noteId))?.getStatus() ?? null,
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
export const notificationToAPI = async (
|
||||
notification: NotificationWithRelations,
|
||||
): Promise<APINotification> => {
|
||||
const account = new User(notification.account);
|
||||
return {
|
||||
account: account.toAPI(),
|
||||
created_at: new Date(notification.createdAt).toISOString(),
|
||||
id: notification.id,
|
||||
type: notification.type,
|
||||
status: notification.status
|
||||
? await Note.fromStatus(notification.status).toAPI(account)
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type * as Lysand from "lysand-types";
|
||||
import { db } from "~drizzle/db";
|
||||
import { LysandObjects } from "~drizzle/schema";
|
||||
import { findFirstUser } from "./User";
|
||||
|
||||
export type LysandObject = InferSelectModel<typeof LysandObjects>;
|
||||
|
||||
/**
|
||||
* Represents a Lysand object in the database.
|
||||
*/
|
||||
|
||||
export const createFromObject = async (
|
||||
object: Lysand.Entity,
|
||||
authorUri: string,
|
||||
) => {
|
||||
const foundObject = await db.query.LysandObjects.findFirst({
|
||||
where: (o, { eq }) => eq(o.remoteId, object.id),
|
||||
with: {
|
||||
author: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (foundObject) {
|
||||
return foundObject;
|
||||
}
|
||||
|
||||
const author = await findFirstUser({
|
||||
where: (user, { eq }) => eq(user.uri, authorUri),
|
||||
});
|
||||
|
||||
return await db.insert(LysandObjects).values({
|
||||
authorId: author?.id,
|
||||
createdAt: new Date(object.created_at).toISOString(),
|
||||
extensions: object.extensions,
|
||||
remoteId: object.id,
|
||||
type: object.type,
|
||||
uri: object.uri,
|
||||
// Rest of data (remove id, author, created_at, extensions, type, uri)
|
||||
extraData: Object.fromEntries(
|
||||
Object.entries(object).filter(
|
||||
([key]) =>
|
||||
![
|
||||
"id",
|
||||
"author",
|
||||
"created_at",
|
||||
"extensions",
|
||||
"type",
|
||||
"uri",
|
||||
].includes(key),
|
||||
),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export const toLysand = (lyObject: LysandObject): Lysand.Entity => {
|
||||
return {
|
||||
id: lyObject.remoteId || lyObject.id,
|
||||
created_at: new Date(lyObject.createdAt).toISOString(),
|
||||
type: lyObject.type,
|
||||
uri: lyObject.uri,
|
||||
...(lyObject.extraData as object),
|
||||
// @ts-expect-error Assume stored JSON is valid
|
||||
extensions: lyObject.extensions as object,
|
||||
};
|
||||
};
|
||||
|
||||
export const isPublication = (lyObject: LysandObject): boolean => {
|
||||
return lyObject.type === "Note" || lyObject.type === "Patch";
|
||||
};
|
||||
|
||||
export const isAction = (lyObject: LysandObject): boolean => {
|
||||
return [
|
||||
"Like",
|
||||
"Follow",
|
||||
"Dislike",
|
||||
"FollowAccept",
|
||||
"FollowReject",
|
||||
"Undo",
|
||||
"Announce",
|
||||
].includes(lyObject.type);
|
||||
};
|
||||
|
||||
export const isActor = (lyObject: LysandObject): boolean => {
|
||||
return lyObject.type === "User";
|
||||
};
|
||||
|
||||
export const isExtension = (lyObject: LysandObject): boolean => {
|
||||
return lyObject.type === "Extension";
|
||||
};
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
import { config } from "config-manager";
|
||||
// import { Worker } from "bullmq";
|
||||
|
||||
/* export const federationWorker = new Worker(
|
||||
"federation",
|
||||
async job => {
|
||||
await job.updateProgress(0);
|
||||
|
||||
switch (job.name) {
|
||||
case "federation": {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const statusId = job.data.id as string;
|
||||
|
||||
const status = await client.status.findUnique({
|
||||
where: { id: statusId },
|
||||
include: statusAndUserRelations,
|
||||
});
|
||||
|
||||
if (!status) return;
|
||||
|
||||
// Only get remote users that follow the author of the status, and the remote mentioned users
|
||||
const peopleToSendTo = await client.user.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
["public", "unlisted", "private"].includes(
|
||||
status.visibility
|
||||
)
|
||||
? {
|
||||
relationships: {
|
||||
some: {
|
||||
subjectId: status.authorId,
|
||||
following: true,
|
||||
},
|
||||
},
|
||||
instanceId: {
|
||||
not: null,
|
||||
},
|
||||
}
|
||||
: {},
|
||||
// Mentioned users
|
||||
{
|
||||
id: {
|
||||
in: status.mentions.map(m => m.id),
|
||||
},
|
||||
instanceId: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
let peopleDone = 0;
|
||||
|
||||
// Spawn sendToServer job for each user
|
||||
for (const person of peopleToSendTo) {
|
||||
await federationQueue.add("sendToServer", {
|
||||
id: statusId,
|
||||
user: person,
|
||||
});
|
||||
|
||||
peopleDone++;
|
||||
|
||||
await job.updateProgress(
|
||||
Math.round((peopleDone / peopleToSendTo.length) * 100)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "sendToServer": {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const statusId = job.data.id as string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const user = job.data.user as User;
|
||||
|
||||
const status = await client.status.findUnique({
|
||||
where: { id: statusId },
|
||||
include: statusAndUserRelations,
|
||||
});
|
||||
|
||||
if (!status) return;
|
||||
|
||||
const response = await federateStatusTo(
|
||||
status,
|
||||
status.author,
|
||||
user
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(
|
||||
`Federation error: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await job.updateProgress(100);
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
connection: {
|
||||
host: config.redis.queue.host,
|
||||
port: config.redis.queue.port,
|
||||
password: config.redis.queue.password,
|
||||
db: config.redis.queue.database || undefined,
|
||||
},
|
||||
removeOnComplete: {
|
||||
count: 400,
|
||||
},
|
||||
removeOnFail: {
|
||||
count: 3000,
|
||||
},
|
||||
}
|
||||
); */
|
||||
|
||||
export const addStatusFederationJob = async (statusId: string) => {
|
||||
/* await federationQueue.add("federation", {
|
||||
id: statusId,
|
||||
}); */
|
||||
};
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { db } from "~drizzle/db";
|
||||
import { Relationships } from "~drizzle/schema";
|
||||
import type { User } from "~packages/database-interface/user";
|
||||
import type { Relationship as APIRelationship } from "~types/mastodon/relationship";
|
||||
import type { UserType } from "./User";
|
||||
|
||||
export type Relationship = InferSelectModel<typeof Relationships>;
|
||||
|
||||
/**
|
||||
* Creates a new relationship between two users.
|
||||
* @param owner The user who owns the relationship.
|
||||
* @param other The user who is the subject of the relationship.
|
||||
* @returns The newly created relationship.
|
||||
*/
|
||||
export const createNewRelationship = async (
|
||||
owner: User,
|
||||
other: User,
|
||||
): Promise<Relationship> => {
|
||||
return (
|
||||
await db
|
||||
.insert(Relationships)
|
||||
.values({
|
||||
ownerId: owner.id,
|
||||
subjectId: other.id,
|
||||
languages: [],
|
||||
following: false,
|
||||
showingReblogs: false,
|
||||
notifying: false,
|
||||
followedBy: false,
|
||||
blocking: false,
|
||||
blockedBy: false,
|
||||
muting: false,
|
||||
mutingNotifications: false,
|
||||
requested: false,
|
||||
domainBlocking: false,
|
||||
endorsed: false,
|
||||
note: "",
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
};
|
||||
|
||||
export const checkForBidirectionalRelationships = async (
|
||||
user1: User,
|
||||
user2: User,
|
||||
createIfNotExists = true,
|
||||
): Promise<boolean> => {
|
||||
const relationship1 = await db.query.Relationships.findFirst({
|
||||
where: (rel, { and, eq }) =>
|
||||
and(eq(rel.ownerId, user1.id), eq(rel.subjectId, user2.id)),
|
||||
});
|
||||
|
||||
const relationship2 = await db.query.Relationships.findFirst({
|
||||
where: (rel, { and, eq }) =>
|
||||
and(eq(rel.ownerId, user2.id), eq(rel.subjectId, user1.id)),
|
||||
});
|
||||
|
||||
if (!relationship1 && !relationship2 && createIfNotExists) {
|
||||
await createNewRelationship(user1, user2);
|
||||
await createNewRelationship(user2, user1);
|
||||
}
|
||||
|
||||
return !!relationship1 && !!relationship2;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts the relationship to an API-friendly format.
|
||||
* @returns The API-friendly relationship.
|
||||
*/
|
||||
export const relationshipToAPI = (rel: Relationship): APIRelationship => {
|
||||
return {
|
||||
blocked_by: rel.blockedBy,
|
||||
blocking: rel.blocking,
|
||||
domain_blocking: rel.domainBlocking,
|
||||
endorsed: rel.endorsed,
|
||||
followed_by: rel.followedBy,
|
||||
following: rel.following,
|
||||
id: rel.subjectId,
|
||||
muting: rel.muting,
|
||||
muting_notifications: rel.mutingNotifications,
|
||||
notifying: rel.notifying,
|
||||
requested: rel.requested,
|
||||
showing_reblogs: rel.showingReblogs,
|
||||
note: rel.note,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,656 +0,0 @@
|
|||
import markdownItTaskLists from "@hackmd/markdown-it-task-lists";
|
||||
import { dualLogger } from "@loggers";
|
||||
import { sanitizeHtml } from "@sanitization";
|
||||
import { config } from "config-manager";
|
||||
import {
|
||||
type InferSelectModel,
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
isNull,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import linkifyHtml from "linkify-html";
|
||||
import type * as Lysand from "lysand-types";
|
||||
import {
|
||||
anyOf,
|
||||
charIn,
|
||||
createRegExp,
|
||||
digit,
|
||||
exactly,
|
||||
global,
|
||||
letter,
|
||||
maybe,
|
||||
oneOrMore,
|
||||
} from "magic-regexp";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import markdownItAnchor from "markdown-it-anchor";
|
||||
import markdownItContainer from "markdown-it-container";
|
||||
import markdownItTocDoneRight from "markdown-it-toc-done-right";
|
||||
import { db } from "~drizzle/db";
|
||||
import { type Attachments, Instances, Notes, Users } from "~drizzle/schema";
|
||||
import { Note } from "~packages/database-interface/note";
|
||||
import { User } from "~packages/database-interface/user";
|
||||
import { LogLevel } from "~packages/log-manager";
|
||||
import type { Status as APIStatus } from "~types/mastodon/status";
|
||||
import type { Application } from "./Application";
|
||||
import { attachmentFromLysand } from "./Attachment";
|
||||
import { type EmojiWithInstance, fetchEmoji } from "./Emoji";
|
||||
import { objectToInboxRequest } from "./Federation";
|
||||
import type { Like } from "./Like";
|
||||
import {
|
||||
type UserType,
|
||||
type UserWithInstance,
|
||||
type UserWithRelations,
|
||||
resolveWebFinger,
|
||||
transformOutputToUserWithRelations,
|
||||
userExtrasTemplate,
|
||||
userRelations,
|
||||
} from "./User";
|
||||
|
||||
export type Status = InferSelectModel<typeof Notes>;
|
||||
|
||||
export type StatusWithRelations = Status & {
|
||||
author: UserWithRelations;
|
||||
mentions: UserWithInstance[];
|
||||
attachments: InferSelectModel<typeof Attachments>[];
|
||||
reblog: StatusWithoutRecursiveRelations | null;
|
||||
emojis: EmojiWithInstance[];
|
||||
likes: Like[];
|
||||
reply: Status | null;
|
||||
quote: Status | null;
|
||||
application: Application | null;
|
||||
reblogCount: number;
|
||||
likeCount: number;
|
||||
replyCount: number;
|
||||
};
|
||||
|
||||
export type StatusWithoutRecursiveRelations = Omit<
|
||||
StatusWithRelations,
|
||||
"reply" | "quote" | "reblog"
|
||||
>;
|
||||
|
||||
export const noteExtras = {
|
||||
reblogCount:
|
||||
sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."reblogId" = "Notes".id)`.as(
|
||||
"reblog_count",
|
||||
),
|
||||
likeCount:
|
||||
sql`(SELECT COUNT(*) FROM "Likes" WHERE "Likes"."likedId" = "Notes".id)`.as(
|
||||
"like_count",
|
||||
),
|
||||
replyCount:
|
||||
sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."replyId" = "Notes".id)`.as(
|
||||
"reply_count",
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper against the Status object to make it easier to work with
|
||||
* @param query
|
||||
* @returns
|
||||
*/
|
||||
export const findManyNotes = async (
|
||||
query: Parameters<typeof db.query.Notes.findMany>[0],
|
||||
): Promise<StatusWithRelations[]> => {
|
||||
const output = await db.query.Notes.findMany({
|
||||
...query,
|
||||
with: {
|
||||
...query?.with,
|
||||
attachments: {
|
||||
where: (attachment, { eq }) =>
|
||||
eq(attachment.noteId, sql`"Notes"."id"`),
|
||||
},
|
||||
emojis: {
|
||||
with: {
|
||||
emoji: {
|
||||
with: {
|
||||
instance: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
author: {
|
||||
with: {
|
||||
...userRelations,
|
||||
},
|
||||
extras: userExtrasTemplate("Notes_author"),
|
||||
},
|
||||
mentions: {
|
||||
with: {
|
||||
user: {
|
||||
with: {
|
||||
instance: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
reblog: {
|
||||
with: {
|
||||
attachments: true,
|
||||
emojis: {
|
||||
with: {
|
||||
emoji: {
|
||||
with: {
|
||||
instance: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
likes: true,
|
||||
application: true,
|
||||
mentions: {
|
||||
with: {
|
||||
user: {
|
||||
with: userRelations,
|
||||
extras: userExtrasTemplate(
|
||||
"Notes_reblog_mentions_user",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
author: {
|
||||
with: {
|
||||
...userRelations,
|
||||
},
|
||||
extras: userExtrasTemplate("Notes_reblog_author"),
|
||||
},
|
||||
},
|
||||
extras: {
|
||||
...noteExtras,
|
||||
},
|
||||
},
|
||||
reply: true,
|
||||
quote: true,
|
||||
},
|
||||
extras: {
|
||||
...noteExtras,
|
||||
...query?.extras,
|
||||
},
|
||||
});
|
||||
|
||||
return output.map((post) => ({
|
||||
...post,
|
||||
author: transformOutputToUserWithRelations(post.author),
|
||||
mentions: post.mentions.map((mention) => ({
|
||||
...mention.user,
|
||||
endpoints: mention.user.endpoints,
|
||||
})),
|
||||
emojis: (post.emojis ?? []).map((emoji) => emoji.emoji),
|
||||
reblog: post.reblog && {
|
||||
...post.reblog,
|
||||
author: transformOutputToUserWithRelations(post.reblog.author),
|
||||
mentions: post.reblog.mentions.map((mention) => ({
|
||||
...mention.user,
|
||||
endpoints: mention.user.endpoints,
|
||||
})),
|
||||
emojis: (post.reblog.emojis ?? []).map((emoji) => emoji.emoji),
|
||||
reblogCount: Number(post.reblog.reblogCount),
|
||||
likeCount: Number(post.reblog.likeCount),
|
||||
replyCount: Number(post.reblog.replyCount),
|
||||
},
|
||||
reblogCount: Number(post.reblogCount),
|
||||
likeCount: Number(post.likeCount),
|
||||
replyCount: Number(post.replyCount),
|
||||
}));
|
||||
};
|
||||
|
||||
export const findFirstNote = async (
|
||||
query: Parameters<typeof db.query.Notes.findFirst>[0],
|
||||
): Promise<StatusWithRelations | null> => {
|
||||
const output = await db.query.Notes.findFirst({
|
||||
...query,
|
||||
with: {
|
||||
...query?.with,
|
||||
attachments: {
|
||||
where: (attachment, { eq }) =>
|
||||
eq(attachment.noteId, sql`"Notes"."id"`),
|
||||
},
|
||||
emojis: {
|
||||
with: {
|
||||
emoji: {
|
||||
with: {
|
||||
instance: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
author: {
|
||||
with: {
|
||||
...userRelations,
|
||||
},
|
||||
extras: userExtrasTemplate("Notes_author"),
|
||||
},
|
||||
mentions: {
|
||||
with: {
|
||||
user: {
|
||||
with: {
|
||||
instance: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
reblog: {
|
||||
with: {
|
||||
attachments: true,
|
||||
emojis: {
|
||||
with: {
|
||||
emoji: {
|
||||
with: {
|
||||
instance: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
likes: true,
|
||||
application: true,
|
||||
mentions: {
|
||||
with: {
|
||||
user: {
|
||||
with: userRelations,
|
||||
extras: userExtrasTemplate(
|
||||
"Notes_reblog_mentions_user",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
author: {
|
||||
with: {
|
||||
...userRelations,
|
||||
},
|
||||
extras: userExtrasTemplate("Notes_reblog_author"),
|
||||
},
|
||||
},
|
||||
extras: {
|
||||
...noteExtras,
|
||||
},
|
||||
},
|
||||
reply: true,
|
||||
quote: true,
|
||||
},
|
||||
extras: {
|
||||
...noteExtras,
|
||||
...query?.extras,
|
||||
},
|
||||
});
|
||||
|
||||
if (!output) return null;
|
||||
|
||||
return {
|
||||
...output,
|
||||
author: transformOutputToUserWithRelations(output.author),
|
||||
mentions: output.mentions.map((mention) => ({
|
||||
...mention.user,
|
||||
endpoints: mention.user.endpoints,
|
||||
})),
|
||||
emojis: (output.emojis ?? []).map((emoji) => emoji.emoji),
|
||||
reblog: output.reblog && {
|
||||
...output.reblog,
|
||||
author: transformOutputToUserWithRelations(output.reblog.author),
|
||||
mentions: output.reblog.mentions.map((mention) => ({
|
||||
...mention.user,
|
||||
endpoints: mention.user.endpoints,
|
||||
})),
|
||||
emojis: (output.reblog.emojis ?? []).map((emoji) => emoji.emoji),
|
||||
reblogCount: Number(output.reblog.reblogCount),
|
||||
likeCount: Number(output.reblog.likeCount),
|
||||
replyCount: Number(output.reblog.replyCount),
|
||||
},
|
||||
reblogCount: Number(output.reblogCount),
|
||||
likeCount: Number(output.likeCount),
|
||||
replyCount: Number(output.replyCount),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveNote = async (
|
||||
uri?: string,
|
||||
providedNote?: Lysand.Note,
|
||||
): Promise<Note> => {
|
||||
if (!uri && !providedNote) {
|
||||
throw new Error("No URI or note provided");
|
||||
}
|
||||
|
||||
const foundStatus = await Note.fromSql(
|
||||
eq(Notes.uri, uri ?? providedNote?.uri ?? ""),
|
||||
);
|
||||
|
||||
if (foundStatus) return foundStatus;
|
||||
|
||||
let note: Lysand.Note | null = providedNote ?? null;
|
||||
|
||||
if (uri) {
|
||||
if (!URL.canParse(uri)) {
|
||||
throw new Error(`Invalid URI to parse ${uri}`);
|
||||
}
|
||||
|
||||
const response = await fetch(uri, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
note = (await response.json()) as Lysand.Note;
|
||||
}
|
||||
|
||||
if (!note) {
|
||||
throw new Error("No note was able to be fetched");
|
||||
}
|
||||
|
||||
if (note.type !== "Note") {
|
||||
throw new Error("Invalid object type");
|
||||
}
|
||||
|
||||
if (!note.author) {
|
||||
throw new Error("Invalid object author");
|
||||
}
|
||||
|
||||
const author = await User.resolve(note.author);
|
||||
|
||||
if (!author) {
|
||||
throw new Error("Invalid object author");
|
||||
}
|
||||
|
||||
const attachments = [];
|
||||
|
||||
for (const attachment of note.attachments ?? []) {
|
||||
const resolvedAttachment = await attachmentFromLysand(attachment).catch(
|
||||
(e) => {
|
||||
dualLogger.logError(
|
||||
LogLevel.ERROR,
|
||||
"Federation.StatusResolver",
|
||||
e,
|
||||
);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
if (resolvedAttachment) {
|
||||
attachments.push(resolvedAttachment);
|
||||
}
|
||||
}
|
||||
|
||||
const emojis = [];
|
||||
|
||||
for (const emoji of note.extensions?.["org.lysand:custom_emojis"]?.emojis ??
|
||||
[]) {
|
||||
const resolvedEmoji = await fetchEmoji(emoji).catch((e) => {
|
||||
dualLogger.logError(LogLevel.ERROR, "Federation.StatusResolver", e);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (resolvedEmoji) {
|
||||
emojis.push(resolvedEmoji);
|
||||
}
|
||||
}
|
||||
|
||||
const createdNote = await Note.fromData(
|
||||
author,
|
||||
note.content ?? {
|
||||
"text/plain": {
|
||||
content: "",
|
||||
},
|
||||
},
|
||||
note.visibility as APIStatus["visibility"],
|
||||
note.is_sensitive ?? false,
|
||||
note.subject ?? "",
|
||||
emojis,
|
||||
note.uri,
|
||||
await Promise.all(
|
||||
(note.mentions ?? [])
|
||||
.map((mention) => User.resolve(mention))
|
||||
.filter((mention) => mention !== null) as Promise<User>[],
|
||||
),
|
||||
attachments.map((a) => a.id),
|
||||
note.replies_to
|
||||
? (await resolveNote(note.replies_to)).getStatus().id
|
||||
: undefined,
|
||||
note.quotes
|
||||
? (await resolveNote(note.quotes)).getStatus().id
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (!createdNote) {
|
||||
throw new Error("Failed to create status");
|
||||
}
|
||||
|
||||
return createdNote;
|
||||
};
|
||||
|
||||
export const createMentionRegExp = () =>
|
||||
createRegExp(
|
||||
exactly("@"),
|
||||
oneOrMore(anyOf(letter.lowercase, digit, charIn("-"))).groupedAs(
|
||||
"username",
|
||||
),
|
||||
maybe(
|
||||
exactly("@"),
|
||||
oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs("domain"),
|
||||
),
|
||||
[global],
|
||||
);
|
||||
|
||||
/**
|
||||
* Get people mentioned in the content (match @username or @username@domain.com mentions)
|
||||
* @param text The text to parse mentions from.
|
||||
* @returns An array of users mentioned in the text.
|
||||
*/
|
||||
export const parseTextMentions = async (text: string): Promise<User[]> => {
|
||||
const mentionedPeople = [...text.matchAll(createMentionRegExp())] ?? [];
|
||||
if (mentionedPeople.length === 0) return [];
|
||||
|
||||
const baseUrlHost = new URL(config.http.base_url).host;
|
||||
|
||||
const isLocal = (host?: string) => host === baseUrlHost || !host;
|
||||
|
||||
const foundUsers = await db
|
||||
.select({
|
||||
id: Users.id,
|
||||
username: Users.username,
|
||||
baseUrl: Instances.baseUrl,
|
||||
})
|
||||
.from(Users)
|
||||
.leftJoin(Instances, eq(Users.instanceId, Instances.id))
|
||||
.where(
|
||||
or(
|
||||
...mentionedPeople.map((person) =>
|
||||
and(
|
||||
eq(Users.username, person?.[1] ?? ""),
|
||||
isLocal(person?.[2])
|
||||
? isNull(Users.instanceId)
|
||||
: eq(Instances.baseUrl, person?.[2] ?? ""),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const notFoundRemoteUsers = mentionedPeople.filter(
|
||||
(person) =>
|
||||
!isLocal(person?.[2]) &&
|
||||
!foundUsers.find(
|
||||
(user) =>
|
||||
user.username === person?.[1] &&
|
||||
user.baseUrl === person?.[2],
|
||||
),
|
||||
);
|
||||
|
||||
const finalList =
|
||||
foundUsers.length > 0
|
||||
? await User.manyFromSql(
|
||||
inArray(
|
||||
Users.id,
|
||||
foundUsers.map((u) => u.id),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
|
||||
// Attempt to resolve mentions that were not found
|
||||
for (const person of notFoundRemoteUsers) {
|
||||
const user = await resolveWebFinger(
|
||||
person?.[1] ?? "",
|
||||
person?.[2] ?? "",
|
||||
);
|
||||
|
||||
if (user) {
|
||||
finalList.push(user);
|
||||
}
|
||||
}
|
||||
|
||||
return finalList;
|
||||
};
|
||||
|
||||
export const replaceTextMentions = async (text: string, mentions: User[]) => {
|
||||
let finalText = text;
|
||||
for (const mention of mentions) {
|
||||
const user = mention.getUser();
|
||||
// Replace @username and @username@domain
|
||||
if (user.instance) {
|
||||
finalText = finalText.replace(
|
||||
createRegExp(
|
||||
exactly(`@${user.username}@${user.instance.baseUrl}`),
|
||||
[global],
|
||||
),
|
||||
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${
|
||||
user.username
|
||||
}@${user.instance.baseUrl}</a>`,
|
||||
);
|
||||
} else {
|
||||
finalText = finalText.replace(
|
||||
// Only replace @username if it doesn't have another @ right after
|
||||
createRegExp(
|
||||
exactly(`@${user.username}`)
|
||||
.notBefore(anyOf(letter, digit, charIn("@")))
|
||||
.notAfter(anyOf(letter, digit, charIn("@"))),
|
||||
[global],
|
||||
),
|
||||
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${
|
||||
user.username
|
||||
}</a>`,
|
||||
);
|
||||
|
||||
finalText = finalText.replace(
|
||||
createRegExp(
|
||||
exactly(
|
||||
`@${user.username}@${
|
||||
new URL(config.http.base_url).host
|
||||
}`,
|
||||
),
|
||||
[global],
|
||||
),
|
||||
`<a class="u-url mention" rel="nofollow noopener noreferrer" target="_blank" href="${mention.getUri()}">@${
|
||||
user.username
|
||||
}</a>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return finalText;
|
||||
};
|
||||
|
||||
export const contentToHtml = async (
|
||||
content: Lysand.ContentFormat,
|
||||
mentions: User[] = [],
|
||||
): Promise<string> => {
|
||||
let htmlContent: string;
|
||||
|
||||
if (content["text/html"]) {
|
||||
htmlContent = await sanitizeHtml(content["text/html"].content);
|
||||
} else if (content["text/markdown"]) {
|
||||
htmlContent = await sanitizeHtml(
|
||||
await markdownParse(content["text/markdown"].content),
|
||||
);
|
||||
} else if (content["text/plain"]?.content) {
|
||||
// Split by newline and add <p> tags
|
||||
htmlContent = (await sanitizeHtml(content["text/plain"].content))
|
||||
.split("\n")
|
||||
.map((line) => `<p>${line}</p>`)
|
||||
.join("\n");
|
||||
} else {
|
||||
htmlContent = "";
|
||||
}
|
||||
|
||||
// Replace mentions text
|
||||
htmlContent = await replaceTextMentions(htmlContent, mentions ?? []);
|
||||
|
||||
// Linkify
|
||||
htmlContent = linkifyHtml(htmlContent, {
|
||||
defaultProtocol: "https",
|
||||
validate: {
|
||||
email: () => false,
|
||||
},
|
||||
target: "_blank",
|
||||
rel: "nofollow noopener noreferrer",
|
||||
});
|
||||
|
||||
return htmlContent;
|
||||
};
|
||||
|
||||
export const markdownParse = async (content: string) => {
|
||||
return (await getMarkdownRenderer()).render(content);
|
||||
};
|
||||
|
||||
export const getMarkdownRenderer = async () => {
|
||||
const renderer = MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
});
|
||||
|
||||
renderer.use(markdownItAnchor, {
|
||||
permalink: markdownItAnchor.permalink.ariaHidden({
|
||||
symbol: "",
|
||||
placement: "before",
|
||||
}),
|
||||
});
|
||||
|
||||
renderer.use(markdownItTocDoneRight, {
|
||||
containerClass: "toc",
|
||||
level: [1, 2, 3, 4],
|
||||
listType: "ul",
|
||||
listClass: "toc-list",
|
||||
itemClass: "toc-item",
|
||||
linkClass: "toc-link",
|
||||
});
|
||||
|
||||
renderer.use(markdownItTaskLists);
|
||||
|
||||
renderer.use(markdownItContainer);
|
||||
|
||||
return renderer;
|
||||
};
|
||||
|
||||
export const federateNote = async (note: Note) => {
|
||||
for (const user of await note.getUsersToFederateTo()) {
|
||||
// TODO: Add queue system
|
||||
const request = await objectToInboxRequest(
|
||||
note.toLysand(),
|
||||
note.getAuthor(),
|
||||
user,
|
||||
);
|
||||
|
||||
// Send request
|
||||
const response = await fetch(request);
|
||||
|
||||
if (!response.ok) {
|
||||
dualLogger.log(
|
||||
LogLevel.DEBUG,
|
||||
"Federation.Status",
|
||||
await response.text(),
|
||||
);
|
||||
dualLogger.log(
|
||||
LogLevel.ERROR,
|
||||
"Federation.Status",
|
||||
`Failed to federate status ${
|
||||
note.getStatus().id
|
||||
} to ${user.getUri()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const isFavouritedBy = async (status: Status, user: UserType) => {
|
||||
return !!(await db.query.Likes.findFirst({
|
||||
where: (like, { and, eq }) =>
|
||||
and(eq(like.likerId, user.id), eq(like.likedId, status.id)),
|
||||
}));
|
||||
};
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { Tokens } from "~drizzle/schema";
|
||||
|
||||
/**
|
||||
* The type of token.
|
||||
*/
|
||||
export enum TokenType {
|
||||
BEARER = "Bearer",
|
||||
}
|
||||
|
||||
export type Token = InferSelectModel<typeof Tokens>;
|
||||
|
|
@ -1,550 +0,0 @@
|
|||
import { dualLogger } from "@loggers";
|
||||
import { addUserToMeilisearch } from "@meilisearch";
|
||||
import { config } from "config-manager";
|
||||
import { type InferSelectModel, and, eq, inArray, sql } from "drizzle-orm";
|
||||
import type * as Lysand from "lysand-types";
|
||||
import { db } from "~drizzle/db";
|
||||
import {
|
||||
Applications,
|
||||
Instances,
|
||||
Notifications,
|
||||
Relationships,
|
||||
Tokens,
|
||||
Users,
|
||||
} from "~drizzle/schema";
|
||||
import { User } from "~packages/database-interface/user";
|
||||
import { LogLevel } from "~packages/log-manager";
|
||||
import type { Application } from "./Application";
|
||||
import type { EmojiWithInstance } from "./Emoji";
|
||||
import { objectToInboxRequest } from "./Federation";
|
||||
import { createNewRelationship } from "./Relationship";
|
||||
import type { Token } from "./Token";
|
||||
|
||||
export type UserType = InferSelectModel<typeof Users>;
|
||||
|
||||
export type UserWithInstance = UserType & {
|
||||
instance: InferSelectModel<typeof Instances> | null;
|
||||
};
|
||||
|
||||
export type UserWithRelations = UserType & {
|
||||
instance: InferSelectModel<typeof Instances> | null;
|
||||
emojis: EmojiWithInstance[];
|
||||
followerCount: number;
|
||||
followingCount: number;
|
||||
statusCount: number;
|
||||
};
|
||||
|
||||
export type UserWithRelationsAndRelationships = UserWithRelations & {
|
||||
relationships: InferSelectModel<typeof Relationships>[];
|
||||
relationshipSubjects: InferSelectModel<typeof Relationships>[];
|
||||
};
|
||||
|
||||
export const userRelations: {
|
||||
instance: true;
|
||||
emojis: {
|
||||
with: {
|
||||
emoji: {
|
||||
with: {
|
||||
instance: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
} = {
|
||||
instance: true,
|
||||
emojis: {
|
||||
with: {
|
||||
emoji: {
|
||||
with: {
|
||||
instance: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const userExtras = {
|
||||
followerCount:
|
||||
sql`(SELECT COUNT(*) FROM "Relationships" "relationships" WHERE ("relationships"."ownerId" = "Users".id AND "relationships"."following" = true))`.as(
|
||||
"follower_count",
|
||||
),
|
||||
followingCount:
|
||||
sql`(SELECT COUNT(*) FROM "Relationships" "relationshipSubjects" WHERE ("relationshipSubjects"."subjectId" = "Users".id AND "relationshipSubjects"."following" = true))`.as(
|
||||
"following_count",
|
||||
),
|
||||
statusCount:
|
||||
sql`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."authorId" = "Users".id)`.as(
|
||||
"status_count",
|
||||
),
|
||||
};
|
||||
|
||||
export const userExtrasTemplate = (name: string) => ({
|
||||
// @ts-ignore
|
||||
followerCount: sql([
|
||||
`(SELECT COUNT(*) FROM "Relationships" "relationships" WHERE ("relationships"."ownerId" = "${name}".id AND "relationships"."following" = true))`,
|
||||
]).as("follower_count"),
|
||||
// @ts-ignore
|
||||
followingCount: sql([
|
||||
`(SELECT COUNT(*) FROM "Relationships" "relationshipSubjects" WHERE ("relationshipSubjects"."subjectId" = "${name}".id AND "relationshipSubjects"."following" = true))`,
|
||||
]).as("following_count"),
|
||||
// @ts-ignore
|
||||
statusCount: sql([
|
||||
`(SELECT COUNT(*) FROM "Notes" WHERE "Notes"."authorId" = "${name}".id)`,
|
||||
]).as("status_count"),
|
||||
});
|
||||
|
||||
export interface AuthData {
|
||||
user: User | null;
|
||||
token: string;
|
||||
application: Application | null;
|
||||
}
|
||||
|
||||
export const getFromRequest = async (req: Request): Promise<AuthData> => {
|
||||
// Check auth token
|
||||
const token = req.headers.get("Authorization")?.split(" ")[1] || "";
|
||||
|
||||
const { user, application } =
|
||||
await retrieveUserAndApplicationFromToken(token);
|
||||
|
||||
return { user, token, application };
|
||||
};
|
||||
|
||||
export const getFromHeader = async (value: string): Promise<AuthData> => {
|
||||
const token = value.split(" ")[1];
|
||||
|
||||
const { user, application } =
|
||||
await retrieveUserAndApplicationFromToken(token);
|
||||
|
||||
return { user, token, application };
|
||||
};
|
||||
|
||||
export const followRequestUser = async (
|
||||
follower: User,
|
||||
followee: User,
|
||||
relationshipId: string,
|
||||
reblogs = false,
|
||||
notify = false,
|
||||
languages: string[] = [],
|
||||
): Promise<InferSelectModel<typeof Relationships>> => {
|
||||
const isRemote = followee.isRemote();
|
||||
|
||||
const updatedRelationship = (
|
||||
await db
|
||||
.update(Relationships)
|
||||
.set({
|
||||
following: isRemote ? false : !followee.getUser().isLocked,
|
||||
requested: isRemote ? true : followee.getUser().isLocked,
|
||||
showingReblogs: reblogs,
|
||||
notifying: notify,
|
||||
languages: languages,
|
||||
})
|
||||
.where(eq(Relationships.id, relationshipId))
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
if (isRemote) {
|
||||
// Federate
|
||||
// TODO: Make database job
|
||||
const request = await objectToInboxRequest(
|
||||
followRequestToLysand(follower, followee),
|
||||
follower,
|
||||
followee,
|
||||
);
|
||||
|
||||
// Send request
|
||||
const response = await fetch(request);
|
||||
|
||||
if (!response.ok) {
|
||||
dualLogger.log(
|
||||
LogLevel.DEBUG,
|
||||
"Federation.FollowRequest",
|
||||
await response.text(),
|
||||
);
|
||||
|
||||
dualLogger.log(
|
||||
LogLevel.ERROR,
|
||||
"Federation.FollowRequest",
|
||||
`Failed to federate follow request from ${
|
||||
follower.id
|
||||
} to ${followee.getUri()}`,
|
||||
);
|
||||
|
||||
return (
|
||||
await db
|
||||
.update(Relationships)
|
||||
.set({
|
||||
following: false,
|
||||
requested: false,
|
||||
})
|
||||
.where(eq(Relationships.id, relationshipId))
|
||||
.returning()
|
||||
)[0];
|
||||
}
|
||||
} else {
|
||||
await db.insert(Notifications).values({
|
||||
accountId: follower.id,
|
||||
type: followee.getUser().isLocked ? "follow_request" : "follow",
|
||||
notifiedId: followee.id,
|
||||
});
|
||||
}
|
||||
|
||||
return updatedRelationship;
|
||||
};
|
||||
|
||||
export const sendFollowAccept = async (follower: User, followee: User) => {
|
||||
// TODO: Make database job
|
||||
const request = await objectToInboxRequest(
|
||||
followAcceptToLysand(follower, followee),
|
||||
followee,
|
||||
follower,
|
||||
);
|
||||
|
||||
// Send request
|
||||
const response = await fetch(request);
|
||||
|
||||
if (!response.ok) {
|
||||
dualLogger.log(
|
||||
LogLevel.DEBUG,
|
||||
"Federation.FollowAccept",
|
||||
await response.text(),
|
||||
);
|
||||
|
||||
dualLogger.log(
|
||||
LogLevel.ERROR,
|
||||
"Federation.FollowAccept",
|
||||
`Failed to federate follow accept from ${
|
||||
followee.id
|
||||
} to ${follower.getUri()}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const sendFollowReject = async (follower: User, followee: User) => {
|
||||
// TODO: Make database job
|
||||
const request = await objectToInboxRequest(
|
||||
followRejectToLysand(follower, followee),
|
||||
followee,
|
||||
follower,
|
||||
);
|
||||
|
||||
// Send request
|
||||
const response = await fetch(request);
|
||||
|
||||
if (!response.ok) {
|
||||
dualLogger.log(
|
||||
LogLevel.DEBUG,
|
||||
"Federation.FollowReject",
|
||||
await response.text(),
|
||||
);
|
||||
|
||||
dualLogger.log(
|
||||
LogLevel.ERROR,
|
||||
"Federation.FollowReject",
|
||||
`Failed to federate follow reject from ${
|
||||
followee.id
|
||||
} to ${follower.getUri()}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const transformOutputToUserWithRelations = (
|
||||
user: Omit<UserType, "endpoints"> & {
|
||||
followerCount: unknown;
|
||||
followingCount: unknown;
|
||||
statusCount: unknown;
|
||||
emojis: {
|
||||
userId: string;
|
||||
emojiId: string;
|
||||
emoji?: EmojiWithInstance;
|
||||
}[];
|
||||
instance: InferSelectModel<typeof Instances> | null;
|
||||
endpoints: unknown;
|
||||
},
|
||||
): UserWithRelations => {
|
||||
return {
|
||||
...user,
|
||||
followerCount: Number(user.followerCount),
|
||||
followingCount: Number(user.followingCount),
|
||||
statusCount: Number(user.statusCount),
|
||||
endpoints:
|
||||
user.endpoints ??
|
||||
({} as Partial<{
|
||||
dislikes: string;
|
||||
featured: string;
|
||||
likes: string;
|
||||
followers: string;
|
||||
following: string;
|
||||
inbox: string;
|
||||
outbox: string;
|
||||
}>),
|
||||
emojis: user.emojis.map(
|
||||
(emoji) =>
|
||||
(emoji as unknown as Record<string, object>)
|
||||
.emoji as EmojiWithInstance,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const findManyUsers = async (
|
||||
query: Parameters<typeof db.query.Users.findMany>[0],
|
||||
): Promise<UserWithRelations[]> => {
|
||||
const output = await db.query.Users.findMany({
|
||||
...query,
|
||||
with: {
|
||||
...userRelations,
|
||||
...query?.with,
|
||||
},
|
||||
extras: {
|
||||
...userExtras,
|
||||
...query?.extras,
|
||||
},
|
||||
});
|
||||
|
||||
return output.map((user) => transformOutputToUserWithRelations(user));
|
||||
};
|
||||
|
||||
export const findFirstUser = async (
|
||||
query: Parameters<typeof db.query.Users.findFirst>[0],
|
||||
): Promise<UserWithRelations | null> => {
|
||||
const output = await db.query.Users.findFirst({
|
||||
...query,
|
||||
with: {
|
||||
...userRelations,
|
||||
...query?.with,
|
||||
},
|
||||
extras: {
|
||||
...userExtras,
|
||||
...query?.extras,
|
||||
},
|
||||
});
|
||||
|
||||
if (!output) return null;
|
||||
|
||||
return transformOutputToUserWithRelations(output);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a WebFinger identifier to a user.
|
||||
* @param identifier Either a UUID or a username
|
||||
*/
|
||||
export const resolveWebFinger = async (
|
||||
identifier: string,
|
||||
host: string,
|
||||
): Promise<User | null> => {
|
||||
// Check if user not already in database
|
||||
const foundUser = await db
|
||||
.select()
|
||||
.from(Users)
|
||||
.innerJoin(Instances, eq(Users.instanceId, Instances.id))
|
||||
.where(and(eq(Users.username, identifier), eq(Instances.baseUrl, host)))
|
||||
.limit(1);
|
||||
|
||||
if (foundUser[0]) return await User.fromId(foundUser[0].Users.id);
|
||||
|
||||
const hostWithProtocol = host.startsWith("http") ? host : `https://${host}`;
|
||||
|
||||
const response = await fetch(
|
||||
new URL(
|
||||
`/.well-known/webfinger?${new URLSearchParams({
|
||||
resource: `acct:${identifier}@${host}`,
|
||||
})}`,
|
||||
hostWithProtocol,
|
||||
),
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
subject: string;
|
||||
links: {
|
||||
rel: string;
|
||||
type: string;
|
||||
href: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
if (!data.subject || !data.links) {
|
||||
throw new Error(
|
||||
"Invalid WebFinger data (missing subject or links from response)",
|
||||
);
|
||||
}
|
||||
|
||||
const relevantLink = data.links.find((link) => link.rel === "self");
|
||||
|
||||
if (!relevantLink) {
|
||||
throw new Error(
|
||||
"Invalid WebFinger data (missing link with rel: 'self')",
|
||||
);
|
||||
}
|
||||
|
||||
return User.resolve(relevantLink.href);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses mentions from a list of URIs
|
||||
*/
|
||||
export const parseMentionsUris = async (
|
||||
mentions: string[],
|
||||
): Promise<User[]> => {
|
||||
return await User.manyFromSql(inArray(Users.uri, mentions));
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves a user from a token.
|
||||
* @param access_token The access token to retrieve the user from.
|
||||
* @returns The user associated with the given access token.
|
||||
*/
|
||||
export const retrieveUserFromToken = async (
|
||||
access_token: string,
|
||||
): Promise<User | null> => {
|
||||
if (!access_token) return null;
|
||||
|
||||
const token = await retrieveToken(access_token);
|
||||
|
||||
if (!token || !token.userId) return null;
|
||||
|
||||
const user = await User.fromId(token.userId);
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
export const retrieveUserAndApplicationFromToken = async (
|
||||
access_token: string,
|
||||
): Promise<{
|
||||
user: User | null;
|
||||
application: Application | null;
|
||||
}> => {
|
||||
if (!access_token) return { user: null, application: null };
|
||||
|
||||
const output = (
|
||||
await db
|
||||
.select({
|
||||
token: Tokens,
|
||||
application: Applications,
|
||||
})
|
||||
.from(Tokens)
|
||||
.leftJoin(Applications, eq(Tokens.applicationId, Applications.id))
|
||||
.where(eq(Tokens.accessToken, access_token))
|
||||
.limit(1)
|
||||
)[0];
|
||||
|
||||
if (!output?.token.userId) return { user: null, application: null };
|
||||
|
||||
const user = await User.fromId(output.token.userId);
|
||||
|
||||
return { user, application: output.application ?? null };
|
||||
};
|
||||
|
||||
export const retrieveToken = async (
|
||||
access_token: string,
|
||||
): Promise<Token | null> => {
|
||||
if (!access_token) return null;
|
||||
|
||||
return (
|
||||
(await db.query.Tokens.findFirst({
|
||||
where: (tokens, { eq }) => eq(tokens.accessToken, access_token),
|
||||
})) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the relationship to another user.
|
||||
* @param other The other user to get the relationship to.
|
||||
* @returns The relationship to the other user.
|
||||
*/
|
||||
export const getRelationshipToOtherUser = async (
|
||||
user: User,
|
||||
other: User,
|
||||
): Promise<InferSelectModel<typeof Relationships>> => {
|
||||
const foundRelationship = await db.query.Relationships.findFirst({
|
||||
where: (relationship, { and, eq }) =>
|
||||
and(
|
||||
eq(relationship.ownerId, user.id),
|
||||
eq(relationship.subjectId, other.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!foundRelationship) {
|
||||
// Create new relationship
|
||||
|
||||
const newRelationship = await createNewRelationship(user, other);
|
||||
|
||||
return newRelationship;
|
||||
}
|
||||
|
||||
return foundRelationship;
|
||||
};
|
||||
|
||||
export const followRequestToLysand = (
|
||||
follower: User,
|
||||
followee: User,
|
||||
): Lysand.Follow => {
|
||||
if (follower.isRemote()) {
|
||||
throw new Error("Follower must be a local user");
|
||||
}
|
||||
|
||||
if (!followee.isRemote()) {
|
||||
throw new Error("Followee must be a remote user");
|
||||
}
|
||||
|
||||
if (!followee.getUser().uri) {
|
||||
throw new Error("Followee must have a URI in database");
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
return {
|
||||
type: "Follow",
|
||||
id: id,
|
||||
author: follower.getUri(),
|
||||
followee: followee.getUri(),
|
||||
created_at: new Date().toISOString(),
|
||||
uri: new URL(`/follows/${id}`, config.http.base_url).toString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const followAcceptToLysand = (
|
||||
follower: User,
|
||||
followee: User,
|
||||
): Lysand.FollowAccept => {
|
||||
if (!follower.isRemote()) {
|
||||
throw new Error("Follower must be a remote user");
|
||||
}
|
||||
|
||||
if (followee.isRemote()) {
|
||||
throw new Error("Followee must be a local user");
|
||||
}
|
||||
|
||||
if (!follower.getUser().uri) {
|
||||
throw new Error("Follower must have a URI in database");
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
return {
|
||||
type: "FollowAccept",
|
||||
id: id,
|
||||
author: followee.getUri(),
|
||||
created_at: new Date().toISOString(),
|
||||
follower: follower.getUri(),
|
||||
uri: new URL(`/follows/${id}`, config.http.base_url).toString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const followRejectToLysand = (
|
||||
follower: User,
|
||||
followee: User,
|
||||
): Lysand.FollowReject => {
|
||||
return {
|
||||
...followAcceptToLysand(follower, followee),
|
||||
type: "FollowReject",
|
||||
};
|
||||
};
|
||||
|
|
@ -1,64 +1,65 @@
|
|||
services:
|
||||
lysand:
|
||||
build: ghcr.io/lysand-org/lysand:main
|
||||
versia:
|
||||
image: ghcr.io/versia-pub/server:main
|
||||
volumes:
|
||||
- ./logs:/app/dist/logs
|
||||
- ./config:/app/dist/config
|
||||
- ./config:/app/dist/config:ro
|
||||
- ./uploads:/app/dist/uploads
|
||||
- ./glitch:/app/dist/glitch
|
||||
restart: unless-stopped
|
||||
container_name: lysand
|
||||
container_name: versia
|
||||
tty: true
|
||||
networks:
|
||||
- lysand-net
|
||||
depends-on:
|
||||
- versia-net
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
- meilisearch
|
||||
- fe
|
||||
|
||||
fe:
|
||||
image: ghcr.io/lysand-org/lysand-fe:main
|
||||
container_name: lysand-fe
|
||||
- sonic
|
||||
|
||||
worker:
|
||||
image: ghcr.io/versia-pub/worker:main
|
||||
volumes:
|
||||
- ./logs:/app/dist/logs
|
||||
- ./config:/app/dist/config:ro
|
||||
restart: unless-stopped
|
||||
container_name: versia-worker
|
||||
tty: true
|
||||
networks:
|
||||
- lysand-net
|
||||
environment:
|
||||
NUXT_PUBLIC_API_HOST: https://yourserver.com
|
||||
|
||||
- versia-net
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
|
||||
db:
|
||||
image: ghcr.io/lysand-org/postgres:main
|
||||
container_name: lysand-db
|
||||
image: postgres:17-alpine
|
||||
container_name: versia-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: lysand
|
||||
POSTGRES_USER: lysand
|
||||
POSTGRES_PASSWORD: _______________
|
||||
POSTGRES_DB: versia
|
||||
POSTGRES_USER: versia
|
||||
POSTGRES_PASSWORD: versia
|
||||
networks:
|
||||
- lysand-net
|
||||
- versia-net
|
||||
volumes:
|
||||
- ./db-data:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: lysand-redis
|
||||
container_name: versia-redis
|
||||
volumes:
|
||||
- ./redis-data:/data
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- lysand-net
|
||||
- versia-net
|
||||
|
||||
meilisearch:
|
||||
stdin_open: true
|
||||
environment:
|
||||
- MEILI_MASTER_KEY=__________________
|
||||
tty: true
|
||||
networks:
|
||||
- lysand-net
|
||||
sonic:
|
||||
volumes:
|
||||
- ./meili-data:/meili_data
|
||||
image: getmeili/meilisearch:v1.7
|
||||
container_name: lysand-meilisearch
|
||||
- ./config.cfg:/etc/sonic.cfg
|
||||
- ./store:/var/lib/sonic/store/
|
||||
image: valeriansaliou/sonic:v1.4.9
|
||||
container_name: versia-sonic
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- versia-net
|
||||
|
||||
networks:
|
||||
lysand-net:
|
||||
versia-net:
|
||||
|
|
|
|||
98
docs/.vitepress/config.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import taskLists from "@hackmd/markdown-it-task-lists";
|
||||
import implicitFigures from "markdown-it-image-figures";
|
||||
import { defineConfig } from "vitepress";
|
||||
import { tabsMarkdownPlugin } from "vitepress-plugin-tabs";
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
title: "Versia Server Docs",
|
||||
lang: "en-US",
|
||||
description: "Documentation for Versia Server APIs",
|
||||
markdown: {
|
||||
config: (md): void => {
|
||||
md.use(implicitFigures, {
|
||||
figcaption: "alt",
|
||||
copyAttrs: "^class$",
|
||||
});
|
||||
|
||||
md.use(taskLists);
|
||||
|
||||
md.use(tabsMarkdownPlugin);
|
||||
},
|
||||
math: true,
|
||||
},
|
||||
cleanUrls: true,
|
||||
themeConfig: {
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
nav: [
|
||||
{ text: "Home", link: "/" },
|
||||
{
|
||||
text: "Versia Protocol",
|
||||
link: "https://versia.pub",
|
||||
target: "_blank",
|
||||
},
|
||||
],
|
||||
|
||||
sidebar: [
|
||||
{
|
||||
text: "Installation",
|
||||
items: [
|
||||
{
|
||||
text: "Normal",
|
||||
link: "/setup/installation",
|
||||
},
|
||||
{
|
||||
text: "Nix",
|
||||
link: "/setup/nix",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "CLI",
|
||||
link: "/cli",
|
||||
},
|
||||
{
|
||||
text: "API",
|
||||
items: [
|
||||
{
|
||||
text: "Reactions",
|
||||
link: "/api/reactions",
|
||||
},
|
||||
{
|
||||
text: "Challenges",
|
||||
link: "/api/challenges",
|
||||
},
|
||||
{
|
||||
text: "Mastodon Extensions",
|
||||
link: "/api/mastodon",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Frontend",
|
||||
items: [
|
||||
{
|
||||
text: "Authentication",
|
||||
link: "/frontend/auth",
|
||||
},
|
||||
{
|
||||
text: "Routes",
|
||||
link: "/frontend/routes",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
socialLinks: [
|
||||
{ icon: "github", link: "https://github.com/versia-pub/server" },
|
||||
],
|
||||
|
||||
search: {
|
||||
provider: "local",
|
||||
},
|
||||
|
||||
logo: "https://cdn.versia.pub/branding/icon.svg",
|
||||
},
|
||||
head: [["link", { rel: "icon", href: "/favicon.png", type: "image/png" }]],
|
||||
titleTemplate: ":title • Versia Server Docs",
|
||||
});
|
||||
14
docs/.vitepress/theme/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { Theme } from "vitepress";
|
||||
import DefaultTheme from "vitepress/theme";
|
||||
// https://vitepress.dev/guide/custom-theme
|
||||
import { h, type VNode } from "vue";
|
||||
import "./style.css";
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
Layout: (): VNode => {
|
||||
return h(DefaultTheme.Layout, null, {
|
||||
// https://vitepress.dev/guide/extending-default-theme#layout-slots
|
||||
});
|
||||
},
|
||||
} satisfies Theme;
|
||||
138
docs/.vitepress/theme/style.css
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* Customize default theme styling by overriding CSS variables:
|
||||
* https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css
|
||||
*/
|
||||
|
||||
/**
|
||||
* Colors
|
||||
*
|
||||
* Each colors have exact same color scale system with 3 levels of solid
|
||||
* colors with different brightness, and 1 soft color.
|
||||
*
|
||||
* - `XXX-1`: The most solid color used mainly for colored text. It must
|
||||
* satisfy the contrast ratio against when used on top of `XXX-soft`.
|
||||
*
|
||||
* - `XXX-2`: The color used mainly for hover state of the button.
|
||||
*
|
||||
* - `XXX-3`: The color for solid background, such as bg color of the button.
|
||||
* It must satisfy the contrast ratio with pure white (#ffffff) text on
|
||||
* top of it.
|
||||
*
|
||||
* - `XXX-soft`: The color used for subtle background such as custom container
|
||||
* or badges. It must satisfy the contrast ratio when putting `XXX-1` colors
|
||||
* on top of it.
|
||||
*
|
||||
* The soft color must be semi transparent alpha channel. This is crucial
|
||||
* because it allows adding multiple "soft" colors on top of each other
|
||||
* to create a accent, such as when having inline code block inside
|
||||
* custom containers.
|
||||
*
|
||||
* - `default`: The color used purely for subtle indication without any
|
||||
* special meanings attached to it such as bg color for menu hover state.
|
||||
*
|
||||
* - `brand`: Used for primary brand colors, such as link text, button with
|
||||
* brand theme, etc.
|
||||
*
|
||||
* - `tip`: Used to indicate useful information. The default theme uses the
|
||||
* brand color for this by default.
|
||||
*
|
||||
* - `warning`: Used to indicate warning to the users. Used in custom
|
||||
* container, badges, etc.
|
||||
*
|
||||
* - `danger`: Used to show error, or dangerous message to the users. Used
|
||||
* in custom container, badges, etc.
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--vp-c-default-1: var(--vp-c-gray-1);
|
||||
--vp-c-default-2: var(--vp-c-gray-2);
|
||||
--vp-c-default-3: var(--vp-c-gray-3);
|
||||
--vp-c-default-soft: var(--vp-c-gray-soft);
|
||||
|
||||
--vp-c-brand-1: var(--vp-c-indigo-1);
|
||||
--vp-c-brand-2: var(--vp-c-indigo-2);
|
||||
--vp-c-brand-3: var(--vp-c-indigo-3);
|
||||
--vp-c-brand-soft: var(--vp-c-indigo-soft);
|
||||
|
||||
--vp-c-tip-1: var(--vp-c-brand-1);
|
||||
--vp-c-tip-2: var(--vp-c-brand-2);
|
||||
--vp-c-tip-3: var(--vp-c-brand-3);
|
||||
--vp-c-tip-soft: var(--vp-c-brand-soft);
|
||||
|
||||
--vp-c-warning-1: var(--vp-c-yellow-1);
|
||||
--vp-c-warning-2: var(--vp-c-yellow-2);
|
||||
--vp-c-warning-3: var(--vp-c-yellow-3);
|
||||
--vp-c-warning-soft: var(--vp-c-yellow-soft);
|
||||
|
||||
--vp-c-danger-1: var(--vp-c-red-1);
|
||||
--vp-c-danger-2: var(--vp-c-red-2);
|
||||
--vp-c-danger-3: var(--vp-c-red-3);
|
||||
--vp-c-danger-soft: var(--vp-c-red-soft);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Button
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--vp-button-brand-border: transparent;
|
||||
--vp-button-brand-text: var(--vp-c-white);
|
||||
--vp-button-brand-bg: var(--vp-c-brand-3);
|
||||
--vp-button-brand-hover-border: transparent;
|
||||
--vp-button-brand-hover-text: var(--vp-c-white);
|
||||
--vp-button-brand-hover-bg: var(--vp-c-brand-2);
|
||||
--vp-button-brand-active-border: transparent;
|
||||
--vp-button-brand-active-text: var(--vp-c-white);
|
||||
--vp-button-brand-active-bg: var(--vp-c-brand-1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Home
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--vp-home-hero-name-color: transparent;
|
||||
--vp-home-hero-name-background: -webkit-linear-gradient(
|
||||
120deg,
|
||||
#e6a9fe 30%,
|
||||
#bd34fe
|
||||
);
|
||||
|
||||
--vp-home-hero-image-background-image: linear-gradient(
|
||||
-45deg,
|
||||
#e6a9fe 50%,
|
||||
#bd34fe 50%
|
||||
);
|
||||
--vp-home-hero-image-filter: blur(44px);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
:root {
|
||||
--vp-home-hero-image-filter: blur(56px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
:root {
|
||||
--vp-home-hero-image-filter: blur(68px);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Custom Block
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--vp-custom-block-tip-border: transparent;
|
||||
--vp-custom-block-tip-text: var(--vp-c-text-1);
|
||||
--vp-custom-block-tip-bg: var(--vp-c-brand-soft);
|
||||
--vp-custom-block-tip-code-bg: var(--vp-c-brand-soft);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component: Algolia
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
.DocSearch {
|
||||
--docsearch-primary-color: var(--vp-c-brand-1) !important;
|
||||
}
|
||||
37
docs/api/challenges.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Challenges API
|
||||
|
||||
Some API routes may require a cryptographic challenge to be solved before the request can be made. This is to prevent abuse of the API by bots and other malicious actors. The challenge is a simple mathematical problem that can be solved by any client.
|
||||
|
||||
This is a form of proof of work CAPTCHA, and should be mostly invisible to users. The challenge is generated by the server and sent to the client, which must solve it and send the solution back to the server.
|
||||
|
||||
## Solving a Challenge
|
||||
|
||||
Challenges are powered by the [Altcha](https://altcha.org/) library. You may either reimplement their solution code (which is very simple), or use [`altcha-lib`](https://github.com/altcha-org/altcha-lib) to solve the challenges.
|
||||
|
||||
## Request Challenge
|
||||
|
||||
To request a challenge, you may use the [`POST /api/v1/challenges`](https://vs.cpluspatch.com/docs#tag/challenges/POST/api/v1/challenges) endpoint.
|
||||
|
||||
## Sending a Solution
|
||||
|
||||
To send a solution with any request, add the following headers:
|
||||
- `X-Challenge-Solution`: A base64 encoded string of the following JSON object:
|
||||
```ts
|
||||
{
|
||||
number: number; // Solution to the challenge
|
||||
algorithm: "SHA-256" | "SHA-384" | "SHA-512";
|
||||
challenge: string;
|
||||
salt: string,
|
||||
signature: string,
|
||||
}
|
||||
```
|
||||
Example: `{"number": 42, "algorithm": "SHA-256", "challenge": "xxxx", "salt": "abc", "signature": "def"}` -> `eyJudW1iZXIiOjQyLCJhbGdvcml0aG0iOiJTSEEtMjU2IiwiY2hhbGxlbmdlIjoieHh4eCIsInNhbHQiOiJhYmMiLCJzaWduYXR1cmUiOiJkZWYifQ==`
|
||||
|
||||
A challenge solution is valid for 5 minutes (configurable) after the challenge is generated. No solved challenge may be used more than once.
|
||||
|
||||
## Routes Requiring Challenges
|
||||
|
||||
If challenges are enabled, the following routes will require a challenge to be solved before the request can be made:
|
||||
- `POST /api/v1/accounts`
|
||||
|
||||
Routes requiring challenges may eventually be expanded or made configurable.
|
||||
420
docs/api/mastodon.md
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
# Mastodon API Extensions
|
||||
|
||||
Versia Server extends several Mastodon API endpoints to provide additional functionality. These endpoints are not part of the official Mastodon API, but are provided by Versia Server to enhance the user experience.
|
||||
|
||||
## Refetch User
|
||||
|
||||
```http
|
||||
POST /api/v1/accounts/:id/refetch
|
||||
```
|
||||
|
||||
Refetches the user's profile information from remote servers. Does not work for local users.
|
||||
|
||||
- **Returns**: [`Account`](https://docs.joinmastodon.org/entities/Account/)
|
||||
- **Authentication**: Required
|
||||
- **Permissions**: `read:account`
|
||||
- **Version History**:
|
||||
- `0.7.0`: Added.
|
||||
|
||||
### Request
|
||||
|
||||
#### Example
|
||||
|
||||
```http
|
||||
POST /api/v1/accounts/364fd13f-28b5-4e88-badd-ce3e533f0d02/refetch
|
||||
Authorization: Bearer ...
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
#### `400 Bad Request`
|
||||
|
||||
The user is a local user and cannot be refetched.
|
||||
|
||||
#### `200 OK`
|
||||
|
||||
New user data.
|
||||
|
||||
Example from the [Mastodon API documentation](https://docs.joinmastodon.org/entities/Account/):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "23634",
|
||||
"username": "noiob",
|
||||
"acct": "noiob@awoo.space",
|
||||
"display_name": "ikea shark fan account",
|
||||
"locked": false,
|
||||
"bot": false,
|
||||
"created_at": "2017-02-08T02:00:53.274Z",
|
||||
"note": "<p>:ms_rainbow_flag: :ms_bisexual_flagweb: :ms_nonbinary_flag: <a href=\"https://awoo.space/tags/awoo\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>awoo</span}.space <a href=\"https://awoo.space/tags/admin\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>admin</span} ~ <a href=\"https://awoo.space/tags/bi\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>bi</span} ~ <a href=\"https://awoo.space/tags/nonbinary\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>nonbinary</span} ~ compsci student ~ likes video <a href=\"https://awoo.space/tags/games\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>games</span} and weird/ old electronics and will post obsessively about both ~ avatar by <span class=\"h-card\"><a href=\"https://weirder.earth/@dzuk\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">@<span>dzuk</span}</span></p>",
|
||||
"url": "https://awoo.space/@noiob",
|
||||
"avatar": "https://files.mastodon.social/accounts/avatars/000/023/634/original/6ca8804dc46800ad.png",
|
||||
"avatar_static": "https://files.mastodon.social/accounts/avatars/000/023/634/original/6ca8804dc46800ad.png",
|
||||
"header": "https://files.mastodon.social/accounts/headers/000/023/634/original/256eb8d7ac40f49a.png",
|
||||
"header_static": "https://files.mastodon.social/accounts/headers/000/023/634/original/256eb8d7ac40f49a.png",
|
||||
"followers_count": 547,
|
||||
"following_count": 404,
|
||||
"statuses_count": 28468,
|
||||
"last_status_at": "2019-11-17",
|
||||
"emojis": [
|
||||
{
|
||||
"shortcode": "ms_rainbow_flag",
|
||||
"url": "https://files.mastodon.social/custom_emojis/images/000/028/691/original/6de008d6281f4f59.png",
|
||||
"static_url": "https://files.mastodon.social/custom_emojis/images/000/028/691/static/6de008d6281f4f59.png",
|
||||
"visible_in_picker": true
|
||||
},
|
||||
{
|
||||
"shortcode": "ms_bisexual_flag",
|
||||
"url": "https://files.mastodon.social/custom_emojis/images/000/050/744/original/02f94a5fca7eaf78.png",
|
||||
"static_url": "https://files.mastodon.social/custom_emojis/images/000/050/744/static/02f94a5fca7eaf78.png",
|
||||
"visible_in_picker": true
|
||||
},
|
||||
{
|
||||
"shortcode": "ms_nonbinary_flag",
|
||||
"url": "https://files.mastodon.social/custom_emojis/images/000/105/099/original/8106088bd4782072.png",
|
||||
"static_url": "https://files.mastodon.social/custom_emojis/images/000/105/099/static/8106088bd4782072.png",
|
||||
"visible_in_picker": true
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"name": "Pronouns",
|
||||
"value": "they/them",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"name": "Alt",
|
||||
"value": "<span class=\"h-card\"><a href=\"https://cybre.space/@noiob\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">@<span>noiob</span}</span>",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"name": "Bots",
|
||||
"value": "<span class=\"h-card\"><a href=\"https://botsin.space/@darksouls\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">@<span>darksouls</span}</span>, <span class=\"h-card\"><a href=\"https://botsin.space/@nierautomata\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">@<span>nierautomata</span}</span>, <span class=\"h-card\"><a href=\"https://mastodon.social/@fedi\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">@<span>fedi</span}</span>, code for <span class=\"h-card\"><a href=\"https://botsin.space/@awoobot\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">@<span>awoobot</span}</span>",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"name": "Website",
|
||||
"value": "<a href=\"http://shork.xyz\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">http://</span><span class=\"\">shork.xyz</span><span class=\"invisible\"></span}",
|
||||
"verified_at": "2019-11-10T10:31:10.744+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Get User By Username
|
||||
|
||||
```http
|
||||
GET /api/v1/accounts/id?username=:username
|
||||
```
|
||||
|
||||
Retrieves a user by their username.
|
||||
|
||||
- **Returns**: [`Account`](https://docs.joinmastodon.org/entities/Account/)
|
||||
- **Authentication**: Not required
|
||||
- **Permissions**: `read:account`
|
||||
- **Version History**:
|
||||
- `0.7.0`: Added.
|
||||
|
||||
### Request
|
||||
|
||||
#### Example
|
||||
|
||||
```http
|
||||
GET /api/v1/accounts/id?username=bobleponge
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
#### `404 Not Found`
|
||||
|
||||
No user with that username was found.
|
||||
|
||||
#### `200 OK`
|
||||
|
||||
User data.
|
||||
|
||||
Example from the [Mastodon API documentation](https://docs.joinmastodon.org/entities/Account/):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "23634",
|
||||
"username": "noiob",
|
||||
"acct": "noiob@awoo.space",
|
||||
"display_name": "ikea shark fan account",
|
||||
"locked": false,
|
||||
"bot": false,
|
||||
"created_at": "2017-02-08T02:00:53.274Z",
|
||||
"note": "<p>:ms_rainbow_flag: :ms_bisexual_flagweb: :ms_nonbinary_flag: <a href=\"https://awoo.space/tags/awoo\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>awoo</span}.space <a href=\"https://awoo.space/tags/admin\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>admin</span} ~ <a href=\"https://awoo.space/tags/bi\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>bi</span} ~ <a href=\"https://awoo.space/tags/nonbinary\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>nonbinary</span} ~ compsci student ~ likes video <a href=\"https://awoo.space/tags/games\" class=\"mention hashtag\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">#<span>games</span} and weird/ old electronics and will post obsessively about both ~ avatar by <span class=\"h-card\"><a href=\"https://weirder.earth/@dzuk\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">@<span>dzuk</span}</span></p>",
|
||||
"url": "https://awoo.space/@noiob",
|
||||
"avatar": "https://files.mastodon.social/accounts/avatars/000/023/634/original/6ca8804dc46800ad.png",
|
||||
"avatar_static": "https://files.mastodon.social/accounts/avatars/000/023/634/original/6ca8804dc46800ad.png",
|
||||
"header": "https://files.mastodon.social/accounts/headers/000/023/634/original/256eb8d7ac40f49a.png",
|
||||
"header_static": "https://files.mastodon.social/accounts/headers/000/023/634/original/256eb8d7ac40f49a.png",
|
||||
"followers_count": 547,
|
||||
"following_count": 404,
|
||||
"statuses_count": 28468,
|
||||
"last_status_at": "2019-11-17",
|
||||
"emojis": [
|
||||
{
|
||||
"shortcode": "ms_rainbow_flag",
|
||||
"url": "https://files.mastodon.social/custom_emojis/images/000/028/691/original/6de008d6281f4f59.png",
|
||||
"static_url": "https://files.mastodon.social/custom_emojis/images/000/028/691/static/6de008d6281f4f59.png",
|
||||
"visible_in_picker": true
|
||||
},
|
||||
{
|
||||
"shortcode": "ms_bisexual_flag",
|
||||
"url": "https://files.mastodon.social/custom_emojis/images/000/050/744/original/02f94a5fca7eaf78.png",
|
||||
"static_url": "https://files.mastodon.social/custom_emojis/images/000/050/744/static/02f94a5fca7eaf78.png",
|
||||
"visible_in_picker": true
|
||||
},
|
||||
{
|
||||
"shortcode": "ms_nonbinary_flag",
|
||||
"url": "https://files.mastodon.social/custom_emojis/images/000/105/099/original/8106088bd4782072.png",
|
||||
"static_url": "https://files.mastodon.social/custom_emojis/images/000/105/099/static/8106088bd4782072.png",
|
||||
"visible_in_picker": true
|
||||
}
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"name": "Pronouns",
|
||||
"value": "they/them",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"name": "Alt",
|
||||
"value": "<span class=\"h-card\"><a href=\"https://cybre.space/@noiob\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">@<span>noiob</span}</span>",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"name": "Bots",
|
||||
"value": "<span class=\"h-card\"><a href=\"https://botsin.space/@darksouls\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">@<span>darksouls</span}</span>, <span class=\"h-card\"><a href=\"https://botsin.space/@nierautomata\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">@<span>nierautomata</span}</span>, <span class=\"h-card\"><a href=\"https://mastodon.social/@fedi\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">@<span>fedi</span}</span>, code for <span class=\"h-card\"><a href=\"https://botsin.space/@awoobot\" class=\"u-url mention\" rel=\"nofollow noopener noreferrer\" target=\"_blank\">@<span>awoobot</span}</span>",
|
||||
"verified_at": null
|
||||
},
|
||||
{
|
||||
"name": "Website",
|
||||
"value": "<a href=\"http://shork.xyz\" rel=\"nofollow noopener noreferrer\" target=\"_blank\"><span class=\"invisible\">http://</span><span class=\"\">shork.xyz</span><span class=\"invisible\"></span}",
|
||||
"verified_at": "2019-11-10T10:31:10.744+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Get Instance TOS
|
||||
|
||||
```http
|
||||
GET /api/v1/instance/tos
|
||||
```
|
||||
|
||||
Returns the instance's Terms of Service, as configured in the instance settings.
|
||||
|
||||
- **Returns**: [`ExtendedDescription`](https://docs.joinmastodon.org/entities/ExtendedDescription/)
|
||||
- **Authentication**: Not required
|
||||
- **Permissions**: None
|
||||
- **Version History**:
|
||||
- `0.7.0`: Added.
|
||||
|
||||
### Request
|
||||
|
||||
#### Example
|
||||
|
||||
```http
|
||||
GET /api/v1/instance/tos
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
#### `200 OK`
|
||||
|
||||
Instance's Terms of Service.
|
||||
|
||||
```json
|
||||
{
|
||||
"updated_at": "2019-11-17T00:00:00.000Z",
|
||||
"content": "<h1>TOS</h1>\n<p>These are the terms of service for this instance.</p>",
|
||||
}
|
||||
```
|
||||
|
||||
## Get Instance Privacy Policy
|
||||
|
||||
```http
|
||||
GET /api/v1/instance/privacy_policy
|
||||
```
|
||||
|
||||
Returns the instance's Privacy Policy, as configured in the instance settings.
|
||||
|
||||
- **Returns**: [`ExtendedDescription`](https://docs.joinmastodon.org/entities/ExtendedDescription/)
|
||||
- **Authentication**: Not required
|
||||
- **Permissions**: None
|
||||
- **Version History**:
|
||||
- `0.7.0`: Added.
|
||||
|
||||
### Request
|
||||
|
||||
#### Example
|
||||
|
||||
```http
|
||||
GET /api/v1/instance/privacy_policy
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
#### `200 OK`
|
||||
|
||||
Instance's Privacy Policy.
|
||||
|
||||
```json
|
||||
{
|
||||
"updated_at": "2019-11-17T00:00:00.000Z",
|
||||
"content": "<h1>Privacy Policy</h1>\n<p>This is the privacy policy for this instance.</p>",
|
||||
}
|
||||
```
|
||||
|
||||
## `/api/v1/instance`
|
||||
|
||||
Extra attributes have been added to the `/api/v1/instance` endpoint.
|
||||
|
||||
```ts
|
||||
interface SSOProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
type ExtendedInstance = Instance & {
|
||||
banner: string | null;
|
||||
versia_version: string;
|
||||
sso: {
|
||||
forced: boolean;
|
||||
providers: SSOProvider[];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### `banner`
|
||||
|
||||
The URL of the instance's banner image.
|
||||
|
||||
### `versia_version`
|
||||
|
||||
The version of Versia Server running on the instance.
|
||||
|
||||
The normal `version` field is always set to `"4.3.0+glitch"` or similar, to not confuse clients that expect a Mastodon instance.
|
||||
|
||||
### `sso`
|
||||
|
||||
Single Sign-On (SSO) settings for the instance. This object contains two fields:
|
||||
|
||||
- `forced`: If this is enabled, normal identifier/password login is disabled and login must be done through SSO.
|
||||
- `providers`: An array of external OpenID Connect providers that users can link their accounts to. Each provider object contains the following fields:
|
||||
- `id`: The issuer ID of the OpenID Connect provider.
|
||||
- `name`: The name of the provider.
|
||||
- `icon`: The URL of the provider's icon. Optional.
|
||||
|
||||
## `/api/v2/instance`
|
||||
|
||||
Extra attributes have been added to the `/api/v2/instance` endpoint. These are identical to the `/api/v1/instance` endpoint, except that the `banner` attribute uses the normal Mastodon API attribute.
|
||||
|
||||
```ts
|
||||
type ExtendedInstanceV2 = InstanceV2 & {
|
||||
versia_version: string;
|
||||
configuration: Instance["configuration"] & {
|
||||
emojis: {
|
||||
// In bytes
|
||||
emoji_size_limit: number;
|
||||
max_emoji_shortcode_characters: number;
|
||||
max_emoji_description_characters: number;
|
||||
};
|
||||
};
|
||||
sso: {
|
||||
forced: boolean;
|
||||
providers: SSOProvider[];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### `versia_version`
|
||||
|
||||
The version of Versia Server running on the instance.
|
||||
|
||||
The normal `version` field is always set to `"4.3.0+glitch"` or similar, to not confuse clients that expect a Mastodon instance.
|
||||
|
||||
### `sso`
|
||||
|
||||
Single Sign-On (SSO) settings for the instance. This object contains two fields:
|
||||
|
||||
- `forced`: If this is enabled, normal identifier/password login is disabled and login must be done through SSO.
|
||||
- `providers`: An array of external OpenID Connect providers that users can link their accounts to. Each provider object contains the following fields:
|
||||
- `id`: The issuer ID of the OpenID Connect provider.
|
||||
- `name`: The name of the provider.
|
||||
- `icon`: The URL of the provider's icon. Optional.
|
||||
|
||||
## `Account`
|
||||
|
||||
Two extra attributes have been added to all returned [`Account`](https://docs.joinmastodon.org/entities/Account/) objects.
|
||||
|
||||
This object is returned on routes such as `/api/v1/accounts/:id`, `/api/v1/accounts/verify_credentials`, etc.
|
||||
|
||||
```ts
|
||||
type ExtendedAccount = Account & {
|
||||
roles: Role[];
|
||||
uri: string;
|
||||
}
|
||||
```
|
||||
|
||||
### `roles`
|
||||
|
||||
An array of `Roles` that the user has.
|
||||
|
||||
### `uri`
|
||||
|
||||
URI of the account's Versia entity (for federation). Similar to Mastodon's `uri` field on notes.
|
||||
|
||||
## `Status`
|
||||
|
||||
One attribute has been added to all returned [`Status`](https://docs.joinmastodon.org/entities/Status/) objects.
|
||||
|
||||
This object is returned on routes such as `/api/v1/statuses/:id`, `/api/v1/statuses/:id/context`, etc.
|
||||
|
||||
```ts
|
||||
type URL = string;
|
||||
|
||||
interface NoteReaction {
|
||||
name: string;
|
||||
count: number;
|
||||
me: boolean;
|
||||
url: URL;
|
||||
}
|
||||
|
||||
type ExtendedStatus = Status & {
|
||||
reactions: NoteReaction[];
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
...
|
||||
"reactions": [
|
||||
{
|
||||
"name": "like",
|
||||
"count": 3,
|
||||
"me": true,
|
||||
},
|
||||
{
|
||||
"name": "blobfox",
|
||||
"count": 1,
|
||||
"me": false,
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `reactions`
|
||||
|
||||
An array of all the [`NoteReactions`](./reactions.md#reaction) for the note. Data for the custom emoji (e.g. URL) can be found in the `emojis` field of the [`Status`](https://docs.joinmastodon.org/entities/Status#emojis).
|
||||
|
||||
## `/api/v1/accounts/update_credentials`
|
||||
|
||||
The `username` parameter can now (optionally) be set to change the user's handle.
|
||||
|
||||
> [!WARNING]
|
||||
> Clients should indicate to users that changing their handle will break existing links to their profile. This is reversible, but the old handle will be available for anyone to claim.
|
||||
175
docs/api/reactions.md
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
# Reactions API
|
||||
|
||||
This API is used to send reactions to notes.
|
||||
|
||||
## Reaction
|
||||
|
||||
```typescript
|
||||
type UUID = string;
|
||||
|
||||
interface NoteReaction {
|
||||
name: string;
|
||||
count: number;
|
||||
me: boolean;
|
||||
}
|
||||
|
||||
type NoteReactionWithAccounts = NoteReaction & {
|
||||
account_ids: UUID[];
|
||||
}
|
||||
```
|
||||
|
||||
## Get Reactions
|
||||
|
||||
All reactions attached to a [`Status`](https://docs.joinmastodon.org/entities/Status) can be found on the note itself, [in the `reactions` field](./mastodon.md#reactions).
|
||||
|
||||
## Get Users Who Reacted
|
||||
|
||||
```http
|
||||
GET /api/v1/statuses/:id/reactions
|
||||
```
|
||||
|
||||
Get a list of all the users who reacted to a note. Only IDs are returned, not full account objects, to improve performance on very popular notes.
|
||||
|
||||
- **Returns:** [`NoteReactionWithAccounts[]`](#reaction)
|
||||
- **Authentication:** Not required
|
||||
- **Permissions:** `read:reaction`
|
||||
- **Version History**:
|
||||
- `0.8.0`: Added.
|
||||
|
||||
### Request
|
||||
|
||||
#### Example
|
||||
|
||||
```http
|
||||
GET /api/v1/statuses/123/reactions
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
#### `200 OK`
|
||||
|
||||
List of reactions and associated users. The `me` field is `true` if the current user has reacted with that emoji.
|
||||
|
||||
Data for the custom emoji (e.g. URL) can be found in the `emojis` field of the [`Status`](https://docs.joinmastodon.org/entities/Status#emojis).
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "like",
|
||||
"count": 3,
|
||||
"me": true,
|
||||
"account_ids": ["1", "2", "3"]
|
||||
},
|
||||
{
|
||||
"name": "blobfox-coffee",
|
||||
"count": 1,
|
||||
"me": false,
|
||||
"account_ids": ["4"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Add Reaction
|
||||
|
||||
```http
|
||||
PUT /api/v1/statuses/:id/reactions/:name
|
||||
```
|
||||
|
||||
Add a reaction to a note.
|
||||
|
||||
- **Returns:** [`Status`](https://docs.joinmastodon.org/entities/Status)
|
||||
- **Authentication:** Required
|
||||
- **Permissions:** `owner:reaction`
|
||||
- **Version History**:
|
||||
- `0.8.0`: Added.
|
||||
|
||||
### Request
|
||||
|
||||
- `name` (string, required): Either a custom emoji shortcode or a Unicode emoji.
|
||||
|
||||
#### Example
|
||||
|
||||
```http
|
||||
PUT /api/v1/statuses/123/reactions/blobfox-coffee
|
||||
Authorization: Bearer ...
|
||||
```
|
||||
|
||||
```http
|
||||
PUT /api/v1/statuses/123/reactions/👍
|
||||
Authorization: Bearer ...
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
#### `201 Created`
|
||||
|
||||
Returns the updated note.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "123",
|
||||
...
|
||||
"reactions": [
|
||||
{
|
||||
"name": "👍",
|
||||
"count": 3,
|
||||
"me": true
|
||||
},
|
||||
{
|
||||
"name": "blobfox-coffee",
|
||||
"count": 1,
|
||||
"me": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Remove Reaction
|
||||
|
||||
```http
|
||||
DELETE /api/v1/statuses/:id/reactions/:name
|
||||
```
|
||||
|
||||
Remove a reaction from a note.
|
||||
|
||||
- **Returns:** [`Status`](https://docs.joinmastodon.org/entities/Status)
|
||||
- **Authentication:** Required
|
||||
- **Permissions:** `owner:reaction`
|
||||
- **Version History**:
|
||||
- `0.8.0`: Added.
|
||||
|
||||
### Request
|
||||
|
||||
- `name` (string, required): Either a custom emoji shortcode or a Unicode emoji.
|
||||
|
||||
#### Example
|
||||
|
||||
```http
|
||||
DELETE /api/v1/statuses/123/reactions/blobfox-coffee
|
||||
Authorization: Bearer ...
|
||||
```
|
||||
|
||||
```http
|
||||
DELETE /api/v1/statuses/123/reactions
|
||||
Authorization: Bearer ...
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
#### `200 OK`
|
||||
|
||||
Returns the updated note. If the reaction was not found, the note is returned as is.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "123",
|
||||
...
|
||||
"reactions": [
|
||||
{
|
||||
"name": "👍",
|
||||
"count": 3,
|
||||
"me": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
24
docs/cli.md
|
|
@ -1,24 +0,0 @@
|
|||
# Lysand CLI
|
||||
|
||||
Lysand includes a built-in, scripting-compatible CLI that can be used to manage the server. This CLI can be used to create and delete users, manage the database and more. It can also output data in JSON or CSV format, making it easy to use in scripts.
|
||||
|
||||
## Using the CLI
|
||||
|
||||
Lysand includes a built-in CLI for managing the server. To use it, simply run the following command:
|
||||
|
||||
```bash
|
||||
# Development
|
||||
bun cli help
|
||||
# Source installs
|
||||
bun run dist/cli.js help
|
||||
# Docker
|
||||
docker compose exec -it lysand /bin/sh /app/entrypoint.sh cli help
|
||||
```
|
||||
|
||||
You can use the `help` command to see a list of available commands. These include creating users, deleting users and more. Each command also has a `--help,-h` flag that you can use to see more information about the command.
|
||||
|
||||
## Scripting with the CLI
|
||||
|
||||
Some CLI commands that return data as tables can be used in scripts. To convert them to JSON or CSV, some commands allow you to specify a `--format` flag that can be either `"json"` or `"csv"`. See `bun cli help` or `bun cli <command> -h` for more information.
|
||||
|
||||
Flags can be used in any order and anywhere in the script (except for the `bun cli` command itself). The command arguments themselves must be in the correct order, however.Z
|
||||
21
docs/cli/index.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Versia Server CLI
|
||||
|
||||
Versia Server includes a built-in, scripting-compatible CLI that can be used to manage the server. This CLI can be used to create and delete users, manage the database and more. It can also output data in JSON or CSV format, making it easy to use in scripts.
|
||||
|
||||
## Using the CLI
|
||||
|
||||
Versia Server includes a built-in CLI for managing the server. To use it, simply run the following command:
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
# Replace `versia` with the name of your container
|
||||
docker compose exec -it versia sh /app/entrypoint.sh cli help
|
||||
```
|
||||
|
||||
You can use the `help` command to see a list of available commands. These include creating users, deleting users and more. Each command also has a `--help,-h` flag that you can use to see more information about the command.
|
||||
|
||||
## Scripting with the CLI
|
||||
|
||||
Some CLI commands that return data as tables can be used in scripts. To convert them to JSON or CSV, some commands allow you to specify a `--format` flag that can be either `"json"` or `"csv"`. See `cli help` or `cli <command> -h` for more information.
|
||||
|
||||
Flags can be used in any order and anywhere in the script (except for the `cli` command itself).
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
# Installing the database
|
||||
|
||||
Lysand uses a special PostgreSQL extension called `pg_uuidv7` to generate UUIDs. This extension is required for Lysand to work properly. To install it, you can either use the pre-made Docker image or install it manually.
|
||||
|
||||
## Using the Docker image
|
||||
|
||||
Lysand offers a pre-made Docker image for PostgreSQL with the extension already installed. Use `ghcr.io/lysand-org/postgres:main` as your Docker image name to use it.
|
||||
|
||||
## Manual installation
|
||||
|
||||
87
docs/frontend/auth.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# Frontend Authentication
|
||||
|
||||
Multiple API routes are exposed for authentication, to be used by frontend developers.
|
||||
|
||||
> [!INFO]
|
||||
>
|
||||
> These are different from the Client API routes, which are used by clients to interact with the Mastodon API.
|
||||
|
||||
A frontend is a web application that is designed to be the primary user interface for an instance. It is used also used by clients to perform authentication.
|
||||
|
||||
## Get Frontend Configuration
|
||||
|
||||
```http
|
||||
GET /api/v1/frontend/config
|
||||
```
|
||||
|
||||
Retrieves the frontend configuration for the instance. This returns whatever the `frontend.settings` object is set to in the Versia Server configuration.
|
||||
|
||||
This behaves like the `/api/v1/preferences` endpoint in the Mastodon API, but is specific to the frontend. These values are arbitrary and can be used for anything.
|
||||
|
||||
Frontend developers should always namespace their keys to avoid conflicts with other keys.
|
||||
|
||||
- **Returns**: Object with arbitrary keys and values.
|
||||
- **Authentication**: Not required
|
||||
- **Permissions**: None
|
||||
- **Version History**:
|
||||
- `0.7.0`: Added.
|
||||
|
||||
### Request
|
||||
|
||||
#### Example
|
||||
|
||||
```http
|
||||
GET /api/v1/frontend/config
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
#### `200 OK`
|
||||
|
||||
Frontend configuration.
|
||||
|
||||
```json
|
||||
{
|
||||
"pub.versia.fe:theme": "dark",
|
||||
"pub.versia.fe:custom_css": "body { background-color: black; }",
|
||||
"net.googly.frontend:spoiler_image": "https://example.com/spoiler.png"
|
||||
}
|
||||
```
|
||||
|
||||
## SSO Sign In
|
||||
|
||||
```http
|
||||
POST /oauth/sso
|
||||
```
|
||||
|
||||
Allows users to sign in to the instance using an external OpenID Connect provider.
|
||||
|
||||
- **Returns**: `302 Found` with a `Location` header to redirect the user to the next step.
|
||||
- **Authentication**: Not required
|
||||
- **Permissions**: None
|
||||
- **Version History**:
|
||||
- `0.7.0`: First documented.
|
||||
|
||||
### Request
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
- `client_id` (string, required): Client ID of the [application](https://docs.joinmastodon.org/entities/Application/) that is making the request.
|
||||
- `issuer` (string, required): The ID of the OpenID Connect provider, as found in `/api/{v1,v2}/instance`.
|
||||
|
||||
#### Example
|
||||
|
||||
```http
|
||||
POST /oauth/sso?client_id=123&issuer=google
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
#### `302 Found`
|
||||
|
||||
Redirects the user to the OpenID Connect provider's login page.
|
||||
|
||||
```http
|
||||
HTTP/2.0 302 Found
|
||||
Location: https://accounts.google.com/o/oauth2/auth?client_id=123&redirect_uri=https%3A%2F%2Fexample.com%2Fauth&response_type=code&scope=openid%20email&state=123
|
||||
```
|
||||
53
docs/frontend/routes.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# Frontend Routes
|
||||
|
||||
Frontend implementors must implement these routes for correct operation of the instance.
|
||||
|
||||
The location of these routes can be configured in the Versia Server configuration at `frontend.routes`:
|
||||
|
||||
## Login Form
|
||||
|
||||
```http
|
||||
GET /oauth/authorize
|
||||
```
|
||||
|
||||
This route should display a login form for the user to enter their username and password, as well as a list of OpenID providers to use if available.
|
||||
|
||||
The form should submit to the OpenID Connect flow.
|
||||
|
||||
Configurable in the Versia Server configuration at `frontend.routes.login`.
|
||||
|
||||
## Consent Form
|
||||
|
||||
```http
|
||||
GET /oauth/consent
|
||||
```
|
||||
|
||||
This route should display a consent form for the user to approve the requested application permissions, after logging in.
|
||||
|
||||
The form should submit an OpenID Connect authorization request at `POST /oauth/authorize`, with the correct [application](https://docs.joinmastodon.org/entities/Application/) data (client ID, redirect URI, etc.). Do not forget the JWT cookie.
|
||||
|
||||
### Submission Example
|
||||
|
||||
```http
|
||||
POST /oauth/authorize
|
||||
Content-Type: application/json
|
||||
Cookie: jwt=...
|
||||
|
||||
{
|
||||
"client_id": "client_id",
|
||||
"response_type": "code",
|
||||
"redirect_uri": "https://example.com/callback",
|
||||
"scope": "read write",
|
||||
"state": "state123",
|
||||
"code_challenge": "code_challenge",
|
||||
"code_challenge_method": "S256",
|
||||
"response_type": "code"
|
||||
}
|
||||
```
|
||||
|
||||
### Submission Response
|
||||
|
||||
```http
|
||||
HTTP/2.0 302 Found
|
||||
Location: https://example.com/callback?code=code&state=state123
|
||||
```
|
||||