mirror of
https://github.com/versia-pub/server.git
synced 2025-12-07 16:58:20 +01:00
Compare commits
410 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 |
|
|
@ -1,16 +1,18 @@
|
||||||
version = 1
|
version = 1
|
||||||
|
|
||||||
|
test_patterns = ["**/*.test.ts"]
|
||||||
|
|
||||||
[[analyzers]]
|
[[analyzers]]
|
||||||
name = "shell"
|
name = "shell"
|
||||||
|
|
||||||
[[analyzers]]
|
[[analyzers]]
|
||||||
name = "javascript"
|
name = "javascript"
|
||||||
|
|
||||||
[analyzers.meta]
|
[analyzers.meta]
|
||||||
environment = ["nodejs"]
|
environment = ["nodejs"]
|
||||||
|
|
||||||
[[analyzers]]
|
[[analyzers]]
|
||||||
name = "docker"
|
name = "docker"
|
||||||
|
|
||||||
[analyzers.meta]
|
[analyzers.meta]
|
||||||
dockerfile_paths = ["Dockerfile"]
|
dockerfile_paths = ["Dockerfile"]
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
# Bun doesn't run well on Musl but this seems to work
|
|
||||||
FROM oven/bun:1.1.34-alpine as base
|
|
||||||
|
|
||||||
# Switch to Bash by editing /etc/passwd
|
|
||||||
RUN apk add --no-cache libstdc++ git bash curl openssh cloc && \
|
|
||||||
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": "versia 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",
|
|
||||||
"eamodio.gitlens"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
9
.editorconfig
Normal file
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
|
||||||
481
.github/config.workflow.toml
vendored
481
.github/config.workflow.toml
vendored
|
|
@ -1,100 +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"
|
host = "localhost"
|
||||||
port = 5432
|
port = 5432
|
||||||
username = "versia"
|
username = "versia"
|
||||||
|
# Sensitive value
|
||||||
password = "versia"
|
password = "versia"
|
||||||
database = "versia"
|
database = "versia"
|
||||||
|
|
||||||
|
# Additional read-only replicas
|
||||||
|
# [[postgres.replicas]]
|
||||||
|
# host = "other-host"
|
||||||
|
# port = 5432
|
||||||
|
# username = "versia"
|
||||||
|
# password = "mycoolpassword2"
|
||||||
|
# database = "replica1"
|
||||||
|
|
||||||
[redis.queue]
|
[redis.queue]
|
||||||
|
# A Redis database used for managing queues.
|
||||||
|
# Required for federation
|
||||||
host = "localhost"
|
host = "localhost"
|
||||||
port = 6379
|
port = 6379
|
||||||
password = ""
|
# Sensitive value
|
||||||
|
# password = "test"
|
||||||
database = 0
|
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
|
enabled = false
|
||||||
|
|
||||||
[redis.cache]
|
# Optional if search is disabled
|
||||||
host = "localhost"
|
[search.sonic]
|
||||||
port = 6379
|
|
||||||
password = ""
|
|
||||||
database = 1
|
|
||||||
enabled = false
|
|
||||||
|
|
||||||
[sonic]
|
|
||||||
host = "localhost"
|
host = "localhost"
|
||||||
port = 40007
|
port = 40007
|
||||||
|
# Sensitive value
|
||||||
password = ""
|
password = ""
|
||||||
enabled = false
|
|
||||||
|
|
||||||
[signups]
|
[registration]
|
||||||
# Whether to enable registrations or not
|
# Can users sign up freely?
|
||||||
registration = true
|
allow = true
|
||||||
rules = [
|
# NOT IMPLEMENTED
|
||||||
"Do not harass others",
|
require_approval = false
|
||||||
"Be nice to people",
|
# Message to show to users when registration is disabled
|
||||||
"Don't spam",
|
# message = "ran out of spoons to moderate registrations, sorry"
|
||||||
"Don't post illegal content",
|
|
||||||
]
|
|
||||||
|
|
||||||
[http]
|
[http]
|
||||||
|
# URL that the instance will be accessible at
|
||||||
base_url = "http://0.0.0.0:8080"
|
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 = "0.0.0.0"
|
||||||
bind_port = 8080
|
bind_port = 8080
|
||||||
|
|
||||||
# Bans IPv4 or IPv6 IPs (wildcards, networks and ranges are supported)
|
# Bans IPv4 or IPv6 IPs (wildcards, networks and ranges are supported)
|
||||||
banned_ips = []
|
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
|
# SMTP server to use for sending emails
|
||||||
server = "smtp.example.com"
|
# server = "smtp.example.com"
|
||||||
port = 465
|
# port = 465
|
||||||
username = "test@example.com"
|
# username = "test@example.com"
|
||||||
password = "password123"
|
# Sensitive value
|
||||||
tls = true
|
# password = "password123"
|
||||||
|
# tls = true
|
||||||
|
|
||||||
[media]
|
[media]
|
||||||
# Can be "s3" or "local", where "local" uploads the file to the local filesystem
|
# 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
|
# Changing this value will not retroactively apply to existing data
|
||||||
# from one backend to the other manually
|
# Don't forget to fill in the s3 config :3
|
||||||
backend = "local"
|
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
|
# 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]
|
[media.conversion]
|
||||||
|
# Whether to automatically convert images to another format on upload
|
||||||
convert_images = false
|
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
|
# JXL support will likely not work
|
||||||
convert_to = "webp"
|
convert_to = "image/webp"
|
||||||
|
# Also convert SVG images?
|
||||||
|
convert_vectors = false
|
||||||
|
|
||||||
# [s3]
|
# [s3]
|
||||||
# Can be left blank if you don't use the S3 media backend
|
# Can be left commented if you don't use the S3 media backend
|
||||||
# endpoint = "https://s3-us-west-2.amazonaws.com"
|
# endpoint = "https://s3.example.com"
|
||||||
# access_key = ""
|
# Sensitive value
|
||||||
# secret_access_key = ""
|
# access_key = "XXXXX"
|
||||||
# region = "us-west-2"
|
# Sensitive value
|
||||||
|
# secret_access_key = "XXX"
|
||||||
|
# region = "us-east-1"
|
||||||
# bucket_name = "versia"
|
# bucket_name = "versia"
|
||||||
# public_url = "https://cdn.example.com"
|
# public_url = "https://cdn.example.com"
|
||||||
|
|
||||||
[validation]
|
[validation]
|
||||||
# Self explanatory
|
# Checks user data
|
||||||
max_displayname_size = 50
|
# Does not retroactively apply to previously entered data
|
||||||
max_bio_size = 160
|
[validation.accounts]
|
||||||
max_note_size = 5000
|
max_displayname_characters = 50
|
||||||
max_avatar_size = 5_000_000
|
max_username_characters = 30
|
||||||
max_header_size = 5_000_000
|
max_bio_characters = 5000
|
||||||
max_media_size = 40_000_000
|
max_avatar_bytes = 5_000_000
|
||||||
max_media_attachments = 10
|
max_header_bytes = 5_000_000
|
||||||
max_media_description_size = 1000
|
# Regex is allowed here
|
||||||
max_poll_options = 20
|
disallowed_usernames = [
|
||||||
max_poll_option_size = 500
|
"well-known",
|
||||||
min_poll_duration = 60
|
|
||||||
max_poll_duration = 1893456000
|
|
||||||
max_username_size = 30
|
|
||||||
# An array of strings, defaults are from Akkoma
|
|
||||||
username_blacklist = [
|
|
||||||
".well-known",
|
|
||||||
"~",
|
|
||||||
"about",
|
"about",
|
||||||
"activities",
|
"activities",
|
||||||
"api",
|
"api",
|
||||||
|
|
@ -120,12 +191,14 @@ username_blacklist = [
|
||||||
"search",
|
"search",
|
||||||
"mfa",
|
"mfa",
|
||||||
]
|
]
|
||||||
# Whether to blacklist known temporary email providers
|
max_field_count = 10
|
||||||
blacklist_tempmail = false
|
max_field_name_characters = 1000
|
||||||
# Additional email providers to blacklist
|
max_field_value_characters = 1000
|
||||||
email_blacklist = []
|
max_pinned_notes = 20
|
||||||
# Valid URL schemes, otherwise the URL is parsed as text
|
|
||||||
url_scheme_whitelist = [
|
[validation.notes]
|
||||||
|
max_characters = 5000
|
||||||
|
allowed_url_schemes = [
|
||||||
"http",
|
"http",
|
||||||
"https",
|
"https",
|
||||||
"ftp",
|
"ftp",
|
||||||
|
|
@ -143,62 +216,122 @@ url_scheme_whitelist = [
|
||||||
"mumble",
|
"mumble",
|
||||||
"ssb",
|
"ssb",
|
||||||
"gemini",
|
"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
|
||||||
|
|
||||||
|
[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]
|
[validation.challenges]
|
||||||
# "Challenges" (aka captchas) are a way to verify that a user is human
|
# "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
|
# 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
|
# This means that they do not require any user interaction, instead
|
||||||
# they require the user's computer to do a small amount of work
|
# they require the user's computer to do a small amount of work
|
||||||
enabled = true
|
# The difficulty of the challenge, higher is will take more time to solve
|
||||||
# The difficulty of the challenge, higher is harder
|
|
||||||
difficulty = 50000
|
difficulty = 50000
|
||||||
# Challenge expiration time in seconds
|
# Challenge expiration time in seconds
|
||||||
expiration = 300 # 5 minutes
|
expiration = 300 # 5 minutes
|
||||||
# Leave this empty to generate a new key
|
# Leave this empty to generate a new key
|
||||||
|
# Sensitive value
|
||||||
key = "YBpAV0KZOeM/MZ4kOb2E9moH9gCUr00Co9V7ncGRJ3wbd/a9tLDKKFdI0BtOcnlpfx0ZBh0+w3WSvsl0TsesTg=="
|
key = "YBpAV0KZOeM/MZ4kOb2E9moH9gCUr00Co9V7ncGRJ3wbd/a9tLDKKFdI0BtOcnlpfx0ZBh0+w3WSvsl0TsesTg=="
|
||||||
|
|
||||||
|
# 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 = "BBanhyj2_xWwbTsWld3T49VcAoKZHrVJTzF1f6Av2JwQY_wUi3CF9vZ0WeEcACRj6EEqQ7N35CkUh5epF7n4P_s"
|
||||||
|
# Sensitive value
|
||||||
|
private = "Eujaz7NsF0rKZOVrAFL7mMpFdl96f591ERsRn81unq0"
|
||||||
|
|
||||||
[defaults]
|
[defaults]
|
||||||
# Default visibility for new notes
|
# 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"
|
visibility = "public"
|
||||||
# Default language for new notes
|
# Default language for new notes (ISO code)
|
||||||
language = "en"
|
language = "en"
|
||||||
# Default avatar, must be a valid URL or ""
|
# Default avatar, must be a valid URL or left out for a placeholder avatar
|
||||||
# avatar = ""
|
# avatar = ""
|
||||||
# Default header, must be a valid URL or ""
|
# Default header, must be a valid URL or left out for none
|
||||||
# header = ""
|
# 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]
|
[federation]
|
||||||
# This is a list of domain names, such as "mastodon.social" or "pleroma.site"
|
# This is a list of domain names, such as "mastodon.social" or "pleroma.site"
|
||||||
|
|
@ -223,57 +356,119 @@ reactions = []
|
||||||
banners = []
|
banners = []
|
||||||
avatars = []
|
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]
|
[instance]
|
||||||
name = "Versia"
|
name = "Versia"
|
||||||
description = "A test instance of Versia Server"
|
description = "A Versia Server instance"
|
||||||
# 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 = ""
|
|
||||||
|
|
||||||
|
# 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]
|
# Primary instance languages. ISO 639-1 codes.
|
||||||
# Regex filters for federated and local data
|
languages = ["en"]
|
||||||
# Drops data matching the filters
|
|
||||||
# Does not apply retroactively to existing data
|
|
||||||
|
|
||||||
# Note contents
|
[instance.contact]
|
||||||
note_content = [
|
email = "staff@yourinstance.com"
|
||||||
# "(https?://)?(www\\.)?youtube\\.com/watch\\?v=[a-zA-Z0-9_-]+",
|
|
||||||
# "(https?://)?(www\\.)?youtu\\.be/[a-zA-Z0-9_-]+",
|
[instance.branding]
|
||||||
]
|
# logo = "https://cdn.example.com/logo.png"
|
||||||
emoji = []
|
# banner = "https://cdn.example.com/banner.png"
|
||||||
# These will drop users matching the filters
|
|
||||||
username = []
|
# Used for federation. If left empty or missing, the server will generate one for you.
|
||||||
displayname = []
|
[instance.keys]
|
||||||
bio = []
|
# 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]
|
[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
|
# Available levels: trace, debug, info, warning, error, fatal
|
||||||
log_filters = true
|
log_level = "info" # For console output
|
||||||
|
|
||||||
[ratelimits]
|
# [logging.file]
|
||||||
# These settings apply to every route at once
|
# path = "logs/versia.log"
|
||||||
# Amount to multiply every route's duration by
|
# log_level = "info"
|
||||||
duration_coeff = 1.0
|
#
|
||||||
# Amount to multiply every route's max requests per [duration] by
|
# [logging.file.rotation]
|
||||||
max_coeff = 1.0
|
# 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.custom]
|
[authentication]
|
||||||
# Add in any API route in this style here
|
# Run Versia Server with this value missing to generate a new key
|
||||||
# Applies before the global ratelimit changes
|
key = "ZWcwanRaQAqY3ChUro/Jey9XGQjzsxEed5iqTp4yFr8W6vEnXdz91F/Pu/uf7HBMbNeIK7V6aHsM0lq9onrO8Q=="
|
||||||
# "/api/v1/accounts/:id/block" = { duration = 30, max = 60 }
|
|
||||||
# "/api/v1/timelines/public" = { duration = 60, max = 200 }
|
|
||||||
|
|
||||||
[plugins]
|
# The provider MUST support OpenID Connect with .well-known discovery
|
||||||
|
# Most notably, GitHub does not support this
|
||||||
[plugins.config."@versia/openid".keys]
|
# Redirect URLs in your OpenID provider can be set to this:
|
||||||
private = "MC4CAQAwBQYDK2VwBCIEID+H5n9PY3zVKZQcq4jrnE1IiRd2EWWr8ApuHUXmuOzl"
|
# <base_url>/oauth/sso/<provider_id>/callback*
|
||||||
public = "MCowBQYDK2VwAyEAzenliNkgpXYsh3gXTnAoUWzlCPjIOppmAVx2DBlLsC8="
|
# 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
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.
|
||||||
44
.github/workflows/check.yml
vendored
44
.github/workflows/check.yml
vendored
|
|
@ -1,31 +1,27 @@
|
||||||
name: Check Types
|
name: Check Types
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_call:
|
||||||
branches: ["*"]
|
|
||||||
pull_request:
|
|
||||||
# The branches below must be a subset of the branches above
|
|
||||||
branches: ["main"]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install NPM packages
|
- name: Install NPM packages
|
||||||
run: |
|
run: |
|
||||||
bun install
|
bun install
|
||||||
|
|
||||||
- name: Run typechecks
|
- name: Run typechecks
|
||||||
run: |
|
run: |
|
||||||
bun run check
|
bun run typecheck
|
||||||
|
|
|
||||||
27
.github/workflows/circular-imports.yml
vendored
Normal file
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
|
||||||
74
.github/workflows/docker-publish.yml
vendored
74
.github/workflows/docker-publish.yml
vendored
|
|
@ -1,74 +0,0 @@
|
||||||
name: Docker Build
|
|
||||||
|
|
||||||
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
|
|
||||||
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 # v3.0.0
|
|
||||||
|
|
||||||
- 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 }}
|
|
||||||
|
|
||||||
- name: Extract Docker metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5 # v5.0.0
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
|
|
||||||
- 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 # v5.0.0
|
|
||||||
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 }}
|
|
||||||
provenance: mode=max
|
|
||||||
sbom: true
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
98
.github/workflows/docker.yml
vendored
Normal file
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
|
||||||
76
.github/workflows/docs.yml
vendored
76
.github/workflows/docs.yml
vendored
|
|
@ -1,56 +1,56 @@
|
||||||
name: Deploy Docs to GitHub Pages
|
name: Deploy Docs to GitHub Pages
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pages: write
|
pages: write
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
# 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.
|
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: pages
|
group: pages
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: oven-sh/setup-bun@v2
|
- uses: oven-sh/setup-bun@v2
|
||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
|
|
||||||
uses: actions/configure-pages@v4
|
uses: actions/configure-pages@v4
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
- name: Build with VitePress
|
- name: Build with VitePress
|
||||||
run: bun run docs:build
|
run: bun run --filter="@versia-server/api" docs:build
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-pages-artifact@v3
|
uses: actions/upload-pages-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: docs/.vitepress/dist
|
path: packages/api/docs/.vitepress/dist
|
||||||
|
|
||||||
# Deployment job
|
# Deployment job
|
||||||
deploy:
|
deploy:
|
||||||
environment:
|
environment:
|
||||||
name: github-pages
|
name: github-pages
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Deploy
|
name: Deploy
|
||||||
steps:
|
steps:
|
||||||
- name: Deploy to GitHub Pages
|
- name: Deploy to GitHub Pages
|
||||||
id: deployment
|
id: deployment
|
||||||
uses: actions/deploy-pages@v4
|
uses: actions/deploy-pages@v4
|
||||||
|
|
|
||||||
40
.github/workflows/lint.yml
vendored
40
.github/workflows/lint.yml
vendored
|
|
@ -1,31 +1,27 @@
|
||||||
name: Lint & Format
|
name: Lint & Format
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_call:
|
||||||
branches: ["*"]
|
|
||||||
pull_request:
|
|
||||||
# The branches below must be a subset of the branches above
|
|
||||||
branches: ["main"]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install NPM packages
|
- name: Install NPM packages
|
||||||
run: |
|
run: |
|
||||||
bun install
|
bun install
|
||||||
|
|
||||||
- name: Run linting
|
- name: Run linting
|
||||||
run: |
|
run: |
|
||||||
bunx @biomejs/biome ci .
|
bunx @biomejs/biome ci .
|
||||||
|
|
|
||||||
8
.github/workflows/mirror.yml
vendored
8
.github/workflows/mirror.yml
vendored
|
|
@ -2,7 +2,7 @@ name: Mirror to Codeberg
|
||||||
on: [push]
|
on: [push]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
mirror:
|
mirror:
|
||||||
name: Mirror
|
name: Mirror
|
||||||
uses: versia-pub/.github/.github/workflows/mirror.yml@main
|
uses: versia-pub/.github/.github/workflows/mirror.yml@main
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
|
||||||
42
.github/workflows/nix-flake.yml
vendored
42
.github/workflows/nix-flake.yml
vendored
|
|
@ -1,25 +1,25 @@
|
||||||
name: Nix Build
|
name: Nix Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches: ["*"]
|
branches: ["*"]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
id-token: "write"
|
id-token: "write"
|
||||||
contents: "read"
|
contents: "read"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: DeterminateSystems/nix-installer-action@main
|
- uses: DeterminateSystems/nix-installer-action@main
|
||||||
with:
|
with:
|
||||||
extra-conf: accept-flake-config = true
|
extra-conf: accept-flake-config = true
|
||||||
- uses: DeterminateSystems/magic-nix-cache-action@main
|
- uses: DeterminateSystems/magic-nix-cache-action@main
|
||||||
- uses: DeterminateSystems/flake-checker-action@main
|
- uses: DeterminateSystems/flake-checker-action@main
|
||||||
- name: Build default package
|
- name: Build default package
|
||||||
run: nix build .
|
run: nix build .
|
||||||
- name: Check flakes
|
- name: Check flakes
|
||||||
run: nix flake check --allow-import-from-derivation
|
run: nix flake check --allow-import-from-derivation
|
||||||
|
|
|
||||||
48
.github/workflows/publish.yml
vendored
Normal file
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
|
||||||
50
.github/workflows/staging.yml
vendored
50
.github/workflows/staging.yml
vendored
|
|
@ -1,50 +0,0 @@
|
||||||
name: Staging build bundle
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["staging"]
|
|
||||||
|
|
||||||
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: Build dist
|
|
||||||
run: |
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
- name: Bundle
|
|
||||||
run: |
|
|
||||||
mkdir bundle
|
|
||||||
cp -r dist bundle/
|
|
||||||
cp -r config bundle/
|
|
||||||
cp -r docs bundle/
|
|
||||||
cp -r CODE_OF_CONDUCT.md bundle/
|
|
||||||
cp -r CONTRIBUTING.md bundle/
|
|
||||||
cp -r README.md bundle/
|
|
||||||
cp -r flake.nix bundle/
|
|
||||||
cp -r shell.nix bundle/
|
|
||||||
cp -r flake.lock bundle/
|
|
||||||
cp -r LICENSE bundle/
|
|
||||||
cp -r SECURITY.md bundle/
|
|
||||||
tar cfJ archive.tar.xz bundle/
|
|
||||||
|
|
||||||
- name: Upload
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: staging-dist
|
|
||||||
path: archive.tar.xz
|
|
||||||
36
.github/workflows/test-publish.yml
vendored
Normal file
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
|
||||||
86
.github/workflows/tests.yml
vendored
86
.github/workflows/tests.yml
vendored
|
|
@ -1,51 +1,53 @@
|
||||||
name: Tests
|
name: Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_call:
|
||||||
branches: ["*"]
|
|
||||||
pull_request:
|
|
||||||
# The branches below must be a subset of the branches above
|
|
||||||
branches: ["main"]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: ghcr.io/versia-pub/postgres:main
|
image: postgres:17-alpine
|
||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: versia
|
POSTGRES_DB: versia
|
||||||
POSTGRES_USER: versia
|
POSTGRES_USER: versia
|
||||||
POSTGRES_PASSWORD: versia
|
POSTGRES_PASSWORD: versia
|
||||||
volumes:
|
volumes:
|
||||||
- versia-data:/var/lib/postgresql/data
|
- versia-data:/var/lib/postgresql/data
|
||||||
options: --health-cmd pg_isready
|
options: --health-cmd pg_isready
|
||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
permissions:
|
redis:
|
||||||
contents: read
|
image: redis:latest
|
||||||
security-events: write
|
ports:
|
||||||
actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
|
- 6379:6379
|
||||||
steps:
|
options: --health-cmd "redis-cli ping"
|
||||||
- name: Checkout code
|
--health-interval 10s
|
||||||
uses: actions/checkout@v4
|
--health-timeout 5s
|
||||||
with:
|
--health-retries 5
|
||||||
submodules: recursive
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
- name: Install NPM packages
|
- name: Install NPM packages
|
||||||
run: |
|
run: |
|
||||||
bun install
|
bun install
|
||||||
|
|
||||||
- name: Move workflow config to config folder
|
- name: Move workflow config to config folder
|
||||||
run: |
|
run: |
|
||||||
mv .github/config.workflow.toml config/config.toml
|
mv .github/config.workflow.toml config/config.toml
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: |
|
run: |
|
||||||
bun run test
|
bun run test
|
||||||
|
|
|
||||||
7
.madgerc
Normal file
7
.madgerc
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"detectiveOptions": {
|
||||||
|
"ts": {
|
||||||
|
"skipTypeImports": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
.vscode/launch.json
vendored
Normal file
48
.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "bun",
|
||||||
|
"internalConsoleOptions": "neverOpen",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
|
@ -6,7 +6,10 @@
|
||||||
"cli",
|
"cli",
|
||||||
"federation",
|
"federation",
|
||||||
"config",
|
"config",
|
||||||
"plugin"
|
"worker",
|
||||||
|
"media",
|
||||||
|
"packages/client",
|
||||||
|
"packages/sdk"
|
||||||
],
|
],
|
||||||
"languageToolLinter.languageTool.ignoredWordsInWorkspace": ["versia"]
|
"languageToolLinter.languageTool.ignoredWordsInWorkspace": ["versia"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
217
CHANGELOG.md
217
CHANGELOG.md
|
|
@ -1,4 +1,93 @@
|
||||||
# `0.7.0` (unreleased)
|
# `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]
|
> [!WARNING]
|
||||||
> This release marks the rename of the project from `Lysand` to `Versia`.
|
> This release marks the rename of the project from `Lysand` to `Versia`.
|
||||||
|
|
@ -9,87 +98,87 @@ Versia Server `0.7.0` is backwards compatible with `0.6.0`. However, some new fe
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Upgraded Bun to `1.1.34`. This brings performance upgrades and better stability.
|
- 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 [ActivityPub Federation Bridge](https://github.com/versia-pub/activitypub).
|
||||||
- Added support for the [Sonic](https://github.com/valeriansaliou/sonic) search indexer.
|
- Added support for the [Sonic](https://github.com/valeriansaliou/sonic) search indexer.
|
||||||
- Note deletions are now federated.
|
- Note deletions are now federated.
|
||||||
- Note edits are now federated.
|
- Note edits are now federated.
|
||||||
- Added support for [Sentry](https://sentry.io).
|
- Added support for [Sentry](https://sentry.io).
|
||||||
- Added option for more federation debug logging.
|
- Added option for more federation debug logging.
|
||||||
- Added [**Roles API**](https://server.versia.pub/api/roles).
|
- 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 [**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 [**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 [**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 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 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.
|
- 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 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.
|
- 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.
|
- 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 HTTP proxies.
|
||||||
- Add support for serving Versia over a Tor hidden service.
|
- Add support for serving Versia over a Tor hidden service.
|
||||||
- Add global server error handler, to properly return 500 error messages to clients.
|
- Add global server error handler, to properly return 500 error messages to clients.
|
||||||
- Sign all federation HTTP requests.
|
- Sign all federation HTTP requests.
|
||||||
- Add JSON schema for configuration file.
|
- Add JSON schema for configuration file.
|
||||||
- Rewrite federation stack
|
- Rewrite federation stack
|
||||||
- Updated federation to Versia 0.4
|
- Updated federation to Versia 0.4
|
||||||
- Implement OAuth2 token revocation
|
- Implement OAuth2 token revocation
|
||||||
- Add new **Plugin API**
|
- Add new **Plugin API**
|
||||||
|
|
||||||
## Plugin System
|
## Plugin System
|
||||||
|
|
||||||
A new plugin system for extending Versia Server has been added in this release!
|
A new plugin system for extending Versia Server has been added in this release!
|
||||||
|
|
||||||
> [!NOTE]
|
> [!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
|
> 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:
|
Plugins using this framework support:
|
||||||
|
|
||||||
- [x] Plugin hotswapping and hotreloading
|
- [x] Plugin hotswapping and hotreloading
|
||||||
- [x] Manifest files (JSON, JSON5, JSONC supported) with metadata (JSON schema provided)
|
- [x] Manifest files (JSON, JSON5, JSONC supported) with metadata (JSON schema provided)
|
||||||
- [x] Installation by dropping a folder into the plugins/ directory
|
- [x] Installation by dropping a folder into the plugins/ directory
|
||||||
- [x] Support for plugins having their own NPM dependencies
|
- [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] 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] Schema-based strict config validation (plugins can specify their own schemas)
|
||||||
- [x] Full type-safety
|
- [x] Full type-safety
|
||||||
- [x] Custom hooks
|
- [x] Custom hooks
|
||||||
- [x] FFI compatibility (with `bun:ffi` or Node's FFI)
|
- [x] FFI compatibility (with `bun:ffi` or Node's FFI)
|
||||||
- [x] Custom API route registration or overriding or middlewaring
|
- [x] Custom API route registration or overriding or middlewaring
|
||||||
- [x] Automatic OpenAPI schema generation for all installed plugins
|
- [x] Automatic OpenAPI schema generation for all installed plugins
|
||||||
- [x] End-to-end and unit testing supported
|
- [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] 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] Access to internal database abstractions
|
||||||
- [x] Support for sending raw SQL to database (type-safe!)
|
- [x] Support for sending raw SQL to database (type-safe!)
|
||||||
- [x] Plugin autoload on startup with override controls (enable/disable)
|
- [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.
|
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
|
## Bug Fixes
|
||||||
|
|
||||||
- Fix favouriting/unfavouriting sometimes returning negative counts.
|
- Fix favouriting/unfavouriting sometimes returning negative counts.
|
||||||
- Non-images will now properly be uploaded to object storage.
|
- Non-images will now properly be uploaded to object storage.
|
||||||
- Make account searches case-insensitive
|
- Make account searches case-insensitive
|
||||||
- Fix image decoding error when passing media through proxy.
|
- Fix image decoding error when passing media through proxy.
|
||||||
- OpenID Connect now correctly remembers and passes `state` parameter.
|
- OpenID Connect now correctly remembers and passes `state` parameter.
|
||||||
- OpenID Connect will not reject some correct but weird redirect URIs.
|
- OpenID Connect will not reject some correct but weird redirect URIs.
|
||||||
- Markdown posts will not have invisible anchor tags anymore (this messed up accessibility).
|
- 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.
|
- 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`.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Fix [Elk Client](https://elk.zone/) not being able to log in.
|
||||||
|
|
||||||
## Removals
|
## Removals
|
||||||
|
|
||||||
- Remove old logging system, to be replaced by a new one.
|
- 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 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.
|
- Removed explicit Glitch-FE support. Glitch-FE will still work, but must be hosted separately like any other frontend.
|
||||||
|
|
||||||
## Miscellaneous
|
## Miscellaneous
|
||||||
|
|
||||||
- Remove Node.js from Docker build.
|
- Remove Node.js from Docker build.
|
||||||
- Update all dependencies.
|
- Update all dependencies.
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ TypeScript errors should be ignored with `// @ts-expect-error` comments, as well
|
||||||
|
|
||||||
To scan for all TypeScript errors, run:
|
To scan for all TypeScript errors, run:
|
||||||
```sh
|
```sh
|
||||||
bun check
|
bun typecheck
|
||||||
```
|
```
|
||||||
|
|
||||||
### Commit messages
|
### Commit messages
|
||||||
|
|
@ -153,4 +153,4 @@ If you find a bug, please open an issue on GitHub. Please make sure to include t
|
||||||
|
|
||||||
# License
|
# 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.
|
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.
|
||||||
|
|
|
||||||
15
Dockerfile
15
Dockerfile
|
|
@ -1,7 +1,5 @@
|
||||||
# Node is required for building the project
|
# Node is required for building the project
|
||||||
FROM imbios/bun-node:1-20-alpine AS base
|
FROM imbios/bun-node:latest-23-alpine AS base
|
||||||
|
|
||||||
RUN apk add --no-cache libstdc++
|
|
||||||
|
|
||||||
# Install dependencies into temp directory
|
# Install dependencies into temp directory
|
||||||
# This will cache them and speed up future builds
|
# This will cache them and speed up future builds
|
||||||
|
|
@ -22,20 +20,19 @@ COPY --from=install /temp/node_modules /temp/node_modules
|
||||||
|
|
||||||
# Build the project
|
# Build the project
|
||||||
WORKDIR /temp
|
WORKDIR /temp
|
||||||
RUN bun run build
|
RUN bun run build api
|
||||||
WORKDIR /temp/dist
|
WORKDIR /temp/dist
|
||||||
|
|
||||||
# Copy production dependencies and source code into final image
|
# Copy production dependencies and source code into final image
|
||||||
FROM oven/bun:1.1.34-alpine
|
FROM oven/bun:1.3.2-alpine
|
||||||
|
|
||||||
# Install libstdc++ for Bun and create app directory
|
# Install libstdc++ for Bun and create app directory
|
||||||
RUN apk add --no-cache libstdc++ && \
|
RUN mkdir -p /app
|
||||||
mkdir -p /app
|
|
||||||
|
|
||||||
COPY --from=build /temp/dist /app/dist
|
COPY --from=build /temp/dist /app/dist
|
||||||
COPY entrypoint.sh /app
|
COPY entrypoint.sh /app
|
||||||
|
|
||||||
LABEL org.opencontainers.image.authors="Gaspard Wierzbinski (https://cpluspatch.dev)"
|
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.source="https://github.com/versia-pub/server"
|
||||||
LABEL org.opencontainers.image.vendor="Versia Pub"
|
LABEL org.opencontainers.image.vendor="Versia Pub"
|
||||||
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
|
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
|
||||||
|
|
@ -51,4 +48,4 @@ WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENTRYPOINT [ "/bin/sh", "/app/entrypoint.sh" ]
|
ENTRYPOINT [ "/bin/sh", "/app/entrypoint.sh" ]
|
||||||
# Run migrations and start the server
|
# Run migrations and start the server
|
||||||
CMD [ "cli", "start" ]
|
CMD [ "bun", "run", "api.js" ]
|
||||||
|
|
|
||||||
54
README.md
54
README.md
|
|
@ -1,8 +1,30 @@
|
||||||
<p align="center">
|
<div align="center">
|
||||||
<a href="https://versia.pub"><img src="https://cdn.versia.pub/branding/logo-dark.svg" alt="Versia Logo" height="110"></a>
|
<a href="https://versia.pub">
|
||||||
</p>
|
<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?
|
## What is this?
|
||||||
|
|
||||||
|
|
@ -70,8 +92,10 @@ Contributions are welcome! Please see the [CONTRIBUTING.md](CONTRIBUTING.md) fil
|
||||||
|
|
||||||
The following extensions are currently supported or being worked on:
|
The following extensions are currently supported or being worked on:
|
||||||
- `pub.versia:custom_emojis`: Custom emojis
|
- `pub.versia:custom_emojis`: Custom emojis
|
||||||
- `pub.versia:polls`: Polls
|
- `pub.versia:instance_messaging`: Instance Messaging
|
||||||
|
- `pub.versia:likes`: Likes
|
||||||
- `pub.versia:share`: Share
|
- `pub.versia:share`: Share
|
||||||
|
- `pub.versia:reactions`: Reactions
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|
@ -188,7 +212,7 @@ Working endpoints are:
|
||||||
- [x] `/oauth/authorize`
|
- [x] `/oauth/authorize`
|
||||||
- [x] `/oauth/token`
|
- [x] `/oauth/token`
|
||||||
- [x] `/oauth/revoke`
|
- [x] `/oauth/revoke`
|
||||||
- Admin API
|
- Admin API
|
||||||
|
|
||||||
### Main work to do for API
|
### Main work to do for API
|
||||||
|
|
||||||
|
|
@ -213,3 +237,21 @@ For Versia Server's own custom API, please see the [API documentation](https://s
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the [AGPL-3.0-or-later](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>
|
||||||
|
|
|
||||||
51
Worker.Dockerfile
Normal file
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
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,228 +0,0 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
|
||||||
import { randomString } from "@/math";
|
|
||||||
import { Application } from "@versia/kit/db";
|
|
||||||
import { config } from "~/packages/config-manager";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
import { meta } from "./index.ts";
|
|
||||||
|
|
||||||
const { users, deleteUsers, passwords } = await getTestUsers(1);
|
|
||||||
|
|
||||||
// Create application
|
|
||||||
const application = await Application.insert({
|
|
||||||
name: "Test Application",
|
|
||||||
clientId: randomString(32, "hex"),
|
|
||||||
secret: "test",
|
|
||||||
redirectUri: "https://example.com",
|
|
||||||
scopes: "read write",
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
await application.delete();
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/auth/login
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should get a JWT with email", async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
formData.append("identifier", users[0]?.data.email ?? "");
|
|
||||||
formData.append("password", passwords[0]);
|
|
||||||
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(302);
|
|
||||||
expect(response.headers.get("location")).toBeDefined();
|
|
||||||
const locationHeader = new URL(
|
|
||||||
response.headers.get("Location") ?? "",
|
|
||||||
config.http.base_url,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(locationHeader.pathname).toBe("/oauth/consent");
|
|
||||||
expect(locationHeader.searchParams.get("client_id")).toBe(
|
|
||||||
application.data.clientId,
|
|
||||||
);
|
|
||||||
expect(locationHeader.searchParams.get("redirect_uri")).toBe(
|
|
||||||
"https://example.com",
|
|
||||||
);
|
|
||||||
expect(locationHeader.searchParams.get("response_type")).toBe("code");
|
|
||||||
expect(locationHeader.searchParams.get("scope")).toBe("read write");
|
|
||||||
|
|
||||||
expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should get a JWT with username", async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
formData.append("identifier", users[0]?.data.username ?? "");
|
|
||||||
formData.append("password", passwords[0]);
|
|
||||||
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(302);
|
|
||||||
expect(response.headers.get("location")).toBeDefined();
|
|
||||||
const locationHeader = new URL(
|
|
||||||
response.headers.get("Location") ?? "",
|
|
||||||
config.http.base_url,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(locationHeader.pathname).toBe("/oauth/consent");
|
|
||||||
expect(locationHeader.searchParams.get("client_id")).toBe(
|
|
||||||
application.data.clientId,
|
|
||||||
);
|
|
||||||
expect(locationHeader.searchParams.get("redirect_uri")).toBe(
|
|
||||||
"https://example.com",
|
|
||||||
);
|
|
||||||
expect(locationHeader.searchParams.get("response_type")).toBe("code");
|
|
||||||
expect(locationHeader.searchParams.get("scope")).toBe("read write");
|
|
||||||
|
|
||||||
expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should have state in the URL", async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
formData.append("identifier", users[0]?.data.email ?? "");
|
|
||||||
formData.append("password", passwords[0]);
|
|
||||||
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write&state=abc`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(302);
|
|
||||||
expect(response.headers.get("location")).toBeDefined();
|
|
||||||
const locationHeader = new URL(
|
|
||||||
response.headers.get("Location") ?? "",
|
|
||||||
config.http.base_url,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(locationHeader.pathname).toBe("/oauth/consent");
|
|
||||||
expect(locationHeader.searchParams.get("client_id")).toBe(
|
|
||||||
application.data.clientId,
|
|
||||||
);
|
|
||||||
expect(locationHeader.searchParams.get("redirect_uri")).toBe(
|
|
||||||
"https://example.com",
|
|
||||||
);
|
|
||||||
expect(locationHeader.searchParams.get("response_type")).toBe("code");
|
|
||||||
expect(locationHeader.searchParams.get("scope")).toBe("read write");
|
|
||||||
expect(locationHeader.searchParams.get("state")).toBe("abc");
|
|
||||||
|
|
||||||
expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("should reject invalid credentials", () => {
|
|
||||||
// Redirects to /oauth/authorize on invalid
|
|
||||||
test("invalid email", async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
formData.append("identifier", "ababa@gmail.com");
|
|
||||||
formData.append("password", "password");
|
|
||||||
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
|
||||||
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(302);
|
|
||||||
expect(response.headers.get("location")).toBeDefined();
|
|
||||||
const locationHeader = new URL(
|
|
||||||
response.headers.get("Location") ?? "",
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(locationHeader.pathname).toBe("/oauth/authorize");
|
|
||||||
expect(locationHeader.searchParams.get("error")).toBe(
|
|
||||||
"invalid_grant",
|
|
||||||
);
|
|
||||||
expect(locationHeader.searchParams.get("error_description")).toBe(
|
|
||||||
"Invalid identifier or password",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.headers.get("Set-Cookie")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("invalid username", async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
formData.append("identifier", "ababa");
|
|
||||||
formData.append("password", "password");
|
|
||||||
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(302);
|
|
||||||
expect(response.headers.get("location")).toBeDefined();
|
|
||||||
const locationHeader = new URL(
|
|
||||||
response.headers.get("Location") ?? "",
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(locationHeader.pathname).toBe("/oauth/authorize");
|
|
||||||
expect(locationHeader.searchParams.get("error")).toBe(
|
|
||||||
"invalid_grant",
|
|
||||||
);
|
|
||||||
expect(locationHeader.searchParams.get("error_description")).toBe(
|
|
||||||
"Invalid identifier or password",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.headers.get("Set-Cookie")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("invalid password", async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
formData.append("identifier", users[0]?.data.email ?? "");
|
|
||||||
formData.append("password", "password");
|
|
||||||
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(302);
|
|
||||||
expect(response.headers.get("location")).toBeDefined();
|
|
||||||
const locationHeader = new URL(
|
|
||||||
response.headers.get("Location") ?? "",
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(locationHeader.pathname).toBe("/oauth/authorize");
|
|
||||||
expect(locationHeader.searchParams.get("error")).toBe(
|
|
||||||
"invalid_grant",
|
|
||||||
);
|
|
||||||
expect(locationHeader.searchParams.get("error_description")).toBe(
|
|
||||||
"Invalid identifier or password",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.headers.get("Set-Cookie")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,236 +0,0 @@
|
||||||
import { apiRoute, applyConfig } from "@/api";
|
|
||||||
import type { Context } from "@hono/hono";
|
|
||||||
import { setCookie } from "@hono/hono/cookie";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Application, User } from "@versia/kit/db";
|
|
||||||
import { Users } from "@versia/kit/tables";
|
|
||||||
import { eq, or } from "drizzle-orm";
|
|
||||||
import { SignJWT } from "jose";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { config } from "~/packages/config-manager";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 4,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/auth/login",
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
form: z.object({
|
|
||||||
identifier: z
|
|
||||||
.string()
|
|
||||||
.email()
|
|
||||||
.toLowerCase()
|
|
||||||
.or(z.string().toLowerCase()),
|
|
||||||
password: z.string().min(2).max(100),
|
|
||||||
}),
|
|
||||||
query: z.object({
|
|
||||||
scope: z.string().optional(),
|
|
||||||
redirect_uri: z.string().url().optional(),
|
|
||||||
response_type: z.enum([
|
|
||||||
"code",
|
|
||||||
"token",
|
|
||||||
"none",
|
|
||||||
"id_token",
|
|
||||||
"code id_token",
|
|
||||||
"code token",
|
|
||||||
"token id_token",
|
|
||||||
"code token id_token",
|
|
||||||
]),
|
|
||||||
client_id: z.string(),
|
|
||||||
state: z.string().optional(),
|
|
||||||
code_challenge: z.string().optional(),
|
|
||||||
code_challenge_method: z.enum(["plain", "S256"]).optional(),
|
|
||||||
prompt: z
|
|
||||||
.enum(["none", "login", "consent", "select_account"])
|
|
||||||
.optional()
|
|
||||||
.default("none"),
|
|
||||||
max_age: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.optional()
|
|
||||||
.default(60 * 60 * 24 * 7),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/auth/login",
|
|
||||||
summary: "Login",
|
|
||||||
description: "Login to the application",
|
|
||||||
request: {
|
|
||||||
body: {
|
|
||||||
content: {
|
|
||||||
"multipart/form-data": {
|
|
||||||
schema: schemas.form,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
query: schemas.query,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
302: {
|
|
||||||
description: "Redirect to OAuth authorize, or error",
|
|
||||||
headers: {
|
|
||||||
"Set-Cookie": {
|
|
||||||
description: "JWT cookie",
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const returnError = (
|
|
||||||
context: Context,
|
|
||||||
error: string,
|
|
||||||
description: string,
|
|
||||||
): Response => {
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
|
|
||||||
// Add all data that is not undefined except email and password
|
|
||||||
for (const [key, value] of Object.entries(context.req.query())) {
|
|
||||||
if (key !== "email" && key !== "password" && value !== undefined) {
|
|
||||||
searchParams.append(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
searchParams.append("error", error);
|
|
||||||
searchParams.append("error_description", description);
|
|
||||||
|
|
||||||
return context.redirect(
|
|
||||||
new URL(
|
|
||||||
`${config.frontend.routes.login}?${searchParams.toString()}`,
|
|
||||||
config.http.base_url,
|
|
||||||
).toString(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const oidcConfig = config.plugins?.config?.["@versia/openid"] as
|
|
||||||
| {
|
|
||||||
forced: boolean;
|
|
||||||
providers: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
icon: string;
|
|
||||||
}[];
|
|
||||||
keys: {
|
|
||||||
private: string;
|
|
||||||
public: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (!oidcConfig) {
|
|
||||||
return returnError(
|
|
||||||
context,
|
|
||||||
"invalid_request",
|
|
||||||
"The OpenID Connect plugin is not enabled on this instance. Cannot process login request.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oidcConfig?.forced) {
|
|
||||||
return returnError(
|
|
||||||
context,
|
|
||||||
"invalid_request",
|
|
||||||
"Logging in with a password is disabled by the administrator. Please use a valid OpenID Connect provider.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { identifier, password } = context.req.valid("form");
|
|
||||||
const { client_id } = context.req.valid("query");
|
|
||||||
|
|
||||||
// Find user
|
|
||||||
const user = await User.fromSql(
|
|
||||||
or(
|
|
||||||
eq(Users.email, identifier.toLowerCase()),
|
|
||||||
eq(Users.username, identifier.toLowerCase()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!(
|
|
||||||
user &&
|
|
||||||
(await Bun.password.verify(password, user.data.password || ""))
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return returnError(
|
|
||||||
context,
|
|
||||||
"invalid_grant",
|
|
||||||
"Invalid identifier or password",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.data.passwordResetToken) {
|
|
||||||
return context.redirect(
|
|
||||||
`${config.frontend.routes.password_reset}?${new URLSearchParams(
|
|
||||||
{
|
|
||||||
token: user.data.passwordResetToken ?? "",
|
|
||||||
login_reset: "true",
|
|
||||||
},
|
|
||||||
).toString()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try and import the key
|
|
||||||
const privateKey = await crypto.subtle.importKey(
|
|
||||||
"pkcs8",
|
|
||||||
Buffer.from(oidcConfig?.keys?.private ?? "", "base64"),
|
|
||||||
"Ed25519",
|
|
||||||
false,
|
|
||||||
["sign"],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Generate JWT
|
|
||||||
const jwt = await new SignJWT({
|
|
||||||
sub: user.id,
|
|
||||||
iss: new URL(config.http.base_url).origin,
|
|
||||||
aud: client_id,
|
|
||||||
exp: Math.floor(Date.now() / 1000) + 60 * 60,
|
|
||||||
iat: Math.floor(Date.now() / 1000),
|
|
||||||
nbf: Math.floor(Date.now() / 1000),
|
|
||||||
})
|
|
||||||
.setProtectedHeader({ alg: "EdDSA" })
|
|
||||||
.sign(privateKey);
|
|
||||||
|
|
||||||
const application = await Application.fromClientId(client_id);
|
|
||||||
|
|
||||||
if (!application) {
|
|
||||||
return context.json({ error: "Invalid application" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchParams = new URLSearchParams({
|
|
||||||
application: application.data.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (application.data.website) {
|
|
||||||
searchParams.append("website", application.data.website);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add all data that is not undefined except email and password
|
|
||||||
for (const [key, value] of Object.entries(context.req.query())) {
|
|
||||||
if (key !== "email" && key !== "password" && value !== undefined) {
|
|
||||||
searchParams.append(key, String(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to OAuth authorize with JWT
|
|
||||||
setCookie(context, "jwt", jwt, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: true,
|
|
||||||
sameSite: "Strict",
|
|
||||||
path: "/",
|
|
||||||
maxAge: 60 * 60,
|
|
||||||
});
|
|
||||||
return context.redirect(
|
|
||||||
`${config.frontend.routes.consent}?${searchParams.toString()}`,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
import { apiRoute, applyConfig } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { db } from "@versia/kit/db";
|
|
||||||
import { Applications, Tokens } from "@versia/kit/tables";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { config } from "~/packages/config-manager";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 4,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/auth/redirect",
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
query: z.object({
|
|
||||||
redirect_uri: z.string().url(),
|
|
||||||
client_id: z.string(),
|
|
||||||
code: z.string(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/auth/redirect",
|
|
||||||
summary: "OAuth Code flow",
|
|
||||||
description:
|
|
||||||
"Redirects to the application, or back to login if the code is invalid",
|
|
||||||
responses: {
|
|
||||||
302: {
|
|
||||||
description:
|
|
||||||
"Redirects to the application, or back to login if the code is invalid",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
request: {
|
|
||||||
query: schemas.query,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OAuth Code flow
|
|
||||||
*/
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { redirect_uri, client_id, code } = context.req.valid("query");
|
|
||||||
|
|
||||||
const redirectToLogin = (error: string): Response =>
|
|
||||||
context.redirect(
|
|
||||||
`${config.frontend.routes.login}?${new URLSearchParams({
|
|
||||||
...context.req.query,
|
|
||||||
error: encodeURIComponent(error),
|
|
||||||
}).toString()}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const foundToken = await db
|
|
||||||
.select()
|
|
||||||
.from(Tokens)
|
|
||||||
.leftJoin(Applications, eq(Tokens.applicationId, Applications.id))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(Tokens.code, code),
|
|
||||||
eq(Applications.clientId, client_id),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!foundToken || foundToken.length <= 0) {
|
|
||||||
return redirectToLogin("Invalid code");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect back to application
|
|
||||||
return context.redirect(
|
|
||||||
`${redirect_uri}?${new URLSearchParams({
|
|
||||||
code,
|
|
||||||
}).toString()}`,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
|
||||||
import { randomString } from "@/math";
|
|
||||||
import { Application } from "@versia/kit/db";
|
|
||||||
import { config } from "~/packages/config-manager";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
import { meta } from "./index.ts";
|
|
||||||
|
|
||||||
const { users, deleteUsers, passwords } = await getTestUsers(1);
|
|
||||||
const token = randomString(32, "hex");
|
|
||||||
const newPassword = randomString(16, "hex");
|
|
||||||
|
|
||||||
// Create application
|
|
||||||
const application = await Application.insert({
|
|
||||||
name: "Test Application",
|
|
||||||
clientId: randomString(32, "hex"),
|
|
||||||
secret: "test",
|
|
||||||
redirectUri: "https://example.com",
|
|
||||||
scopes: "read write",
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
await application.delete();
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/auth/reset
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should login with normal password", async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
formData.append("identifier", users[0]?.data.username ?? "");
|
|
||||||
formData.append("password", passwords[0]);
|
|
||||||
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
|
||||||
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(302);
|
|
||||||
expect(response.headers.get("location")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reset password and refuse login with old password", async () => {
|
|
||||||
await users[0]?.update({
|
|
||||||
passwordResetToken: token,
|
|
||||||
});
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
formData.append("identifier", users[0]?.data.username ?? "");
|
|
||||||
formData.append("password", passwords[0]);
|
|
||||||
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(302);
|
|
||||||
expect(response.headers.get("location")).toBeDefined();
|
|
||||||
const locationHeader = new URL(
|
|
||||||
response.headers.get("Location") ?? "",
|
|
||||||
config.http.base_url,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(locationHeader.pathname).toBe("/oauth/reset");
|
|
||||||
expect(locationHeader.searchParams.get("token")).toBe(token);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reset password and login with new password", async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
formData.append("token", token);
|
|
||||||
formData.append("password", newPassword);
|
|
||||||
formData.append("password2", newPassword);
|
|
||||||
|
|
||||||
const response = await fakeRequest("/api/auth/reset", {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(302);
|
|
||||||
expect(response.headers.get("location")).toBeDefined();
|
|
||||||
|
|
||||||
const loginFormData = new FormData();
|
|
||||||
|
|
||||||
loginFormData.append("identifier", users[0]?.data.username ?? "");
|
|
||||||
loginFormData.append("password", newPassword);
|
|
||||||
|
|
||||||
const loginResponse = await fakeRequest(
|
|
||||||
`/api/auth/login?client_id=${application.data.clientId}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: loginFormData,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(loginResponse.status).toBe(302);
|
|
||||||
expect(loginResponse.headers.get("location")).toBeDefined();
|
|
||||||
const locationHeader = new URL(
|
|
||||||
loginResponse.headers.get("Location") ?? "",
|
|
||||||
config.http.base_url,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(locationHeader.pathname).toBe("/oauth/consent");
|
|
||||||
expect(locationHeader.searchParams.get("client_id")).toBe(
|
|
||||||
application.data.clientId,
|
|
||||||
);
|
|
||||||
expect(locationHeader.searchParams.get("redirect_uri")).toBe(
|
|
||||||
"https://example.com",
|
|
||||||
);
|
|
||||||
expect(locationHeader.searchParams.get("response_type")).toBe("code");
|
|
||||||
expect(locationHeader.searchParams.get("scope")).toBe("read write");
|
|
||||||
|
|
||||||
expect(loginResponse.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
import { apiRoute, applyConfig } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { User } from "@versia/kit/db";
|
|
||||||
import { Users } from "@versia/kit/tables";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import type { Context } from "hono";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { config } from "~/packages/config-manager";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 4,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/auth/reset",
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
form: z.object({
|
|
||||||
token: z.string().min(1),
|
|
||||||
password: z.string().min(3).max(100),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/auth/reset",
|
|
||||||
summary: "Reset password",
|
|
||||||
description: "Reset password",
|
|
||||||
responses: {
|
|
||||||
302: {
|
|
||||||
description: "Redirect to the password reset page with a message",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
request: {
|
|
||||||
body: {
|
|
||||||
content: {
|
|
||||||
"application/x-www-form-urlencoded": {
|
|
||||||
schema: schemas.form,
|
|
||||||
},
|
|
||||||
"multipart/form-data": {
|
|
||||||
schema: schemas.form,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const returnError = (
|
|
||||||
context: Context,
|
|
||||||
token: string,
|
|
||||||
error: string,
|
|
||||||
description: string,
|
|
||||||
): Response => {
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
|
|
||||||
searchParams.append("error", error);
|
|
||||||
searchParams.append("error_description", description);
|
|
||||||
searchParams.append("token", token);
|
|
||||||
|
|
||||||
return context.redirect(
|
|
||||||
new URL(
|
|
||||||
`${
|
|
||||||
config.frontend.routes.password_reset
|
|
||||||
}?${searchParams.toString()}`,
|
|
||||||
config.http.base_url,
|
|
||||||
).toString(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { token, password } = context.req.valid("form");
|
|
||||||
|
|
||||||
const user = await User.fromSql(eq(Users.passwordResetToken, token));
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return returnError(
|
|
||||||
context,
|
|
||||||
token,
|
|
||||||
"invalid_token",
|
|
||||||
"Invalid token",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await user.update({
|
|
||||||
password: await Bun.password.hash(password),
|
|
||||||
passwordResetToken: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
return context.redirect(
|
|
||||||
`${config.frontend.routes.password_reset}?success=true`,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
|
||||||
import type { Relationship as ApiRelationship } from "@versia/client/types";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
import { meta } from "./block.ts";
|
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/v1/accounts/:id/block
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return 401 if not authenticated", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", users[1].id),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return 404 if user not found", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", "00000000-0000-0000-0000-000000000000"),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should block user", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", users[1].id),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const relationship = (await response.json()) as ApiRelationship;
|
|
||||||
expect(relationship.blocking).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return 200 if user already blocked", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", users[1].id),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const relationship = (await response.json()) as ApiRelationship;
|
|
||||||
expect(relationship.blocking).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Relationship, User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/block",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:blocks"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [
|
|
||||||
RolePermissions.ManageOwnBlocks,
|
|
||||||
RolePermissions.ViewAccounts,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
param: z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/accounts/{id}/block",
|
|
||||||
summary: "Block user",
|
|
||||||
description: "Block a user",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Updated relationship",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: Relationship.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "User not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) {
|
|
||||||
return context.json({ error: "User not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
|
||||||
user,
|
|
||||||
otherUser,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!foundRelationship.data.blocking) {
|
|
||||||
await foundRelationship.update({
|
|
||||||
blocking: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(foundRelationship.toApi(), 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
|
||||||
import type { Relationship as ApiRelationship } from "@versia/client/types";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
import { meta } from "./follow.ts";
|
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/v1/accounts/:id/follow
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return 401 if not authenticated", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", users[1].id),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return 404 if user not found", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", "00000000-0000-0000-0000-000000000000"),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should follow user", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", users[1].id),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const relationship = (await response.json()) as ApiRelationship;
|
|
||||||
expect(relationship.following).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return 200 if user already followed", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", users[1].id),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const relationship = (await response.json()) as ApiRelationship;
|
|
||||||
expect(relationship.following).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Relationship, User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import ISO6391 from "iso-639-1";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/follow",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:follows"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [
|
|
||||||
RolePermissions.ManageOwnFollows,
|
|
||||||
RolePermissions.ViewAccounts,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
param: z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
json: z
|
|
||||||
.object({
|
|
||||||
reblogs: z.coerce.boolean().optional(),
|
|
||||||
notify: z.coerce.boolean().optional(),
|
|
||||||
languages: z
|
|
||||||
.array(z.enum(ISO6391.getAllCodes() as [string, ...string[]]))
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.default({ reblogs: true, notify: false, languages: [] }),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/accounts/{id}/follow",
|
|
||||||
summary: "Follow user",
|
|
||||||
description: "Follow a user",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Updated relationship",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: Relationship.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "User not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
body: {
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: schemas.json,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
const { reblogs, notify, languages } = context.req.valid("json");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) {
|
|
||||||
return context.json({ error: "User not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
let relationship = await Relationship.fromOwnerAndSubject(
|
|
||||||
user,
|
|
||||||
otherUser,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!relationship.data.following) {
|
|
||||||
relationship = await user.followRequest(otherUser, {
|
|
||||||
reblogs,
|
|
||||||
notify,
|
|
||||||
languages,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(relationship.toApi(), 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
||||||
import type { Account as ApiAccount } from "@versia/client/types";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
import { meta } from "./followers.ts";
|
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
// Follow user
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`/api/v1/accounts/${users[1].id}/follow`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/v1/accounts/:id/followers
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return 200 with followers", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", users[1].id),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const data = (await response.json()) as ApiAccount[];
|
|
||||||
|
|
||||||
expect(data).toBeInstanceOf(Array);
|
|
||||||
expect(data.length).toBe(1);
|
|
||||||
expect(data[0].id).toBe(users[0].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return no followers after unfollowing", async () => {
|
|
||||||
// Unfollow user
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`/api/v1/accounts/${users[1].id}/unfollow`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const response2 = await fakeRequest(
|
|
||||||
meta.route.replace(":id", users[1].id),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response2.status).toBe(200);
|
|
||||||
|
|
||||||
const data = (await response2.json()) as ApiAccount[];
|
|
||||||
|
|
||||||
expect(data).toBeInstanceOf(Array);
|
|
||||||
expect(data.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth, idValidator } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Timeline, User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions, Users } from "@versia/kit/tables";
|
|
||||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 60,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/followers",
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
oauthPermissions: ["read:accounts"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [
|
|
||||||
RolePermissions.ViewAccountFollows,
|
|
||||||
RolePermissions.ViewAccounts,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
query: z.object({
|
|
||||||
max_id: z.string().regex(idValidator).optional(),
|
|
||||||
since_id: z.string().regex(idValidator).optional(),
|
|
||||||
min_id: z.string().regex(idValidator).optional(),
|
|
||||||
limit: z.coerce.number().int().min(1).max(40).optional().default(20),
|
|
||||||
}),
|
|
||||||
param: z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/accounts/{id}/followers",
|
|
||||||
summary: "Get account followers",
|
|
||||||
description:
|
|
||||||
"Gets an paginated list of accounts that follow the specified account",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
query: schemas.query,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "A list of accounts that follow the specified account",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.array(User.schema),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
Link: {
|
|
||||||
description: "Links to the next and previous pages",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "The specified account was not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
const { max_id, since_id, min_id, limit } = context.req.valid("query");
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
// TODO: Add follower/following privacy settings
|
|
||||||
|
|
||||||
if (!otherUser) {
|
|
||||||
return context.json({ error: "User not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { objects, link } = await Timeline.getUserTimeline(
|
|
||||||
and(
|
|
||||||
max_id ? lt(Users.id, max_id) : undefined,
|
|
||||||
since_id ? gte(Users.id, since_id) : undefined,
|
|
||||||
min_id ? gt(Users.id, min_id) : undefined,
|
|
||||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${otherUser.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."following" = true)`,
|
|
||||||
),
|
|
||||||
limit,
|
|
||||||
context.req.url,
|
|
||||||
);
|
|
||||||
|
|
||||||
return context.json(
|
|
||||||
await Promise.all(objects.map((object) => object.toApi())),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
Link: link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
||||||
import type { Account as ApiAccount } from "@versia/client/types";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
import { meta } from "./following.ts";
|
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
// Follow user
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`/api/v1/accounts/${users[1].id}/follow`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/v1/accounts/:id/following
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return 200 with following", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", users[0].id),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const data = (await response.json()) as ApiAccount[];
|
|
||||||
|
|
||||||
expect(data).toBeInstanceOf(Array);
|
|
||||||
expect(data.length).toBe(1);
|
|
||||||
expect(data[0].id).toBe(users[1].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return no following after unfollowing", async () => {
|
|
||||||
// Unfollow user
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`/api/v1/accounts/${users[1].id}/unfollow`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const response2 = await fakeRequest(
|
|
||||||
meta.route.replace(":id", users[0].id),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response2.status).toBe(200);
|
|
||||||
|
|
||||||
const data = (await response2.json()) as ApiAccount[];
|
|
||||||
|
|
||||||
expect(data).toBeInstanceOf(Array);
|
|
||||||
expect(data.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth, idValidator } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Timeline, User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions, Users } from "@versia/kit/tables";
|
|
||||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 60,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/following",
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
oauthPermissions: ["read:accounts"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [
|
|
||||||
RolePermissions.ViewAccountFollows,
|
|
||||||
RolePermissions.ViewAccounts,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
query: z.object({
|
|
||||||
max_id: z.string().regex(idValidator).optional(),
|
|
||||||
since_id: z.string().regex(idValidator).optional(),
|
|
||||||
min_id: z.string().regex(idValidator).optional(),
|
|
||||||
limit: z.coerce.number().int().min(1).max(40).optional().default(20),
|
|
||||||
}),
|
|
||||||
param: z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/accounts/{id}/following",
|
|
||||||
summary: "Get account following",
|
|
||||||
description:
|
|
||||||
"Gets an paginated list of accounts that the specified account follows",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
query: schemas.query,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description:
|
|
||||||
"A list of accounts that the specified account follows",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.array(User.schema),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
Link: {
|
|
||||||
description: "Link to the next page of results",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "User not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
const { max_id, since_id, min_id } = context.req.valid("query");
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) {
|
|
||||||
return context.json({ error: "User not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Add follower/following privacy settings
|
|
||||||
|
|
||||||
const { objects, link } = await Timeline.getUserTimeline(
|
|
||||||
and(
|
|
||||||
max_id ? lt(Users.id, max_id) : undefined,
|
|
||||||
since_id ? gte(Users.id, since_id) : undefined,
|
|
||||||
min_id ? gt(Users.id, min_id) : undefined,
|
|
||||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
|
|
||||||
),
|
|
||||||
context.req.valid("query").limit,
|
|
||||||
context.req.url,
|
|
||||||
);
|
|
||||||
|
|
||||||
return context.json(
|
|
||||||
await Promise.all(objects.map((object) => object.toApi())),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
Link: link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id",
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
oauthPermissions: [],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ViewAccounts],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
param: z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/accounts/{id}",
|
|
||||||
summary: "Get account data",
|
|
||||||
description: "Gets the specified account data",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Account data",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: User.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "User not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
const foundUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!foundUser) {
|
|
||||||
return context.json({ error: "User not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(foundUser.toApi(user?.id === foundUser.id), 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
|
||||||
import type { Relationship as ApiRelationship } from "@versia/client/types";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
import { meta } from "./mute.ts";
|
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/v1/accounts/:id/mute
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return 401 if not authenticated", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", users[1].id),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return 404 if user not found", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", "00000000-0000-0000-0000-000000000000"),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should mute user", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", users[1].id),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const relationship = (await response.json()) as ApiRelationship;
|
|
||||||
expect(relationship.muting).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return 200 if user already muted", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", users[1].id),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const relationship = (await response.json()) as ApiRelationship;
|
|
||||||
expect(relationship.muting).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Relationship, User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/mute",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:mutes"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [
|
|
||||||
RolePermissions.ManageOwnMutes,
|
|
||||||
RolePermissions.ViewAccounts,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
param: z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
json: z.object({
|
|
||||||
notifications: z.boolean().optional(),
|
|
||||||
duration: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(60)
|
|
||||||
.max(60 * 60 * 24 * 365 * 5)
|
|
||||||
.optional(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/accounts/{id}/mute",
|
|
||||||
summary: "Mute user",
|
|
||||||
description: "Mute a user",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
body: {
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: schemas.json,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Updated relationship",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: Relationship.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "User not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
// TODO: Add duration support
|
|
||||||
const { notifications } = context.req.valid("json");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) {
|
|
||||||
return context.json({ error: "User not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
|
||||||
user,
|
|
||||||
otherUser,
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: Implement duration
|
|
||||||
await foundRelationship.update({
|
|
||||||
muting: true,
|
|
||||||
mutingNotifications: notifications ?? true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return context.json(foundRelationship.toApi(), 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Relationship, User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/note",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:accounts"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [
|
|
||||||
RolePermissions.ManageOwnAccount,
|
|
||||||
RolePermissions.ViewAccounts,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
param: z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
json: z.object({
|
|
||||||
comment: z.string().min(0).max(5000).trim().optional(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/accounts/{id}/note",
|
|
||||||
summary: "Set note",
|
|
||||||
description: "Set a note on a user's profile, visible only to you",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
body: {
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: schemas.json,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Updated relationship",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: Relationship.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "User not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
const { comment } = context.req.valid("json");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) {
|
|
||||||
return context.json({ error: "User not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
|
||||||
user,
|
|
||||||
otherUser,
|
|
||||||
);
|
|
||||||
|
|
||||||
await foundRelationship.update({
|
|
||||||
note: comment,
|
|
||||||
});
|
|
||||||
|
|
||||||
return context.json(foundRelationship.toApi(), 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Relationship, User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/pin",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:accounts"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [
|
|
||||||
RolePermissions.ManageOwnAccount,
|
|
||||||
RolePermissions.ViewAccounts,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
param: z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/accounts/{id}/pin",
|
|
||||||
summary: "Pin user",
|
|
||||||
description: "Pin a user to your profile",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Updated relationship",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: Relationship.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "User not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) {
|
|
||||||
return context.json({ error: "User not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
|
||||||
user,
|
|
||||||
otherUser,
|
|
||||||
);
|
|
||||||
|
|
||||||
await foundRelationship.update({
|
|
||||||
endorsed: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return context.json(foundRelationship.toApi(), 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 4,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/refetch",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:accounts"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ViewAccounts],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
param: z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/accounts/{id}/refetch",
|
|
||||||
summary: "Refetch user",
|
|
||||||
description: "Refetch a user's profile from the remote server",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Updated user data",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: User.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "User not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
400: {
|
|
||||||
description: "User is local",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) {
|
|
||||||
return context.json({ error: "User not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (otherUser.isLocal()) {
|
|
||||||
return context.json({ error: "Cannot refetch a local user" }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newUser = await otherUser.updateFromRemote();
|
|
||||||
|
|
||||||
return context.json(newUser.toApi(false), 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Relationship, User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/remove_from_followers",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:follows"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [
|
|
||||||
RolePermissions.ManageOwnFollows,
|
|
||||||
RolePermissions.ViewAccounts,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
param: z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/accounts/{id}/remove_from_followers",
|
|
||||||
summary: "Remove user from followers",
|
|
||||||
description: "Remove a user from your followers",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Updated relationship",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: Relationship.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "User not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
const { user: self } = context.get("auth");
|
|
||||||
|
|
||||||
if (!self) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) {
|
|
||||||
return context.json({ error: "User not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
|
|
||||||
otherUser,
|
|
||||||
self,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (oppositeRelationship.data.following) {
|
|
||||||
await oppositeRelationship.update({
|
|
||||||
following: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
|
||||||
self,
|
|
||||||
otherUser,
|
|
||||||
);
|
|
||||||
|
|
||||||
return context.json(foundRelationship.toApi(), 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
||||||
import type { Status as ApiStatus } from "@versia/client/types";
|
|
||||||
import { fakeRequest, getTestStatuses, getTestUsers } from "~/tests/utils.ts";
|
|
||||||
import { meta } from "./statuses.ts";
|
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
|
||||||
const timeline = (await getTestStatuses(40, users[1])).toReversed();
|
|
||||||
const timeline2 = (await getTestStatuses(40, users[2])).toReversed();
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`/api/v1/statuses/${timeline2[0].id}/reblog`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/v1/accounts/:id/statuses
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return 200 with statuses", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", users[1].id),
|
|
||||||
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const data = (await response.json()) as ApiStatus[];
|
|
||||||
|
|
||||||
expect(data.length).toBe(20);
|
|
||||||
// Should have reblogs
|
|
||||||
expect(data[0].reblog).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should exclude reblogs", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`${meta.route.replace(":id", users[1].id)}?exclude_reblogs=true`,
|
|
||||||
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const data = (await response.json()) as ApiStatus[];
|
|
||||||
|
|
||||||
expect(data.length).toBe(20);
|
|
||||||
// Should not have reblogs
|
|
||||||
expect(data[0].reblog).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should exclude replies", async () => {
|
|
||||||
// Create reply
|
|
||||||
const replyResponse = await fakeRequest("/api/v1/statuses", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
status: "Reply",
|
|
||||||
in_reply_to_id: timeline[0].id,
|
|
||||||
local_only: "true",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(replyResponse.status).toBe(201);
|
|
||||||
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`${meta.route.replace(":id", users[1].id)}?exclude_replies=true`,
|
|
||||||
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const data = (await response.json()) as ApiStatus[];
|
|
||||||
|
|
||||||
expect(data.length).toBe(20);
|
|
||||||
// Should not have replies
|
|
||||||
expect(data[0].in_reply_to_id).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should only include pins", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`${meta.route.replace(":id", users[1].id)}?pinned=true`,
|
|
||||||
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const data = (await response.json()) as ApiStatus[];
|
|
||||||
|
|
||||||
expect(data.length).toBe(0);
|
|
||||||
|
|
||||||
// Create pin
|
|
||||||
const pinResponse = await fakeRequest(
|
|
||||||
`/api/v1/statuses/${timeline[3].id}/pin`,
|
|
||||||
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(pinResponse.status).toBe(200);
|
|
||||||
|
|
||||||
const response2 = await fakeRequest(
|
|
||||||
`${meta.route.replace(":id", users[1].id)}?pinned=true`,
|
|
||||||
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response2.status).toBe(200);
|
|
||||||
|
|
||||||
const data2 = (await response2.json()) as ApiStatus[];
|
|
||||||
|
|
||||||
expect(data2.length).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth, idValidator } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Note, Timeline, User } from "@versia/kit/db";
|
|
||||||
import { Notes, RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { and, eq, gt, gte, inArray, isNull, lt, or, sql } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/statuses",
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
oauthPermissions: ["read:statuses"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ViewNotes, RolePermissions.ViewAccounts],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
param: z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
query: z.object({
|
|
||||||
max_id: z.string().regex(idValidator).optional(),
|
|
||||||
since_id: z.string().regex(idValidator).optional(),
|
|
||||||
min_id: z.string().regex(idValidator).optional(),
|
|
||||||
limit: z.coerce.number().int().min(1).max(40).optional().default(20),
|
|
||||||
only_media: z
|
|
||||||
.string()
|
|
||||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
|
||||||
.optional(),
|
|
||||||
exclude_replies: z
|
|
||||||
.string()
|
|
||||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
|
||||||
.optional(),
|
|
||||||
exclude_reblogs: z
|
|
||||||
.string()
|
|
||||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
|
||||||
.optional(),
|
|
||||||
pinned: z
|
|
||||||
.string()
|
|
||||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
|
||||||
.optional(),
|
|
||||||
tagged: z
|
|
||||||
.string()
|
|
||||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
|
||||||
.optional(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/accounts/{id}/statuses",
|
|
||||||
summary: "Get account statuses",
|
|
||||||
description: "Gets an paginated list of statuses by the specified account",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
query: schemas.query,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "A list of statuses by the specified account",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.array(Note.schema),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
Link: {
|
|
||||||
description: "Links to the next and previous pages",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "User not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) {
|
|
||||||
return context.json({ error: "User not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
max_id,
|
|
||||||
min_id,
|
|
||||||
since_id,
|
|
||||||
limit,
|
|
||||||
exclude_reblogs,
|
|
||||||
only_media,
|
|
||||||
exclude_replies,
|
|
||||||
pinned,
|
|
||||||
} = context.req.valid("query");
|
|
||||||
|
|
||||||
const { objects, link } = await Timeline.getNoteTimeline(
|
|
||||||
and(
|
|
||||||
max_id ? lt(Notes.id, max_id) : undefined,
|
|
||||||
since_id ? gte(Notes.id, since_id) : undefined,
|
|
||||||
min_id ? gt(Notes.id, min_id) : undefined,
|
|
||||||
eq(Notes.authorId, id),
|
|
||||||
only_media
|
|
||||||
? sql`EXISTS (SELECT 1 FROM "Attachments" WHERE "Attachments"."noteId" = ${Notes.id})`
|
|
||||||
: undefined,
|
|
||||||
pinned
|
|
||||||
? sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."noteId" = ${Notes.id} AND "UserToPinnedNotes"."userId" = ${otherUser.id})`
|
|
||||||
: undefined,
|
|
||||||
// Visibility check
|
|
||||||
or(
|
|
||||||
sql`EXISTS (SELECT 1 FROM "NoteToMentions" WHERE "NoteToMentions"."noteId" = ${Notes.id} AND "NoteToMentions"."userId" = ${otherUser.id})`,
|
|
||||||
and(
|
|
||||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Notes.authorId} AND "Relationships"."ownerId" = ${otherUser.id} AND "Relationships"."following" = true)`,
|
|
||||||
inArray(Notes.visibility, ["public", "private"]),
|
|
||||||
),
|
|
||||||
inArray(Notes.visibility, ["public", "unlisted"]),
|
|
||||||
),
|
|
||||||
exclude_reblogs ? isNull(Notes.reblogId) : undefined,
|
|
||||||
exclude_replies ? isNull(Notes.replyId) : undefined,
|
|
||||||
),
|
|
||||||
limit,
|
|
||||||
context.req.url,
|
|
||||||
user?.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
return context.json(
|
|
||||||
await Promise.all(objects.map((note) => note.toApi(otherUser))),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Relationship, User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/unblock",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:blocks"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [
|
|
||||||
RolePermissions.ManageOwnBlocks,
|
|
||||||
RolePermissions.ViewAccounts,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
param: z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/accounts/{id}/unblock",
|
|
||||||
summary: "Unblock user",
|
|
||||||
description: "Unblock a user",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Updated relationship",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: Relationship.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "User not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) {
|
|
||||||
return context.json({ error: "User not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
|
||||||
user,
|
|
||||||
otherUser,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (foundRelationship.data.blocking) {
|
|
||||||
await foundRelationship.update({
|
|
||||||
blocking: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(foundRelationship.toApi(), 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Relationship, User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/unfollow",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:follows"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [
|
|
||||||
RolePermissions.ManageOwnFollows,
|
|
||||||
RolePermissions.ViewAccounts,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
param: z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/accounts/{id}/unfollow",
|
|
||||||
summary: "Unfollow user",
|
|
||||||
description: "Unfollow a user",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Updated relationship",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: Relationship.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "User not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
500: {
|
|
||||||
description: "Failed to unfollow user during federation",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
const { user: self } = context.get("auth");
|
|
||||||
|
|
||||||
if (!self) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) {
|
|
||||||
return context.json({ error: "User not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
|
||||||
self,
|
|
||||||
otherUser,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!(await self.unfollow(otherUser, foundRelationship))) {
|
|
||||||
return context.json({ error: "Failed to unfollow user" }, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(foundRelationship.toApi(), 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
||||||
import type { Relationship as ApiRelationship } from "@versia/client/types";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
import { meta } from "./unmute.ts";
|
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
await fakeRequest(`/api/v1/accounts/${users[0].id}/mute`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/v1/accounts/:id/unmute
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return 401 if not authenticated", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", users[1].id),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return 404 if user not found", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", "00000000-0000-0000-0000-000000000000"),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should unmute user", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", users[1].id),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const relationship = (await response.json()) as ApiRelationship;
|
|
||||||
expect(relationship.muting).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return 200 if user already unmuted", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", users[1].id),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const relationship = (await response.json()) as ApiRelationship;
|
|
||||||
expect(relationship.muting).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Relationship, User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/unmute",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:mutes"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [
|
|
||||||
RolePermissions.ManageOwnMutes,
|
|
||||||
RolePermissions.ViewAccounts,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
param: z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/accounts/{id}/unmute",
|
|
||||||
summary: "Unmute user",
|
|
||||||
description: "Unmute a user",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Updated relationship",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: Relationship.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "User not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
const { user: self } = context.get("auth");
|
|
||||||
|
|
||||||
if (!self) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "User not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
|
||||||
self,
|
|
||||||
user,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (foundRelationship.data.muting) {
|
|
||||||
await foundRelationship.update({
|
|
||||||
muting: false,
|
|
||||||
mutingNotifications: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(foundRelationship.toApi(), 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Relationship, User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/:id/unpin",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:accounts"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [
|
|
||||||
RolePermissions.ManageOwnAccount,
|
|
||||||
RolePermissions.ViewAccounts,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
param: z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/accounts/{id}/unpin",
|
|
||||||
summary: "Unpin user",
|
|
||||||
description: "Unpin a user from your profile",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Updated relationship",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: Relationship.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "User not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
const { user: self } = context.get("auth");
|
|
||||||
|
|
||||||
if (!self) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherUser = await User.fromId(id);
|
|
||||||
|
|
||||||
if (!otherUser) {
|
|
||||||
return context.json({ error: "User not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
|
||||||
self,
|
|
||||||
otherUser,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (foundRelationship.data.endorsed) {
|
|
||||||
await foundRelationship.update({
|
|
||||||
endorsed: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(foundRelationship.toApi(), 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils.ts";
|
|
||||||
import { meta } from "./index.ts";
|
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
// Create followers relationships
|
|
||||||
const result1 = await fakeRequest(
|
|
||||||
`/api/v1/accounts/${users[1].id}/follow`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result1.status).toBe(200);
|
|
||||||
|
|
||||||
const result2 = await fakeRequest(
|
|
||||||
`/api/v1/accounts/${users[2].id}/follow`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result2.status).toBe(200);
|
|
||||||
|
|
||||||
const result3 = await fakeRequest(
|
|
||||||
`/api/v1/accounts/${users[3].id}/follow`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result3.status).toBe(200);
|
|
||||||
|
|
||||||
const result4 = await fakeRequest(
|
|
||||||
`/api/v1/accounts/${users[2].id}/follow`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result4.status).toBe(200);
|
|
||||||
|
|
||||||
const result5 = await fakeRequest(
|
|
||||||
`/api/v1/accounts/${users[3].id}/follow`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result5.status).toBe(200);
|
|
||||||
|
|
||||||
const result6 = await fakeRequest(
|
|
||||||
`/api/v1/accounts/${users[3].id}/follow`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[2].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result6.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return 0 familiar followers", async () => {
|
|
||||||
const response = await fakeRequest(`${meta.route}?id=${users[4].id}`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
expect(data.length).toBe(1);
|
|
||||||
expect(data[0].id).toBe(users[4].id);
|
|
||||||
expect(data[0].accounts).toBeArrayOfSize(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return 1 familiar follower", async () => {
|
|
||||||
const response = await fakeRequest(`${meta.route}?id=${users[2].id}`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
expect(data.length).toBe(1);
|
|
||||||
expect(data[0].id).toBe(users[2].id);
|
|
||||||
expect(data[0].accounts[0].id).toBe(users[1].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return 2 familiar followers", async () => {
|
|
||||||
const response = await fakeRequest(`${meta.route}?id=${users[3].id}`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
expect(data.length).toBe(1);
|
|
||||||
expect(data[0].id).toBe(users[3].id);
|
|
||||||
expect(data[0].accounts).toBeArrayOfSize(2);
|
|
||||||
expect(data[0].accounts[0].id).toBe(users[2].id);
|
|
||||||
expect(data[0].accounts[1].id).toBe(users[1].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should work with multiple ids", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`${meta.route}?id[]=${users[2].id}&id[]=${users[3].id}&id[]=${users[4].id}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
expect(data.length).toBe(3);
|
|
||||||
expect(data[0].id).toBe(users[2].id);
|
|
||||||
expect(data[0].accounts[0].id).toBe(users[1].id);
|
|
||||||
expect(data[1].id).toBe(users[3].id);
|
|
||||||
expect(data[1].accounts[0].id).toBe(users[2].id);
|
|
||||||
expect(data[1].accounts[1].id).toBe(users[1].id);
|
|
||||||
expect(data[2].id).toBe(users[4].id);
|
|
||||||
expect(data[2].accounts).toBeArrayOfSize(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth, qsQuery } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { User, db } from "@versia/kit/db";
|
|
||||||
import { RolePermissions, type Users } from "@versia/kit/tables";
|
|
||||||
import { type InferSelectModel, sql } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/accounts/familiar_followers",
|
|
||||||
ratelimits: {
|
|
||||||
max: 5,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["read:follows"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ManageOwnFollows],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
query: z.object({
|
|
||||||
id: z
|
|
||||||
.array(z.string().uuid())
|
|
||||||
.min(1)
|
|
||||||
.max(10)
|
|
||||||
.or(z.string().uuid())
|
|
||||||
.transform((v) => (Array.isArray(v) ? v : [v])),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/accounts/familiar_followers",
|
|
||||||
summary: "Get familiar followers",
|
|
||||||
description:
|
|
||||||
"Obtain a list of all accounts that follow a given account, filtered for accounts you follow.",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions), qsQuery()],
|
|
||||||
request: {
|
|
||||||
query: schemas.query,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Familiar followers",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.array(
|
|
||||||
z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
accounts: z.array(User.schema),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { user: self } = context.get("auth");
|
|
||||||
const { id: ids } = context.req.valid("query");
|
|
||||||
|
|
||||||
if (!self) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find followers of the accounts in "ids", that you also follow
|
|
||||||
const finalUsers = await Promise.all(
|
|
||||||
ids.map(async (id) => ({
|
|
||||||
id,
|
|
||||||
accounts: await User.fromIds(
|
|
||||||
(
|
|
||||||
await db.execute(sql<InferSelectModel<typeof Users>>`
|
|
||||||
SELECT "Users"."id" FROM "Users"
|
|
||||||
INNER JOIN "Relationships" AS "SelfFollowing"
|
|
||||||
ON "SelfFollowing"."subjectId" = "Users"."id"
|
|
||||||
WHERE "SelfFollowing"."ownerId" = ${self.id}
|
|
||||||
AND "SelfFollowing"."following" = true
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM "Relationships" AS "IdsFollowers"
|
|
||||||
WHERE "IdsFollowers"."subjectId" = ${id}
|
|
||||||
AND "IdsFollowers"."ownerId" = "Users"."id"
|
|
||||||
AND "IdsFollowers"."following" = true
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
).rows.map((u) => u.id as string),
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
return context.json(
|
|
||||||
finalUsers.map((u) => ({
|
|
||||||
...u,
|
|
||||||
accounts: u.accounts.map((a) => a.toApi()),
|
|
||||||
})),
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
|
||||||
import type { Account as ApiAccount } from "@versia/client/types";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
import { meta } from "./index.ts";
|
|
||||||
|
|
||||||
const { users, deleteUsers } = await getTestUsers(5);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/v1/accounts/id
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should correctly get user from username", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`${meta.route}?username=${users[0].data.username}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const data = (await response.json()) as ApiAccount;
|
|
||||||
|
|
||||||
expect(data.id).toBe(users[0].id);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return 404 for non-existent user", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`${meta.route}?username=${users[0].data.username}-nonexistent`,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions, Users } from "@versia/kit/tables";
|
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/id",
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
oauthPermissions: [],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.Search],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
query: z.object({
|
|
||||||
username: z.string().min(1).max(512).toLowerCase(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/accounts/id",
|
|
||||||
summary: "Get account by username",
|
|
||||||
description: "Get an account by username",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
query: schemas.query,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Account",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: User.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "Not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { username } = context.req.valid("query");
|
|
||||||
|
|
||||||
const user = await User.fromSql(
|
|
||||||
and(eq(Users.username, username), isNull(Users.instanceId)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "User not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(user.toApi(), 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,223 +0,0 @@
|
||||||
import { afterEach, describe, expect, test } from "bun:test";
|
|
||||||
import { randomString } from "@/math";
|
|
||||||
import { db } from "@versia/kit/db";
|
|
||||||
import { Users } from "@versia/kit/tables";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { fakeRequest, getSolvedChallenge } from "~/tests/utils";
|
|
||||||
import { meta } from "./index.ts";
|
|
||||||
|
|
||||||
const username = randomString(10, "hex");
|
|
||||||
const username2 = randomString(10, "hex");
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await db.delete(Users).where(eq(Users.username, username));
|
|
||||||
await db.delete(Users).where(eq(Users.username, username2));
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/v1/statuses
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should create a new account", async () => {
|
|
||||||
const response = await fakeRequest(meta.route, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username,
|
|
||||||
email: "bob@gamer.com",
|
|
||||||
password: "password",
|
|
||||||
agreement: "true",
|
|
||||||
locale: "en",
|
|
||||||
reason: "testing",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should refuse invalid emails", async () => {
|
|
||||||
const response = await fakeRequest(meta.route, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username,
|
|
||||||
email: "bob",
|
|
||||||
password: "password",
|
|
||||||
agreement: "true",
|
|
||||||
locale: "en",
|
|
||||||
reason: "testing",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(422);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should require a password", async () => {
|
|
||||||
const response = await fakeRequest(meta.route, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username,
|
|
||||||
email: "contatc@bob.com",
|
|
||||||
agreement: "true",
|
|
||||||
locale: "en",
|
|
||||||
reason: "testing",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(422);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should not allow a previously registered email", async () => {
|
|
||||||
await fakeRequest(meta.route, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username,
|
|
||||||
email: "contact@george.com",
|
|
||||||
password: "password",
|
|
||||||
agreement: "true",
|
|
||||||
locale: "en",
|
|
||||||
reason: "testing",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fakeRequest(meta.route, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: username2,
|
|
||||||
email: "contact@george.com",
|
|
||||||
password: "password",
|
|
||||||
agreement: "true",
|
|
||||||
locale: "en",
|
|
||||||
reason: "testing",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(422);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should not allow a previously registered email (case insensitive)", async () => {
|
|
||||||
await fakeRequest(meta.route, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username,
|
|
||||||
email: "contact@george.com",
|
|
||||||
password: "password",
|
|
||||||
agreement: "true",
|
|
||||||
locale: "en",
|
|
||||||
reason: "testing",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fakeRequest(meta.route, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: username2,
|
|
||||||
email: "CONTACT@george.CoM",
|
|
||||||
password: "password",
|
|
||||||
agreement: "true",
|
|
||||||
locale: "en",
|
|
||||||
reason: "testing",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(422);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should not allow invalid usernames (not a-z_0-9)", async () => {
|
|
||||||
const response1 = await fakeRequest(meta.route, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: "bob$",
|
|
||||||
email: "contact@bob.com",
|
|
||||||
password: "password",
|
|
||||||
agreement: "true",
|
|
||||||
locale: "en",
|
|
||||||
reason: "testing",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response1.status).toBe(422);
|
|
||||||
|
|
||||||
const response2 = await fakeRequest(meta.route, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: "bob-markey",
|
|
||||||
email: "contact@bob.com",
|
|
||||||
password: "password",
|
|
||||||
agreement: "true",
|
|
||||||
locale: "en",
|
|
||||||
reason: "testing",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response2.status).toBe(422);
|
|
||||||
|
|
||||||
const response3 = await fakeRequest(meta.route, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: "bob markey",
|
|
||||||
email: "contact@bob.com",
|
|
||||||
password: "password",
|
|
||||||
agreement: "true",
|
|
||||||
locale: "en",
|
|
||||||
reason: "testing",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response3.status).toBe(422);
|
|
||||||
|
|
||||||
const response4 = await fakeRequest(meta.route, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Challenge-Solution": await getSolvedChallenge(),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
username: "BOB",
|
|
||||||
email: "contact@bob.com",
|
|
||||||
password: "password",
|
|
||||||
agreement: "true",
|
|
||||||
locale: "en",
|
|
||||||
reason: "testing",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response4.status).toBe(422);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,342 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api";
|
|
||||||
import { tempmailDomains } from "@/tempmail";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { User } from "@versia/kit/db";
|
|
||||||
import { Users } from "@versia/kit/tables";
|
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
|
||||||
import ISO6391 from "iso-639-1";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { config } from "~/packages/config-manager";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/accounts",
|
|
||||||
ratelimits: {
|
|
||||||
max: 2,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
oauthPermissions: ["write:accounts"],
|
|
||||||
},
|
|
||||||
challenge: {
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
json: z.object({
|
|
||||||
username: z.string(),
|
|
||||||
email: z.string().toLowerCase(),
|
|
||||||
password: z.string().optional(),
|
|
||||||
agreement: z
|
|
||||||
.string()
|
|
||||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
|
||||||
.or(z.boolean()),
|
|
||||||
locale: z.string(),
|
|
||||||
reason: z.string(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/accounts",
|
|
||||||
summary: "Create account",
|
|
||||||
description: "Register a new account",
|
|
||||||
middleware: [
|
|
||||||
auth(meta.auth, meta.permissions, meta.challenge),
|
|
||||||
jsonOrForm(),
|
|
||||||
],
|
|
||||||
request: {
|
|
||||||
body: {
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: schemas.json,
|
|
||||||
},
|
|
||||||
"multipart/form-data": {
|
|
||||||
schema: schemas.json,
|
|
||||||
},
|
|
||||||
"application/x-www-form-urlencoded": {
|
|
||||||
schema: schemas.json,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Account created",
|
|
||||||
},
|
|
||||||
422: {
|
|
||||||
description: "Validation failed",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.object({
|
|
||||||
error: z.string(),
|
|
||||||
details: z.object({
|
|
||||||
username: z.array(
|
|
||||||
z.object({
|
|
||||||
error: z.enum([
|
|
||||||
"ERR_BLANK",
|
|
||||||
"ERR_INVALID",
|
|
||||||
"ERR_TOO_LONG",
|
|
||||||
"ERR_TOO_SHORT",
|
|
||||||
"ERR_BLOCKED",
|
|
||||||
"ERR_TAKEN",
|
|
||||||
"ERR_RESERVED",
|
|
||||||
"ERR_ACCEPTED",
|
|
||||||
"ERR_INCLUSION",
|
|
||||||
]),
|
|
||||||
description: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
email: z.array(
|
|
||||||
z.object({
|
|
||||||
error: z.enum([
|
|
||||||
"ERR_BLANK",
|
|
||||||
"ERR_INVALID",
|
|
||||||
"ERR_BLOCKED",
|
|
||||||
"ERR_TAKEN",
|
|
||||||
]),
|
|
||||||
description: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
password: z.array(
|
|
||||||
z.object({
|
|
||||||
error: z.enum([
|
|
||||||
"ERR_BLANK",
|
|
||||||
"ERR_INVALID",
|
|
||||||
"ERR_TOO_LONG",
|
|
||||||
"ERR_TOO_SHORT",
|
|
||||||
]),
|
|
||||||
description: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
agreement: z.array(
|
|
||||||
z.object({
|
|
||||||
error: z.enum(["ERR_ACCEPTED"]),
|
|
||||||
description: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
locale: z.array(
|
|
||||||
z.object({
|
|
||||||
error: z.enum(["ERR_BLANK", "ERR_INVALID"]),
|
|
||||||
description: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
reason: z.array(
|
|
||||||
z.object({
|
|
||||||
error: z.enum(["ERR_BLANK"]),
|
|
||||||
description: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const form = context.req.valid("json");
|
|
||||||
const { username, email, password, agreement, locale } =
|
|
||||||
context.req.valid("json");
|
|
||||||
|
|
||||||
if (!config.signups.registration) {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: "Registration is disabled",
|
|
||||||
},
|
|
||||||
422,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const errors: {
|
|
||||||
details: Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
error:
|
|
||||||
| "ERR_BLANK"
|
|
||||||
| "ERR_INVALID"
|
|
||||||
| "ERR_TOO_LONG"
|
|
||||||
| "ERR_TOO_SHORT"
|
|
||||||
| "ERR_BLOCKED"
|
|
||||||
| "ERR_TAKEN"
|
|
||||||
| "ERR_RESERVED"
|
|
||||||
| "ERR_ACCEPTED"
|
|
||||||
| "ERR_INCLUSION";
|
|
||||||
description: string;
|
|
||||||
}[]
|
|
||||||
>;
|
|
||||||
} = {
|
|
||||||
details: {
|
|
||||||
password: [],
|
|
||||||
username: [],
|
|
||||||
email: [],
|
|
||||||
agreement: [],
|
|
||||||
locale: [],
|
|
||||||
reason: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if fields are blank
|
|
||||||
for (const value of [
|
|
||||||
"username",
|
|
||||||
"email",
|
|
||||||
"password",
|
|
||||||
"agreement",
|
|
||||||
"locale",
|
|
||||||
"reason",
|
|
||||||
]) {
|
|
||||||
// @ts-expect-error We don't care about the type here
|
|
||||||
if (!form[value]) {
|
|
||||||
errors.details[value].push({
|
|
||||||
error: "ERR_BLANK",
|
|
||||||
description: "can't be blank",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if username is valid
|
|
||||||
if (!username?.match(/^[a-z0-9_]+$/)) {
|
|
||||||
errors.details.username.push({
|
|
||||||
error: "ERR_INVALID",
|
|
||||||
description:
|
|
||||||
"must only contain lowercase letters, numbers, and underscores",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if username doesnt match filters
|
|
||||||
if (config.filters.username.some((filter) => username?.match(filter))) {
|
|
||||||
errors.details.username.push({
|
|
||||||
error: "ERR_INVALID",
|
|
||||||
description: "contains blocked words",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if username is too long
|
|
||||||
if ((username?.length ?? 0) > config.validation.max_username_size) {
|
|
||||||
errors.details.username.push({
|
|
||||||
error: "ERR_TOO_LONG",
|
|
||||||
description: `is too long (maximum is ${config.validation.max_username_size} characters)`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if username is too short
|
|
||||||
if ((username?.length ?? 0) < 3) {
|
|
||||||
errors.details.username.push({
|
|
||||||
error: "ERR_TOO_SHORT",
|
|
||||||
description: "is too short (minimum is 3 characters)",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if username is reserved
|
|
||||||
if (config.validation.username_blacklist.includes(username ?? "")) {
|
|
||||||
errors.details.username.push({
|
|
||||||
error: "ERR_RESERVED",
|
|
||||||
description: "is reserved",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if username is taken
|
|
||||||
if (
|
|
||||||
await User.fromSql(
|
|
||||||
and(eq(Users.username, username)),
|
|
||||||
isNull(Users.instanceId),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
errors.details.username.push({
|
|
||||||
error: "ERR_TAKEN",
|
|
||||||
description: "is already taken",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if email is valid
|
|
||||||
if (
|
|
||||||
!email?.match(
|
|
||||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
errors.details.email.push({
|
|
||||||
error: "ERR_INVALID",
|
|
||||||
description: "must be a valid email address",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if email is blocked
|
|
||||||
if (
|
|
||||||
config.validation.email_blacklist.includes(email) ||
|
|
||||||
(config.validation.blacklist_tempmail &&
|
|
||||||
tempmailDomains.domains.includes((email ?? "").split("@")[1]))
|
|
||||||
) {
|
|
||||||
errors.details.email.push({
|
|
||||||
error: "ERR_BLOCKED",
|
|
||||||
description: "is from a blocked email provider",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if email is taken
|
|
||||||
if (await User.fromSql(eq(Users.email, email))) {
|
|
||||||
errors.details.email.push({
|
|
||||||
error: "ERR_TAKEN",
|
|
||||||
description: "is already taken",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if agreement is accepted
|
|
||||||
if (!agreement) {
|
|
||||||
errors.details.agreement.push({
|
|
||||||
error: "ERR_ACCEPTED",
|
|
||||||
description: "must be accepted",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!locale) {
|
|
||||||
errors.details.locale.push({
|
|
||||||
error: "ERR_BLANK",
|
|
||||||
description: "can't be blank",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ISO6391.validate(locale ?? "")) {
|
|
||||||
errors.details.locale.push({
|
|
||||||
error: "ERR_INVALID",
|
|
||||||
description: "must be a valid ISO 639-1 code",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// If any errors are present, return them
|
|
||||||
if (Object.values(errors.details).some((value) => value.length > 0)) {
|
|
||||||
// Error is something like "Validation failed: Password can't be blank, Username must contain only letters, numbers and underscores, Agreement must be accepted"
|
|
||||||
|
|
||||||
const errorsText = Object.entries(errors.details)
|
|
||||||
.filter(([_, errors]) => errors.length > 0)
|
|
||||||
.map(
|
|
||||||
([name, errors]) =>
|
|
||||||
`${name} ${errors
|
|
||||||
.map((error) => error.description)
|
|
||||||
.join(", ")}`,
|
|
||||||
)
|
|
||||||
.join(", ");
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: `Validation failed: ${errorsText}`,
|
|
||||||
details: Object.fromEntries(
|
|
||||||
Object.entries(errors.details).filter(
|
|
||||||
([_, errors]) => errors.length > 0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
422,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await User.fromDataLocal({
|
|
||||||
username: username ?? "",
|
|
||||||
password: password ?? "",
|
|
||||||
email: email ?? "",
|
|
||||||
});
|
|
||||||
|
|
||||||
return context.newResponse(null, 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
|
||||||
import type { Account as ApiAccount } from "@versia/client/types";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
import { meta } from "./index.ts";
|
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/v1/accounts/lookup
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return 200 with users", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`${meta.route}?acct=${users[0].data.username}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const data = (await response.json()) as ApiAccount[];
|
|
||||||
expect(data).toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
id: users[0].id,
|
|
||||||
username: users[0].data.username,
|
|
||||||
display_name: users[0].data.displayName,
|
|
||||||
avatar: expect.any(String),
|
|
||||||
header: expect.any(String),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions, Users } from "@versia/kit/tables";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import {
|
|
||||||
anyOf,
|
|
||||||
charIn,
|
|
||||||
createRegExp,
|
|
||||||
digit,
|
|
||||||
exactly,
|
|
||||||
global,
|
|
||||||
letter,
|
|
||||||
maybe,
|
|
||||||
oneOrMore,
|
|
||||||
} from "magic-regexp";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/accounts/lookup",
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
oauthPermissions: [],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.Search],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
query: z.object({
|
|
||||||
acct: z.string().min(1).max(512),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/accounts/lookup",
|
|
||||||
summary: "Lookup account",
|
|
||||||
description: "Lookup an account by acct",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
query: schemas.query,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Account",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: User.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "Not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { acct } = context.req.valid("query");
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
// Check if acct is matching format username@domain.com or @username@domain.com
|
|
||||||
const accountMatches = acct?.trim().match(
|
|
||||||
createRegExp(
|
|
||||||
maybe("@"),
|
|
||||||
oneOrMore(
|
|
||||||
anyOf(letter.lowercase, digit, charIn("-")),
|
|
||||||
).groupedAs("username"),
|
|
||||||
exactly("@"),
|
|
||||||
oneOrMore(anyOf(letter, digit, charIn("_-.:"))).groupedAs(
|
|
||||||
"domain",
|
|
||||||
),
|
|
||||||
|
|
||||||
[global],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (accountMatches) {
|
|
||||||
// Remove leading @ if it exists
|
|
||||||
if (accountMatches[0].startsWith("@")) {
|
|
||||||
accountMatches[0] = accountMatches[0].slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [username, domain] = accountMatches[0].split("@");
|
|
||||||
|
|
||||||
const manager = await (user ?? User).getFederationRequester();
|
|
||||||
|
|
||||||
const uri = await User.webFinger(manager, username, domain);
|
|
||||||
|
|
||||||
const foundAccount = await User.resolve(uri);
|
|
||||||
|
|
||||||
if (foundAccount) {
|
|
||||||
return context.json(foundAccount.toApi(), 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json({ error: "Account not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
let username = acct;
|
|
||||||
if (username.startsWith("@")) {
|
|
||||||
username = username.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const account = await User.fromSql(eq(Users.username, username));
|
|
||||||
|
|
||||||
if (account) {
|
|
||||||
return context.json(account.toApi(), 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(
|
|
||||||
{ error: `Account with username ${username} not found` },
|
|
||||||
404,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
||||||
import { db } from "@versia/kit/db";
|
|
||||||
import { Users } from "@versia/kit/tables";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
import { meta } from "./index.ts";
|
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
// user0 should be `locked`
|
|
||||||
// user1 should follow user0
|
|
||||||
// user0 should follow user2
|
|
||||||
await db
|
|
||||||
.update(Users)
|
|
||||||
.set({ isLocked: true })
|
|
||||||
.where(eq(Users.id, users[0].id));
|
|
||||||
|
|
||||||
const res1 = await fakeRequest(`/api/v1/accounts/${users[0].id}/follow`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res1.ok).toBe(true);
|
|
||||||
|
|
||||||
const res2 = await fakeRequest(`/api/v1/accounts/${users[2].id}/follow`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res2.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/v1/accounts/relationships
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return 401 if not authenticated", async () => {
|
|
||||||
const response = await fakeRequest(`${meta.route}?id[]=${users[2].id}`);
|
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return relationships", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`${meta.route}?id[]=${users[2].id}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const body = await response.json();
|
|
||||||
expect(body).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
|
||||||
id: users[2].id,
|
|
||||||
following: true,
|
|
||||||
followed_by: false,
|
|
||||||
blocking: false,
|
|
||||||
muting: false,
|
|
||||||
muting_notifications: false,
|
|
||||||
requested: false,
|
|
||||||
domain_blocking: false,
|
|
||||||
endorsed: false,
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should be requested_by user1", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`${meta.route}?id[]=${users[1].id}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const body = await response.json();
|
|
||||||
expect(body).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
|
||||||
following: false,
|
|
||||||
followed_by: false,
|
|
||||||
blocking: false,
|
|
||||||
muting: false,
|
|
||||||
muting_notifications: false,
|
|
||||||
requested_by: true,
|
|
||||||
domain_blocking: false,
|
|
||||||
endorsed: false,
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth, qsQuery } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Relationship } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/accounts/relationships",
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["read:follows"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ManageOwnFollows],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
query: z.object({
|
|
||||||
id: z.array(z.string().uuid()).min(1).max(10).or(z.string().uuid()),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/accounts/relationships",
|
|
||||||
summary: "Get relationships",
|
|
||||||
description: "Get relationships by account ID",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions), qsQuery()],
|
|
||||||
request: {
|
|
||||||
query: schemas.query,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Relationships",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.array(Relationship.schema),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { user: self } = context.get("auth");
|
|
||||||
const { id } = context.req.valid("query");
|
|
||||||
|
|
||||||
const ids = Array.isArray(id) ? id : [id];
|
|
||||||
|
|
||||||
if (!self) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const relationships = await Relationship.fromOwnerAndSubjects(
|
|
||||||
self,
|
|
||||||
ids,
|
|
||||||
);
|
|
||||||
|
|
||||||
relationships.sort(
|
|
||||||
(a, b) =>
|
|
||||||
ids.indexOf(a.data.subjectId) - ids.indexOf(b.data.subjectId),
|
|
||||||
);
|
|
||||||
|
|
||||||
return context.json(
|
|
||||||
relationships.map((r) => r.toApi()),
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
|
||||||
import type { Account as ApiAccount } from "@versia/client/types";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
import { meta } from "./index.ts";
|
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(5);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/v1/accounts/search
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return 200 with users", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`${meta.route}?q=${users[0].data.username}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const data = (await response.json()) as ApiAccount[];
|
|
||||||
expect(data).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
|
||||||
id: users[0].id,
|
|
||||||
username: users[0].data.username,
|
|
||||||
display_name: users[0].data.displayName,
|
|
||||||
avatar: expect.any(String),
|
|
||||||
header: expect.any(String),
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions, Users } from "@versia/kit/tables";
|
|
||||||
import { eq, ilike, not, or, sql } from "drizzle-orm";
|
|
||||||
import {
|
|
||||||
anyOf,
|
|
||||||
charIn,
|
|
||||||
createRegExp,
|
|
||||||
digit,
|
|
||||||
exactly,
|
|
||||||
global,
|
|
||||||
letter,
|
|
||||||
maybe,
|
|
||||||
oneOrMore,
|
|
||||||
} from "magic-regexp";
|
|
||||||
import stringComparison from "string-comparison";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/accounts/search",
|
|
||||||
ratelimits: {
|
|
||||||
max: 100,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
oauthPermissions: ["read:accounts"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.Search, RolePermissions.ViewAccounts],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
query: z.object({
|
|
||||||
q: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.max(512)
|
|
||||||
.regex(
|
|
||||||
createRegExp(
|
|
||||||
maybe("@"),
|
|
||||||
oneOrMore(
|
|
||||||
anyOf(letter.lowercase, digit, charIn("-")),
|
|
||||||
).groupedAs("username"),
|
|
||||||
maybe(
|
|
||||||
exactly("@"),
|
|
||||||
oneOrMore(
|
|
||||||
anyOf(letter, digit, charIn("_-.:")),
|
|
||||||
).groupedAs("domain"),
|
|
||||||
),
|
|
||||||
[global],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
limit: z.coerce.number().int().min(1).max(80).default(40),
|
|
||||||
offset: z.coerce.number().int().optional(),
|
|
||||||
resolve: z
|
|
||||||
.string()
|
|
||||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
|
||||||
.optional(),
|
|
||||||
following: z
|
|
||||||
.string()
|
|
||||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
|
||||||
.optional(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/accounts/search",
|
|
||||||
summary: "Search accounts",
|
|
||||||
description: "Search for accounts",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
query: schemas.query,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Accounts",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.array(User.schema),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { q, limit, offset, resolve, following } =
|
|
||||||
context.req.valid("query");
|
|
||||||
const { user: self } = context.get("auth");
|
|
||||||
|
|
||||||
if (!self && following) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [username, host] = q.replace(/^@/, "").split("@");
|
|
||||||
|
|
||||||
const accounts: User[] = [];
|
|
||||||
|
|
||||||
if (resolve && username && host) {
|
|
||||||
const manager = await (self ?? User).getFederationRequester();
|
|
||||||
|
|
||||||
const uri = await User.webFinger(manager, username, host);
|
|
||||||
|
|
||||||
const resolvedUser = await User.resolve(uri);
|
|
||||||
|
|
||||||
if (resolvedUser) {
|
|
||||||
accounts.push(resolvedUser);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
accounts.push(
|
|
||||||
...(await User.manyFromSql(
|
|
||||||
or(
|
|
||||||
ilike(Users.displayName, `%${q}%`),
|
|
||||||
ilike(Users.username, `%${q}%`),
|
|
||||||
following && self
|
|
||||||
? sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${self.id} AND "Relationships"."following" = true)`
|
|
||||||
: undefined,
|
|
||||||
self ? not(eq(Users.id, self.id)) : undefined,
|
|
||||||
),
|
|
||||||
undefined,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const indexOfCorrectSort = stringComparison.jaccardIndex
|
|
||||||
.sortMatch(
|
|
||||||
q,
|
|
||||||
accounts.map((acct) => acct.getAcct()),
|
|
||||||
)
|
|
||||||
.map((sort) => sort.index);
|
|
||||||
|
|
||||||
const result = indexOfCorrectSort.map((index) => accounts[index]);
|
|
||||||
|
|
||||||
return context.json(
|
|
||||||
result.map((acct) => acct.toApi()),
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,388 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api";
|
|
||||||
import { sanitizedHtmlStrip } from "@/sanitization";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Attachment, Emoji, User, db } from "@versia/kit/db";
|
|
||||||
import { EmojiToUser, RolePermissions, Users } from "@versia/kit/tables";
|
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
|
||||||
import ISO6391 from "iso-639-1";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { contentToHtml } from "~/classes/functions/status";
|
|
||||||
import { MediaManager } from "~/classes/media/media-manager";
|
|
||||||
import { config } from "~/packages/config-manager/index.ts";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/accounts/update_credentials",
|
|
||||||
ratelimits: {
|
|
||||||
max: 2,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:accounts"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ManageOwnAccount],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
json: z.object({
|
|
||||||
display_name: z
|
|
||||||
.string()
|
|
||||||
.min(3)
|
|
||||||
.trim()
|
|
||||||
.max(config.validation.max_displayname_size)
|
|
||||||
.refine(
|
|
||||||
(s) =>
|
|
||||||
!config.filters.displayname.some((filter) =>
|
|
||||||
s.match(filter),
|
|
||||||
),
|
|
||||||
"Display name contains blocked words",
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
username: z
|
|
||||||
.string()
|
|
||||||
.min(3)
|
|
||||||
.trim()
|
|
||||||
.max(config.validation.max_username_size)
|
|
||||||
.toLowerCase()
|
|
||||||
.regex(
|
|
||||||
/^[a-z0-9_-]+$/,
|
|
||||||
"Username can only contain letters, numbers, underscores and hyphens",
|
|
||||||
)
|
|
||||||
.refine(
|
|
||||||
(s) =>
|
|
||||||
!config.filters.username.some((filter) => s.match(filter)),
|
|
||||||
"Username contains blocked words",
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
note: z
|
|
||||||
.string()
|
|
||||||
.min(0)
|
|
||||||
.max(config.validation.max_bio_size)
|
|
||||||
.trim()
|
|
||||||
.refine(
|
|
||||||
(s) => !config.filters.bio.some((filter) => s.match(filter)),
|
|
||||||
"Bio contains blocked words",
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
avatar: z
|
|
||||||
.instanceof(File)
|
|
||||||
.refine(
|
|
||||||
(v) => v.size <= config.validation.max_avatar_size,
|
|
||||||
`Avatar must be less than ${config.validation.max_avatar_size} bytes`,
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
header: z
|
|
||||||
.instanceof(File)
|
|
||||||
.refine(
|
|
||||||
(v) => v.size <= config.validation.max_header_size,
|
|
||||||
`Header must be less than ${config.validation.max_header_size} bytes`,
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
locked: z
|
|
||||||
.string()
|
|
||||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
|
||||||
.optional(),
|
|
||||||
bot: z
|
|
||||||
.string()
|
|
||||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
|
||||||
.optional(),
|
|
||||||
discoverable: z
|
|
||||||
.string()
|
|
||||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
|
||||||
.optional(),
|
|
||||||
source: z
|
|
||||||
.object({
|
|
||||||
privacy: z
|
|
||||||
.enum(["public", "unlisted", "private", "direct"])
|
|
||||||
.optional(),
|
|
||||||
sensitive: z
|
|
||||||
.string()
|
|
||||||
.transform((v) =>
|
|
||||||
["true", "1", "on"].includes(v.toLowerCase()),
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
language: z
|
|
||||||
.enum(ISO6391.getAllCodes() as [string, ...string[]])
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
fields_attributes: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
name: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.max(config.validation.max_field_name_size),
|
|
||||||
value: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.max(config.validation.max_field_value_size),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.max(config.validation.max_field_count)
|
|
||||||
.optional(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "patch",
|
|
||||||
path: "/api/v1/accounts/update_credentials",
|
|
||||||
summary: "Update credentials",
|
|
||||||
description: "Update user credentials",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions), jsonOrForm()],
|
|
||||||
request: {
|
|
||||||
body: {
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: schemas.json,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Updated user",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: User.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
422: {
|
|
||||||
description: "Validation error",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
500: {
|
|
||||||
description: "Couldn't edit user",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
const {
|
|
||||||
display_name,
|
|
||||||
username,
|
|
||||||
note,
|
|
||||||
avatar,
|
|
||||||
header,
|
|
||||||
locked,
|
|
||||||
bot,
|
|
||||||
discoverable,
|
|
||||||
source,
|
|
||||||
fields_attributes,
|
|
||||||
} = context.req.valid("json");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const self = user.data;
|
|
||||||
|
|
||||||
const sanitizedDisplayName = await sanitizedHtmlStrip(
|
|
||||||
display_name ?? "",
|
|
||||||
);
|
|
||||||
|
|
||||||
const mediaManager = new MediaManager(config);
|
|
||||||
|
|
||||||
if (display_name) {
|
|
||||||
self.displayName = sanitizedDisplayName;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (note && self.source) {
|
|
||||||
self.source.note = note;
|
|
||||||
self.note = await contentToHtml({
|
|
||||||
"text/markdown": {
|
|
||||||
content: note,
|
|
||||||
remote: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source?.privacy) {
|
|
||||||
self.source.privacy = source.privacy;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source?.sensitive) {
|
|
||||||
self.source.sensitive = source.sensitive;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source?.language) {
|
|
||||||
self.source.language = source.language;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (username) {
|
|
||||||
// Check if username is already taken
|
|
||||||
const existingUser = await User.fromSql(
|
|
||||||
and(isNull(Users.instanceId), eq(Users.username, username)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingUser) {
|
|
||||||
return context.json(
|
|
||||||
{ error: "Username is already taken" },
|
|
||||||
422,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.username = username;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (avatar) {
|
|
||||||
const { path } = await mediaManager.addFile(avatar);
|
|
||||||
|
|
||||||
self.avatar = Attachment.getUrl(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (header) {
|
|
||||||
const { path } = await mediaManager.addFile(header);
|
|
||||||
|
|
||||||
self.header = Attachment.getUrl(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (locked) {
|
|
||||||
self.isLocked = locked;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bot) {
|
|
||||||
self.isBot = bot;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (discoverable) {
|
|
||||||
self.isDiscoverable = discoverable;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldEmojis: Emoji[] = [];
|
|
||||||
|
|
||||||
if (fields_attributes) {
|
|
||||||
self.fields = [];
|
|
||||||
self.source.fields = [];
|
|
||||||
for (const field of fields_attributes) {
|
|
||||||
// Can be Markdown or plaintext, also has emojis
|
|
||||||
const parsedName = await contentToHtml(
|
|
||||||
{
|
|
||||||
"text/markdown": {
|
|
||||||
content: field.name,
|
|
||||||
remote: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
const parsedValue = await contentToHtml(
|
|
||||||
{
|
|
||||||
"text/markdown": {
|
|
||||||
content: field.value,
|
|
||||||
remote: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Parse emojis
|
|
||||||
const nameEmojis = await Emoji.parseFromText(parsedName);
|
|
||||||
const valueEmojis = await Emoji.parseFromText(parsedValue);
|
|
||||||
|
|
||||||
fieldEmojis.push(...nameEmojis, ...valueEmojis);
|
|
||||||
|
|
||||||
// Replace fields
|
|
||||||
self.fields.push({
|
|
||||||
key: {
|
|
||||||
"text/html": {
|
|
||||||
content: parsedName,
|
|
||||||
remote: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
value: {
|
|
||||||
"text/html": {
|
|
||||||
content: parsedValue,
|
|
||||||
remote: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
self.source.fields.push({
|
|
||||||
name: field.name,
|
|
||||||
value: field.value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse emojis
|
|
||||||
const displaynameEmojis =
|
|
||||||
await Emoji.parseFromText(sanitizedDisplayName);
|
|
||||||
const noteEmojis = await Emoji.parseFromText(self.note);
|
|
||||||
|
|
||||||
self.emojis = [...displaynameEmojis, ...noteEmojis, ...fieldEmojis]
|
|
||||||
.map((e) => e.data)
|
|
||||||
.filter(
|
|
||||||
// Deduplicate emojis
|
|
||||||
(emoji, index, self) =>
|
|
||||||
self.findIndex((e) => e.id === emoji.id) === index,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Connect emojis, if any
|
|
||||||
// Do it before updating user, so that federation takes that into account
|
|
||||||
for (const emoji of self.emojis) {
|
|
||||||
await db
|
|
||||||
.delete(EmojiToUser)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(EmojiToUser.emojiId, emoji.id),
|
|
||||||
eq(EmojiToUser.userId, self.id),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
await db
|
|
||||||
.insert(EmojiToUser)
|
|
||||||
.values({
|
|
||||||
emojiId: emoji.id,
|
|
||||||
userId: self.id,
|
|
||||||
})
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
await user.update({
|
|
||||||
displayName: self.displayName,
|
|
||||||
username: self.username,
|
|
||||||
note: self.note,
|
|
||||||
avatar: self.avatar,
|
|
||||||
header: self.header,
|
|
||||||
fields: self.fields,
|
|
||||||
isLocked: self.isLocked,
|
|
||||||
isBot: self.isBot,
|
|
||||||
isDiscoverable: self.isDiscoverable,
|
|
||||||
source: self.source || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const output = await User.fromId(self.id);
|
|
||||||
if (!output) {
|
|
||||||
return context.json({ error: "Couldn't edit user" }, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(output.toApi(), 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { User } from "@versia/kit/db";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/accounts/verify_credentials",
|
|
||||||
ratelimits: {
|
|
||||||
max: 100,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["read:accounts"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/accounts/verify_credentials",
|
|
||||||
summary: "Verify credentials",
|
|
||||||
description: "Get your own account information",
|
|
||||||
middleware: [auth(meta.auth)],
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Account",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: User.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, (context) => {
|
|
||||||
// TODO: Add checks for disabled/unverified accounts
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(user.toApi(true), 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
import { apiRoute, applyConfig, jsonOrForm } from "@/api";
|
|
||||||
import { randomString } from "@/math";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Application } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/apps",
|
|
||||||
ratelimits: {
|
|
||||||
max: 2,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ManageOwnApps],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
json: z.object({
|
|
||||||
client_name: z.string().trim().min(1).max(100),
|
|
||||||
redirect_uris: z
|
|
||||||
.string()
|
|
||||||
.min(0)
|
|
||||||
.max(2000)
|
|
||||||
.url()
|
|
||||||
.or(z.literal("urn:ietf:wg:oauth:2.0:oob")),
|
|
||||||
scopes: z.string().min(1).max(200),
|
|
||||||
website: z
|
|
||||||
.string()
|
|
||||||
.min(0)
|
|
||||||
.max(2000)
|
|
||||||
.url()
|
|
||||||
.optional()
|
|
||||||
// Allow empty websites because Traewelling decides to give an empty
|
|
||||||
// value instead of not providing anything at all
|
|
||||||
.or(z.literal("").transform(() => undefined)),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/apps",
|
|
||||||
summary: "Create app",
|
|
||||||
description: "Create an OAuth2 app",
|
|
||||||
middleware: [jsonOrForm()],
|
|
||||||
request: {
|
|
||||||
body: {
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: schemas.json,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "App",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
name: z.string(),
|
|
||||||
website: z.string().nullable(),
|
|
||||||
client_id: z.string(),
|
|
||||||
client_secret: z.string(),
|
|
||||||
redirect_uri: z.string(),
|
|
||||||
vapid_link: z.string().nullable(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { client_name, redirect_uris, scopes, website } =
|
|
||||||
context.req.valid("json");
|
|
||||||
|
|
||||||
const app = await Application.insert({
|
|
||||||
name: client_name || "",
|
|
||||||
redirectUri: decodeURI(redirect_uris) || "",
|
|
||||||
scopes: scopes || "read",
|
|
||||||
website: website || null,
|
|
||||||
clientId: randomString(32, "base64url"),
|
|
||||||
secret: randomString(64, "base64url"),
|
|
||||||
});
|
|
||||||
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
id: app.id,
|
|
||||||
name: app.data.name,
|
|
||||||
website: app.data.website,
|
|
||||||
client_id: app.data.clientId,
|
|
||||||
client_secret: app.data.secret,
|
|
||||||
redirect_uri: app.data.redirectUri,
|
|
||||||
vapid_link: app.data.vapidKey,
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Application } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/apps/verify_credentials",
|
|
||||||
ratelimits: {
|
|
||||||
max: 100,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ManageOwnApps],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/apps/verify_credentials",
|
|
||||||
summary: "Verify credentials",
|
|
||||||
description: "Get your own application information",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Application",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: Application.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { user, token } = context.get("auth");
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const application = await Application.getFromToken(
|
|
||||||
token.data.accessToken,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!application) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
...application.toApi(),
|
|
||||||
redirect_uris: application.data.redirectUri,
|
|
||||||
scopes: application.data.scopes,
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth, idValidator } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Timeline, User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions, Users } from "@versia/kit/tables";
|
|
||||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/blocks",
|
|
||||||
ratelimits: {
|
|
||||||
max: 100,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["read:blocks"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ManageOwnBlocks],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
query: z.object({
|
|
||||||
max_id: z.string().regex(idValidator).optional(),
|
|
||||||
since_id: z.string().regex(idValidator).optional(),
|
|
||||||
min_id: z.string().regex(idValidator).optional(),
|
|
||||||
limit: z.coerce.number().int().min(1).max(80).default(40),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/blocks",
|
|
||||||
summary: "Get blocks",
|
|
||||||
description: "Get users you have blocked",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
query: schemas.query,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Blocks",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.array(User.schema),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { max_id, since_id, min_id, limit } = context.req.valid("query");
|
|
||||||
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { objects: blocks, link } = await Timeline.getUserTimeline(
|
|
||||||
and(
|
|
||||||
max_id ? lt(Users.id, max_id) : undefined,
|
|
||||||
since_id ? gte(Users.id, since_id) : undefined,
|
|
||||||
min_id ? gt(Users.id, min_id) : undefined,
|
|
||||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."blocking" = true)`,
|
|
||||||
),
|
|
||||||
limit,
|
|
||||||
context.req.url,
|
|
||||||
);
|
|
||||||
|
|
||||||
return context.json(
|
|
||||||
blocks.map((u) => u.toApi()),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
Link: link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { generateChallenge } from "@/challenges";
|
|
||||||
import { createRoute, z } from "@hono/zod-openapi";
|
|
||||||
import { config } from "~/packages/config-manager";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/challenges",
|
|
||||||
ratelimits: {
|
|
||||||
max: 10,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/challenges",
|
|
||||||
summary: "Generate a challenge",
|
|
||||||
description: "Generate a challenge to solve",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Challenge",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.object({
|
|
||||||
id: z.string(),
|
|
||||||
algorithm: z.enum(["SHA-1", "SHA-256", "SHA-512"]),
|
|
||||||
challenge: z.string(),
|
|
||||||
maxnumber: z.number().optional(),
|
|
||||||
salt: z.string(),
|
|
||||||
signature: z.string(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
400: {
|
|
||||||
description: "Challenges are disabled",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
if (!config.validation.challenges.enabled) {
|
|
||||||
return context.json(
|
|
||||||
{ error: "Challenges are disabled in config" },
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await generateChallenge();
|
|
||||||
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
id: result.id,
|
|
||||||
...result.challenge,
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
||||||
import { db } from "@versia/kit/db";
|
|
||||||
import { Emojis } from "@versia/kit/tables";
|
|
||||||
import { inArray } from "drizzle-orm";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
import { meta } from "./index.ts";
|
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
|
||||||
|
|
||||||
// Make user 2 an admin
|
|
||||||
beforeAll(async () => {
|
|
||||||
await users[1].update({ isAdmin: true });
|
|
||||||
|
|
||||||
// Upload one emoji as admin, then one as each user
|
|
||||||
const response = await fakeRequest("/api/v1/emojis", {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
shortcode: "test1",
|
|
||||||
element: "https://cdn.versia.social/logo.webp",
|
|
||||||
global: true,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
|
||||||
|
|
||||||
await fakeRequest("/api/v1/emojis", {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
shortcode: "test2",
|
|
||||||
element: "https://cdn.versia.social/logo.webp",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
await fakeRequest("/api/v1/emojis", {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
shortcode: "test3",
|
|
||||||
element: "https://cdn.versia.social/logo.webp",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(Emojis)
|
|
||||||
.where(inArray(Emojis.shortcode, ["test1", "test2", "test3"]));
|
|
||||||
});
|
|
||||||
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return all global emojis", async () => {
|
|
||||||
const response = await fakeRequest(meta.route, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.headers.get("content-type")).toContain(
|
|
||||||
"application/json",
|
|
||||||
);
|
|
||||||
|
|
||||||
const emojis = await response.json();
|
|
||||||
|
|
||||||
// Should contain test1 and test2, but not test2
|
|
||||||
expect(emojis).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
shortcode: "test1",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(emojis).not.toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
shortcode: "test2",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(emojis).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
shortcode: "test3",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return all user emojis", async () => {
|
|
||||||
const response = await fakeRequest(meta.route, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.headers.get("content-type")).toContain(
|
|
||||||
"application/json",
|
|
||||||
);
|
|
||||||
|
|
||||||
const emojis = await response.json();
|
|
||||||
|
|
||||||
// Should contain test1 and test2, but not test3
|
|
||||||
expect(emojis).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
shortcode: "test1",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(emojis).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
shortcode: "test2",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(emojis).not.toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
shortcode: "test3",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return all global emojis when signed out", async () => {
|
|
||||||
const response = await fakeRequest(meta.route);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.headers.get("content-type")).toContain(
|
|
||||||
"application/json",
|
|
||||||
);
|
|
||||||
|
|
||||||
const emojis = await response.json();
|
|
||||||
|
|
||||||
// Should contain test1, but not test2 or test3
|
|
||||||
expect(emojis).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
shortcode: "test1",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(emojis).not.toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
shortcode: "test2",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(emojis).not.toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
shortcode: "test3",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute, z } from "@hono/zod-openapi";
|
|
||||||
import { Emoji } from "@versia/kit/db";
|
|
||||||
import { Emojis, RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { and, eq, isNull, or } from "drizzle-orm";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/custom_emojis",
|
|
||||||
ratelimits: {
|
|
||||||
max: 100,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ViewEmojis],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/custom_emojis",
|
|
||||||
summary: "Get custom emojis",
|
|
||||||
description: "Get custom emojis",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Emojis",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.array(Emoji.schema),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
const emojis = await Emoji.manyFromSql(
|
|
||||||
and(
|
|
||||||
isNull(Emojis.instanceId),
|
|
||||||
or(
|
|
||||||
isNull(Emojis.ownerId),
|
|
||||||
user ? eq(Emojis.ownerId, user.id) : undefined,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return context.json(
|
|
||||||
emojis.map((emoji) => emoji.toApi()),
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
||||||
import { db } from "@versia/kit/db";
|
|
||||||
import { Emojis } from "@versia/kit/tables";
|
|
||||||
import { inArray } from "drizzle-orm";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
import { meta } from "./index.ts";
|
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
|
||||||
let id = "";
|
|
||||||
|
|
||||||
// Make user 2 an admin
|
|
||||||
beforeAll(async () => {
|
|
||||||
await users[1].update({ isAdmin: true });
|
|
||||||
|
|
||||||
// Create an emoji
|
|
||||||
const response = await fakeRequest("/api/v1/emojis", {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
shortcode: "test",
|
|
||||||
element: "https://cdn.versia.social/logo.webp",
|
|
||||||
global: true,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
const emoji = await response.json();
|
|
||||||
id = emoji.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(Emojis)
|
|
||||||
.where(inArray(Emojis.shortcode, ["test", "test2", "test3", "test4"]));
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/v1/emojis/:id (PATCH, DELETE, GET)
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return 401 if not authenticated", async () => {
|
|
||||||
const response = await fakeRequest(meta.route.replace(":id", id), {
|
|
||||||
method: "GET",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return 404 if emoji does not exist", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", "00000000-0000-0000-0000-000000000000"),
|
|
||||||
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
},
|
|
||||||
method: "GET",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should not work if the user is trying to update an emoji they don't own", async () => {
|
|
||||||
const response = await fakeRequest(meta.route.replace(":id", id), {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify({
|
|
||||||
shortcode: "test2",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return the emoji", async () => {
|
|
||||||
const response = await fakeRequest(meta.route.replace(":id", id), {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
},
|
|
||||||
method: "GET",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
const emoji = await response.json();
|
|
||||||
expect(emoji.shortcode).toBe("test");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should update the emoji", async () => {
|
|
||||||
const response = await fakeRequest(meta.route.replace(":id", id), {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify({
|
|
||||||
shortcode: "test2",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
const emoji = await response.json();
|
|
||||||
expect(emoji.shortcode).toBe("test2");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should update the emoji with another url, but keep the shortcode", async () => {
|
|
||||||
const response = await fakeRequest(meta.route.replace(":id", id), {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify({
|
|
||||||
element: "https://avatars.githubusercontent.com/u/30842467?v=4",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
const emoji = await response.json();
|
|
||||||
expect(emoji.shortcode).toBe("test2");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should update the emoji to be non-global", async () => {
|
|
||||||
const response = await fakeRequest(meta.route.replace(":id", id), {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify({
|
|
||||||
global: false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
|
|
||||||
// Check if the other user can see it
|
|
||||||
const response2 = await fakeRequest("/api/v1/custom_emojis", {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
method: "GET",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response2.ok).toBe(true);
|
|
||||||
const emojis = await response2.json();
|
|
||||||
expect(emojis).not.toContainEqual(expect.objectContaining({ id }));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should delete the emoji", async () => {
|
|
||||||
const response = await fakeRequest(meta.route.replace(":id", id), {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
},
|
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(204);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,354 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth, emojiValidator, jsonOrForm } from "@/api";
|
|
||||||
import { mimeLookup } from "@/content_types";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Attachment, Emoji, db } from "@versia/kit/db";
|
|
||||||
import { Emojis, RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { MediaManager } from "~/classes/media/media-manager";
|
|
||||||
import { config } from "~/packages/config-manager";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/emojis/:id",
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ManageOwnEmojis, RolePermissions.ViewEmojis],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
param: z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
json: z
|
|
||||||
.object({
|
|
||||||
shortcode: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(1)
|
|
||||||
.max(64)
|
|
||||||
.regex(
|
|
||||||
emojiValidator,
|
|
||||||
"Shortcode must only contain letters (any case), numbers, dashes or underscores.",
|
|
||||||
),
|
|
||||||
element: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(1)
|
|
||||||
.max(2000)
|
|
||||||
.url()
|
|
||||||
.or(z.instanceof(File)),
|
|
||||||
category: z.string().max(64).optional(),
|
|
||||||
alt: z.string().max(1000).optional(),
|
|
||||||
global: z
|
|
||||||
.string()
|
|
||||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
|
||||||
.or(z.boolean())
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.partial(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const routeGet = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/emojis/{id}",
|
|
||||||
summary: "Get emoji data",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Emoji",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: Emoji.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
403: {
|
|
||||||
description: "Insufficient credentials",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "Emoji not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const routePatch = createRoute({
|
|
||||||
method: "patch",
|
|
||||||
path: "/api/v1/emojis/{id}",
|
|
||||||
summary: "Modify emoji",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions), jsonOrForm()],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
body: {
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: schemas.json,
|
|
||||||
},
|
|
||||||
"application/x-www-form-urlencoded": {
|
|
||||||
schema: schemas.json,
|
|
||||||
},
|
|
||||||
"multipart/form-data": {
|
|
||||||
schema: schemas.json,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Emoji modified",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: Emoji.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
403: {
|
|
||||||
description: "Insufficient credentials",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "Emoji not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
422: {
|
|
||||||
description: "Invalid form data",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const routeDelete = createRoute({
|
|
||||||
method: "delete",
|
|
||||||
path: "/api/v1/emojis/{id}",
|
|
||||||
summary: "Delete emoji",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
204: {
|
|
||||||
description: "Emoji deleted",
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "Emoji not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) => {
|
|
||||||
app.openapi(routeGet, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emoji = await Emoji.fromId(id);
|
|
||||||
|
|
||||||
if (!emoji) {
|
|
||||||
return context.json({ error: "Emoji not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is admin
|
|
||||||
if (
|
|
||||||
!user.hasPermission(RolePermissions.ManageEmojis) &&
|
|
||||||
emoji.data.ownerId !== user.data.id
|
|
||||||
) {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: `You cannot modify this emoji, as it is either global, not owned by you, or you do not have the '${RolePermissions.ManageEmojis}' permission to manage global emojis`,
|
|
||||||
},
|
|
||||||
403,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(emoji.toApi(), 200);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.openapi(routePatch, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emoji = await Emoji.fromId(id);
|
|
||||||
|
|
||||||
if (!emoji) {
|
|
||||||
return context.json({ error: "Emoji not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is admin
|
|
||||||
if (
|
|
||||||
!user.hasPermission(RolePermissions.ManageEmojis) &&
|
|
||||||
emoji.data.ownerId !== user.data.id
|
|
||||||
) {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: `You cannot modify this emoji, as it is either global, not owned by you, or you do not have the '${RolePermissions.ManageEmojis}' permission to manage global emojis`,
|
|
||||||
},
|
|
||||||
403,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mediaManager = new MediaManager(config);
|
|
||||||
|
|
||||||
const {
|
|
||||||
global: emojiGlobal,
|
|
||||||
alt,
|
|
||||||
category,
|
|
||||||
element,
|
|
||||||
shortcode,
|
|
||||||
} = context.req.valid("json");
|
|
||||||
|
|
||||||
if (!user.hasPermission(RolePermissions.ManageEmojis) && emojiGlobal) {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: `Only users with the '${RolePermissions.ManageEmojis}' permission can make an emoji global or not`,
|
|
||||||
},
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const modified = structuredClone(emoji.data);
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
// Check of emoji is an image
|
|
||||||
let contentType =
|
|
||||||
element instanceof File
|
|
||||||
? element.type
|
|
||||||
: await mimeLookup(element);
|
|
||||||
|
|
||||||
if (!contentType.startsWith("image/")) {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
|
|
||||||
},
|
|
||||||
422,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = "";
|
|
||||||
|
|
||||||
if (element instanceof File) {
|
|
||||||
const uploaded = await mediaManager.addFile(element);
|
|
||||||
|
|
||||||
url = uploaded.path;
|
|
||||||
contentType = uploaded.uploadedFile.type;
|
|
||||||
} else {
|
|
||||||
url = element;
|
|
||||||
}
|
|
||||||
|
|
||||||
modified.url = Attachment.getUrl(url);
|
|
||||||
modified.contentType = contentType;
|
|
||||||
}
|
|
||||||
|
|
||||||
modified.shortcode = shortcode ?? modified.shortcode;
|
|
||||||
modified.alt = alt ?? modified.alt;
|
|
||||||
modified.category = category ?? modified.category;
|
|
||||||
modified.ownerId = emojiGlobal ? null : user.data.id;
|
|
||||||
|
|
||||||
await emoji.update(modified);
|
|
||||||
|
|
||||||
return context.json(emoji.toApi(), 200);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.openapi(routeDelete, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emoji = await Emoji.fromId(id);
|
|
||||||
|
|
||||||
if (!emoji) {
|
|
||||||
return context.json({ error: "Emoji not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is admin
|
|
||||||
if (
|
|
||||||
!user.hasPermission(RolePermissions.ManageEmojis) &&
|
|
||||||
emoji.data.ownerId !== user.data.id
|
|
||||||
) {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: `You cannot delete this emoji, as it is either global, not owned by you, or you do not have the '${RolePermissions.ManageEmojis}' permission to manage global emojis`,
|
|
||||||
},
|
|
||||||
403,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mediaManager = new MediaManager(config);
|
|
||||||
|
|
||||||
await mediaManager.deleteFileByUrl(emoji.data.url);
|
|
||||||
|
|
||||||
await db.delete(Emojis).where(eq(Emojis.id, id));
|
|
||||||
|
|
||||||
return context.newResponse(null, 204);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
||||||
import { db } from "@versia/kit/db";
|
|
||||||
import { Emojis } from "@versia/kit/tables";
|
|
||||||
import { inArray } from "drizzle-orm";
|
|
||||||
import sharp from "sharp";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
import { meta } from "./index.ts";
|
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(3);
|
|
||||||
|
|
||||||
// Make user 2 an admin
|
|
||||||
beforeAll(async () => {
|
|
||||||
await users[1].update({ isAdmin: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(Emojis)
|
|
||||||
.where(inArray(Emojis.shortcode, ["test1", "test2", "test3", "test4"]));
|
|
||||||
});
|
|
||||||
|
|
||||||
const createImage = async (name: string): Promise<File> => {
|
|
||||||
const inputBuffer = await sharp({
|
|
||||||
create: {
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
channels: 3,
|
|
||||||
background: { r: 255, g: 0, b: 0 },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.png()
|
|
||||||
.toBuffer();
|
|
||||||
|
|
||||||
return new File([inputBuffer], name, {
|
|
||||||
type: "image/png",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return 401 if not authenticated", async () => {
|
|
||||||
const response = await fakeRequest(meta.route, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
shortcode: "test",
|
|
||||||
element: "https://cdn.versia.social/logo.webp",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Admin tests", () => {
|
|
||||||
test("should upload a file and create an emoji", async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("shortcode", "test1");
|
|
||||||
formData.append("element", await createImage("test.png"));
|
|
||||||
formData.append("global", "true");
|
|
||||||
|
|
||||||
const response = await fakeRequest(meta.route, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
const emoji = await response.json();
|
|
||||||
expect(emoji.shortcode).toBe("test1");
|
|
||||||
expect(emoji.url).toContain("/media/proxy");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should try to upload a non-image", async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("shortcode", "test2");
|
|
||||||
formData.append("element", new File(["test"], "test.txt"));
|
|
||||||
|
|
||||||
const response = await fakeRequest(meta.route, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(422);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should upload an emoji by url", async () => {
|
|
||||||
const response = await fakeRequest(meta.route, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
shortcode: "test3",
|
|
||||||
element: "https://cdn.versia.social/logo.webp",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
const emoji = await response.json();
|
|
||||||
expect(emoji.shortcode).toBe("test3");
|
|
||||||
expect(emoji.url).toContain("/media/proxy/");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should fail when uploading an already existing emoji", async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("shortcode", "test1");
|
|
||||||
formData.append("element", await createImage("test-image.png"));
|
|
||||||
|
|
||||||
const response = await fakeRequest(meta.route, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(422);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("User tests", () => {
|
|
||||||
test("should upload a file and create an emoji", async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("shortcode", "test4");
|
|
||||||
formData.append("element", await createImage("test-image.png"));
|
|
||||||
|
|
||||||
const response = await fakeRequest(meta.route, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
const emoji = await response.json();
|
|
||||||
expect(emoji.shortcode).toBe("test4");
|
|
||||||
expect(emoji.url).toContain("/media/proxy/");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should fail when uploading an already existing global emoji", async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("shortcode", "test1");
|
|
||||||
formData.append("element", await createImage("test-image.png"));
|
|
||||||
|
|
||||||
const response = await fakeRequest(meta.route, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(422);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should create an emoji as another user with the same shortcode", async () => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("shortcode", "test4");
|
|
||||||
formData.append("element", await createImage("test-image.png"));
|
|
||||||
|
|
||||||
const response = await fakeRequest(meta.route, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[2].data.accessToken}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
const emoji = await response.json();
|
|
||||||
expect(emoji.shortcode).toBe("test4");
|
|
||||||
expect(emoji.url).toContain("/media/proxy/");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth, emojiValidator, jsonOrForm } from "@/api";
|
|
||||||
import { mimeLookup } from "@/content_types";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Attachment, Emoji } from "@versia/kit/db";
|
|
||||||
import { Emojis, RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { and, eq, isNull, or } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { MediaManager } from "~/classes/media/media-manager";
|
|
||||||
import { config } from "~/packages/config-manager";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/emojis",
|
|
||||||
ratelimits: {
|
|
||||||
max: 30,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ManageOwnEmojis, RolePermissions.ViewEmojis],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
json: z.object({
|
|
||||||
shortcode: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(1)
|
|
||||||
.max(64)
|
|
||||||
.regex(
|
|
||||||
emojiValidator,
|
|
||||||
"Shortcode must only contain letters (any case), numbers, dashes or underscores.",
|
|
||||||
),
|
|
||||||
element: z
|
|
||||||
.string()
|
|
||||||
.trim()
|
|
||||||
.min(1)
|
|
||||||
.max(2000)
|
|
||||||
.url()
|
|
||||||
.or(z.instanceof(File)),
|
|
||||||
category: z.string().max(64).optional(),
|
|
||||||
alt: z.string().max(1000).optional(),
|
|
||||||
global: z
|
|
||||||
.string()
|
|
||||||
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
|
|
||||||
.or(z.boolean())
|
|
||||||
.optional(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/emojis",
|
|
||||||
summary: "Upload emoji",
|
|
||||||
description: "Upload an emoji",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions), jsonOrForm()],
|
|
||||||
request: {
|
|
||||||
body: {
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: schemas.json,
|
|
||||||
},
|
|
||||||
"multipart/form-data": {
|
|
||||||
schema: schemas.json,
|
|
||||||
},
|
|
||||||
"application/x-www-form-urlencoded": {
|
|
||||||
schema: schemas.json,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
201: {
|
|
||||||
description: "Uploaded emoji",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: Emoji.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
422: {
|
|
||||||
description: "Invalid data",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { shortcode, element, alt, global, category } =
|
|
||||||
context.req.valid("json");
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.hasPermission(RolePermissions.ManageEmojis) && global) {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: `Only users with the '${RolePermissions.ManageEmojis}' permission can upload global emojis`,
|
|
||||||
},
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if emoji already exists
|
|
||||||
const existing = await Emoji.fromSql(
|
|
||||||
and(
|
|
||||||
eq(Emojis.shortcode, shortcode),
|
|
||||||
isNull(Emojis.instanceId),
|
|
||||||
or(eq(Emojis.ownerId, user.id), isNull(Emojis.ownerId)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: `An emoji with the shortcode ${shortcode} already exists, either owned by you or global.`,
|
|
||||||
},
|
|
||||||
422,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = "";
|
|
||||||
|
|
||||||
// Check of emoji is an image
|
|
||||||
let contentType =
|
|
||||||
element instanceof File ? element.type : await mimeLookup(element);
|
|
||||||
|
|
||||||
if (!contentType.startsWith("image/")) {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: `Emojis must be images (png, jpg, gif, etc.). Detected: ${contentType}`,
|
|
||||||
},
|
|
||||||
422,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element instanceof File) {
|
|
||||||
const mediaManager = new MediaManager(config);
|
|
||||||
|
|
||||||
const uploaded = await mediaManager.addFile(element);
|
|
||||||
|
|
||||||
url = uploaded.path;
|
|
||||||
contentType = uploaded.uploadedFile.type;
|
|
||||||
} else {
|
|
||||||
url = element;
|
|
||||||
}
|
|
||||||
|
|
||||||
const emoji = await Emoji.insert({
|
|
||||||
shortcode,
|
|
||||||
url: Attachment.getUrl(url),
|
|
||||||
visibleInPicker: true,
|
|
||||||
ownerId: global ? null : user.id,
|
|
||||||
category,
|
|
||||||
contentType,
|
|
||||||
alt,
|
|
||||||
});
|
|
||||||
|
|
||||||
return context.json(emoji.toApi(), 201);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth, idValidator } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Note, Timeline } from "@versia/kit/db";
|
|
||||||
import { Notes, RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/favourites",
|
|
||||||
ratelimits: {
|
|
||||||
max: 100,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ManageOwnLikes],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
query: z.object({
|
|
||||||
max_id: z.string().regex(idValidator).optional(),
|
|
||||||
since_id: z.string().regex(idValidator).optional(),
|
|
||||||
min_id: z.string().regex(idValidator).optional(),
|
|
||||||
limit: z.coerce.number().int().min(1).max(80).default(40),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/favourites",
|
|
||||||
summary: "Get favourites",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
query: schemas.query,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Favourites",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.array(Note.schema),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { max_id, since_id, min_id, limit } = context.req.valid("query");
|
|
||||||
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { objects: favourites, link } = await Timeline.getNoteTimeline(
|
|
||||||
and(
|
|
||||||
max_id ? lt(Notes.id, max_id) : undefined,
|
|
||||||
since_id ? gte(Notes.id, since_id) : undefined,
|
|
||||||
min_id ? gt(Notes.id, min_id) : undefined,
|
|
||||||
sql`EXISTS (SELECT 1 FROM "Likes" WHERE "Likes"."likedId" = ${Notes.id} AND "Likes"."likerId" = ${user.id})`,
|
|
||||||
),
|
|
||||||
limit,
|
|
||||||
context.req.url,
|
|
||||||
user?.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
return context.json(
|
|
||||||
await Promise.all(favourites.map((note) => note.toApi(user))),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
Link: link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Relationship, User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/follow_requests/:account_id/authorize",
|
|
||||||
ratelimits: {
|
|
||||||
max: 100,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ManageOwnFollows],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
param: z.object({
|
|
||||||
account_id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/follow_requests/{account_id}/authorize",
|
|
||||||
summary: "Authorize follow request",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Relationship",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: Relationship.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "Account not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { account_id } = context.req.valid("param");
|
|
||||||
|
|
||||||
const account = await User.fromId(account_id);
|
|
||||||
|
|
||||||
if (!account) {
|
|
||||||
return context.json({ error: "Account not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
|
|
||||||
account,
|
|
||||||
user,
|
|
||||||
);
|
|
||||||
|
|
||||||
await oppositeRelationship.update({
|
|
||||||
requested: false,
|
|
||||||
following: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
|
||||||
user,
|
|
||||||
account,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if accepting remote follow
|
|
||||||
if (account.isRemote()) {
|
|
||||||
// Federate follow accept
|
|
||||||
await user.sendFollowAccept(account);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(foundRelationship.toApi(), 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Relationship, User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/follow_requests/:account_id/reject",
|
|
||||||
ratelimits: {
|
|
||||||
max: 100,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ManageOwnFollows],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
param: z.object({
|
|
||||||
account_id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/follow_requests/{account_id}/reject",
|
|
||||||
summary: "Reject follow request",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Relationship",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: Relationship.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "Account not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { account_id } = context.req.valid("param");
|
|
||||||
|
|
||||||
const account = await User.fromId(account_id);
|
|
||||||
|
|
||||||
if (!account) {
|
|
||||||
return context.json({ error: "Account not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const oppositeRelationship = await Relationship.fromOwnerAndSubject(
|
|
||||||
account,
|
|
||||||
user,
|
|
||||||
);
|
|
||||||
|
|
||||||
await oppositeRelationship.update({
|
|
||||||
requested: false,
|
|
||||||
following: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
|
||||||
user,
|
|
||||||
account,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if rejecting remote follow
|
|
||||||
if (account.isRemote()) {
|
|
||||||
// Federate follow reject
|
|
||||||
await user.sendFollowReject(account);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(foundRelationship.toApi(), 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth, idValidator } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Timeline, User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions, Users } from "@versia/kit/tables";
|
|
||||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/follow_requests",
|
|
||||||
ratelimits: {
|
|
||||||
max: 100,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ManageOwnFollows],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
query: z.object({
|
|
||||||
max_id: z.string().regex(idValidator).optional(),
|
|
||||||
since_id: z.string().regex(idValidator).optional(),
|
|
||||||
min_id: z.string().regex(idValidator).optional(),
|
|
||||||
limit: z.coerce.number().int().min(1).max(80).default(40),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/follow_requests",
|
|
||||||
summary: "Get follow requests",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
query: schemas.query,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Follow requests",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.array(User.schema),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { max_id, since_id, min_id, limit } = context.req.valid("query");
|
|
||||||
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { objects: followRequests, link } =
|
|
||||||
await Timeline.getUserTimeline(
|
|
||||||
and(
|
|
||||||
max_id ? lt(Users.id, max_id) : undefined,
|
|
||||||
since_id ? gte(Users.id, since_id) : undefined,
|
|
||||||
min_id ? gt(Users.id, min_id) : undefined,
|
|
||||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${user.id} AND "Relationships"."ownerId" = ${Users.id} AND "Relationships"."requested" = true)`,
|
|
||||||
),
|
|
||||||
limit,
|
|
||||||
context.req.url,
|
|
||||||
);
|
|
||||||
|
|
||||||
return context.json(
|
|
||||||
followRequests.map((u) => u.toApi()),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
Link: link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import { apiRoute, applyConfig } from "@/api";
|
|
||||||
import { createRoute, z } from "@hono/zod-openapi";
|
|
||||||
import { config } from "~/packages/config-manager";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
ratelimits: {
|
|
||||||
duration: 60,
|
|
||||||
max: 120,
|
|
||||||
},
|
|
||||||
route: "/api/v1/frontend/config",
|
|
||||||
});
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/frontend/config",
|
|
||||||
summary: "Get frontend config",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Frontend config",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.record(z.string(), z.any()).default({}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, (context) => {
|
|
||||||
return context.json(config.frontend.settings, 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { fakeRequest } from "~/tests/utils";
|
|
||||||
import { meta } from "./extended_description.ts";
|
|
||||||
|
|
||||||
// /api/v1/instance/extended_description
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return extended description", async () => {
|
|
||||||
const response = await fakeRequest(meta.route);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const json = await response.json();
|
|
||||||
expect(json).toEqual({
|
|
||||||
updated_at: new Date(1970, 0, 0).toISOString(),
|
|
||||||
content:
|
|
||||||
'<p>This is a <a href="https://versia.pub">Versia</a> server with the default extended description.</p>\n',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { apiRoute, applyConfig } from "@/api";
|
|
||||||
import { renderMarkdownInPath } from "@/markdown";
|
|
||||||
import { createRoute, z } from "@hono/zod-openapi";
|
|
||||||
import { config } from "~/packages/config-manager";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/instance/extended_description",
|
|
||||||
ratelimits: {
|
|
||||||
max: 300,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/instance/extended_description",
|
|
||||||
summary: "Get extended description",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Extended description",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.object({
|
|
||||||
updated_at: z.string(),
|
|
||||||
content: z.string(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { content, lastModified } = await renderMarkdownInPath(
|
|
||||||
config.instance.extended_description_path ?? "",
|
|
||||||
"This is a [Versia](https://versia.pub) server with the default extended description.",
|
|
||||||
);
|
|
||||||
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
updated_at: lastModified.toISOString(),
|
|
||||||
content,
|
|
||||||
},
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { proxyUrl } from "@/response";
|
|
||||||
import { createRoute, z } from "@hono/zod-openapi";
|
|
||||||
import { Instance, Note, User } from "@versia/kit/db";
|
|
||||||
import { Users } from "@versia/kit/tables";
|
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
|
||||||
import manifest from "~/package.json";
|
|
||||||
import { config } from "~/packages/config-manager";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/instance",
|
|
||||||
ratelimits: {
|
|
||||||
max: 300,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/instance",
|
|
||||||
summary: "Get instance information",
|
|
||||||
middleware: [auth(meta.auth)],
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Instance information",
|
|
||||||
content: {
|
|
||||||
// TODO: Add schemas for this response
|
|
||||||
"application/json": {
|
|
||||||
schema: z.any(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
// Get software version from package.json
|
|
||||||
const version = manifest.version;
|
|
||||||
|
|
||||||
const statusCount = await Note.getCount();
|
|
||||||
|
|
||||||
const userCount = await User.getCount();
|
|
||||||
|
|
||||||
const contactAccount = await User.fromSql(
|
|
||||||
and(isNull(Users.instanceId), eq(Users.isAdmin, true)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const knownDomainsCount = await Instance.getCount();
|
|
||||||
|
|
||||||
const oidcConfig = config.plugins?.config?.["@versia/openid"] as
|
|
||||||
| {
|
|
||||||
forced?: boolean;
|
|
||||||
providers?: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
icon: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
// TODO: fill in more values
|
|
||||||
return context.json({
|
|
||||||
approval_required: false,
|
|
||||||
configuration: {
|
|
||||||
polls: {
|
|
||||||
max_characters_per_option:
|
|
||||||
config.validation.max_poll_option_size,
|
|
||||||
max_expiration: config.validation.max_poll_duration,
|
|
||||||
max_options: config.validation.max_poll_options,
|
|
||||||
min_expiration: config.validation.min_poll_duration,
|
|
||||||
},
|
|
||||||
statuses: {
|
|
||||||
characters_reserved_per_url: 0,
|
|
||||||
max_characters: config.validation.max_note_size,
|
|
||||||
max_media_attachments:
|
|
||||||
config.validation.max_media_attachments,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
description: config.instance.description,
|
|
||||||
email: "",
|
|
||||||
invites_enabled: false,
|
|
||||||
registrations: config.signups.registration,
|
|
||||||
languages: ["en"],
|
|
||||||
rules: config.signups.rules.map((r, index) => ({
|
|
||||||
id: String(index),
|
|
||||||
text: r,
|
|
||||||
})),
|
|
||||||
stats: {
|
|
||||||
domain_count: knownDomainsCount,
|
|
||||||
status_count: statusCount,
|
|
||||||
user_count: userCount,
|
|
||||||
},
|
|
||||||
thumbnail: proxyUrl(config.instance.logo),
|
|
||||||
banner: proxyUrl(config.instance.banner),
|
|
||||||
title: config.instance.name,
|
|
||||||
uri: config.http.base_url,
|
|
||||||
urls: {
|
|
||||||
streaming_api: "",
|
|
||||||
},
|
|
||||||
version: "4.3.0-alpha.3+glitch",
|
|
||||||
versia_version: version,
|
|
||||||
// TODO: Put into plugin directly
|
|
||||||
sso: {
|
|
||||||
forced: oidcConfig?.forced ?? false,
|
|
||||||
providers:
|
|
||||||
oidcConfig?.providers?.map((p) => ({
|
|
||||||
name: p.name,
|
|
||||||
icon: proxyUrl(p.icon) || undefined,
|
|
||||||
id: p.id,
|
|
||||||
})) ?? [],
|
|
||||||
},
|
|
||||||
contact_account: contactAccount?.toApi() || undefined,
|
|
||||||
} satisfies Record<string, unknown> & {
|
|
||||||
banner: string | null;
|
|
||||||
versia_version: string;
|
|
||||||
sso: {
|
|
||||||
forced: boolean;
|
|
||||||
providers: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
icon?: string;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { fakeRequest } from "~/tests/utils";
|
|
||||||
import { meta } from "./privacy_policy.ts";
|
|
||||||
|
|
||||||
// /api/v1/instance/privacy_policy
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return privacy policy", async () => {
|
|
||||||
const response = await fakeRequest(meta.route);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const json = await response.json();
|
|
||||||
expect(json).toEqual({
|
|
||||||
updated_at: new Date(1970, 0, 0).toISOString(),
|
|
||||||
// This instance has not provided any privacy policy.
|
|
||||||
content:
|
|
||||||
"<p>This instance has not provided any privacy policy.</p>\n",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { renderMarkdownInPath } from "@/markdown";
|
|
||||||
import { createRoute, z } from "@hono/zod-openapi";
|
|
||||||
import { config } from "~/packages/config-manager";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/instance/privacy_policy",
|
|
||||||
ratelimits: {
|
|
||||||
max: 300,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/instance/privacy_policy",
|
|
||||||
summary: "Get instance privacy policy",
|
|
||||||
middleware: [auth(meta.auth)],
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Instance privacy policy",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.object({
|
|
||||||
updated_at: z.string(),
|
|
||||||
content: z.string(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { content, lastModified } = await renderMarkdownInPath(
|
|
||||||
config.instance.privacy_policy_path ?? "",
|
|
||||||
"This instance has not provided any privacy policy.",
|
|
||||||
);
|
|
||||||
|
|
||||||
return context.json({
|
|
||||||
updated_at: lastModified.toISOString(),
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { config } from "~/packages/config-manager/index.ts";
|
|
||||||
import { fakeRequest } from "~/tests/utils";
|
|
||||||
import { meta } from "./rules.ts";
|
|
||||||
|
|
||||||
// /api/v1/instance/rules
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return rules", async () => {
|
|
||||||
const response = await fakeRequest(meta.route);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const json = await response.json();
|
|
||||||
expect(json).toEqual(
|
|
||||||
config.signups.rules.map((rule, index) => ({
|
|
||||||
id: String(index),
|
|
||||||
text: rule,
|
|
||||||
hint: "",
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute, z } from "@hono/zod-openapi";
|
|
||||||
import { config } from "~/packages/config-manager";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/instance/rules",
|
|
||||||
ratelimits: {
|
|
||||||
max: 300,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/instance/rules",
|
|
||||||
summary: "Get instance rules",
|
|
||||||
middleware: [auth(meta.auth)],
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Instance rules",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.array(
|
|
||||||
z.object({
|
|
||||||
id: z.string(),
|
|
||||||
text: z.string(),
|
|
||||||
hint: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, (context) => {
|
|
||||||
return context.json(
|
|
||||||
config.signups.rules.map((rule, index) => ({
|
|
||||||
id: String(index),
|
|
||||||
text: rule,
|
|
||||||
hint: "",
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { fakeRequest } from "~/tests/utils";
|
|
||||||
import { meta } from "./tos.ts";
|
|
||||||
|
|
||||||
// /api/v1/instance/tos
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return terms of service", async () => {
|
|
||||||
const response = await fakeRequest(meta.route);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const json = await response.json();
|
|
||||||
expect(json).toEqual({
|
|
||||||
updated_at: new Date(1970, 0, 0).toISOString(),
|
|
||||||
// This instance has not provided any terms of service.
|
|
||||||
content:
|
|
||||||
"<p>This instance has not provided any terms of service.</p>\n",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { renderMarkdownInPath } from "@/markdown";
|
|
||||||
import { createRoute, z } from "@hono/zod-openapi";
|
|
||||||
import { config } from "~/packages/config-manager";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/instance/tos",
|
|
||||||
ratelimits: {
|
|
||||||
max: 300,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/instance/tos",
|
|
||||||
summary: "Get instance terms of service",
|
|
||||||
middleware: [auth(meta.auth)],
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Instance terms of service",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.object({
|
|
||||||
updated_at: z.string(),
|
|
||||||
content: z.string(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { content, lastModified } = await renderMarkdownInPath(
|
|
||||||
config.instance.tos_path ?? "",
|
|
||||||
"This instance has not provided any terms of service.",
|
|
||||||
);
|
|
||||||
|
|
||||||
return context.json({
|
|
||||||
updated_at: lastModified.toISOString(),
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
import { afterAll, describe, expect, test } from "bun:test";
|
|
||||||
import { fakeRequest, getTestStatuses, getTestUsers } from "~/tests/utils";
|
|
||||||
import { meta } from "./index.ts";
|
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(1);
|
|
||||||
const timeline = await getTestStatuses(10, users[0]);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/v1/markers
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return 401 if not authenticated", async () => {
|
|
||||||
const response = await fakeRequest(meta.route, {
|
|
||||||
method: "GET",
|
|
||||||
});
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return empty markers", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`${meta.route}?${new URLSearchParams([
|
|
||||||
["timeline[]", "home"],
|
|
||||||
["timeline[]", "notifications"],
|
|
||||||
])}`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(await response.json()).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should create markers", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`${meta.route}?${new URLSearchParams({
|
|
||||||
"home[last_read_id]": timeline[0].id,
|
|
||||||
})}`,
|
|
||||||
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(await response.json()).toEqual({
|
|
||||||
home: {
|
|
||||||
last_read_id: timeline[0].id,
|
|
||||||
updated_at: expect.any(String),
|
|
||||||
version: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return markers", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`${meta.route}?${new URLSearchParams([
|
|
||||||
["timeline[]", "home"],
|
|
||||||
["timeline[]", "notifications"],
|
|
||||||
])}`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(await response.json()).toEqual({
|
|
||||||
home: {
|
|
||||||
last_read_id: timeline[0].id,
|
|
||||||
updated_at: expect.any(String),
|
|
||||||
version: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,255 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth, idValidator } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import type { Marker as ApiMarker } from "@versia/client/types";
|
|
||||||
import { db } from "@versia/kit/db";
|
|
||||||
import { Markers, RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { type SQL, and, eq } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/markers",
|
|
||||||
ratelimits: {
|
|
||||||
max: 100,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["read:blocks"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ManageOwnAccount],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
markers: z.object({
|
|
||||||
home: z
|
|
||||||
.object({
|
|
||||||
last_read_id: z.string().regex(idValidator),
|
|
||||||
version: z.number(),
|
|
||||||
updated_at: z.string(),
|
|
||||||
})
|
|
||||||
.nullable()
|
|
||||||
.optional(),
|
|
||||||
notifications: z
|
|
||||||
.object({
|
|
||||||
last_read_id: z.string().regex(idValidator),
|
|
||||||
version: z.number(),
|
|
||||||
updated_at: z.string(),
|
|
||||||
})
|
|
||||||
.nullable()
|
|
||||||
.optional(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const routeGet = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/markers",
|
|
||||||
summary: "Get markers",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
query: z.object({
|
|
||||||
"timeline[]": z
|
|
||||||
.array(z.enum(["home", "notifications"]))
|
|
||||||
.max(2)
|
|
||||||
.or(z.enum(["home", "notifications"]))
|
|
||||||
.optional(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Markers",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: schemas.markers,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const routePost = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/markers",
|
|
||||||
summary: "Update markers",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
query: z.object({
|
|
||||||
"home[last_read_id]": z.string().regex(idValidator).optional(),
|
|
||||||
"notifications[last_read_id]": z
|
|
||||||
.string()
|
|
||||||
.regex(idValidator)
|
|
||||||
.optional(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Markers",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: schemas.markers,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) => {
|
|
||||||
app.openapi(routeGet, async (context) => {
|
|
||||||
const { "timeline[]": timelines } = context.req.valid("query");
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
const timeline = Array.isArray(timelines) ? timelines : [];
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!timeline) {
|
|
||||||
return context.json({}, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
const markers: ApiMarker = {
|
|
||||||
home: undefined,
|
|
||||||
notifications: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (timeline.includes("home")) {
|
|
||||||
const found = await db.query.Markers.findFirst({
|
|
||||||
where: (marker, { and, eq }): SQL | undefined =>
|
|
||||||
and(
|
|
||||||
eq(marker.userId, user.id),
|
|
||||||
eq(marker.timeline, "home"),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalCount = await db.$count(
|
|
||||||
Markers,
|
|
||||||
and(eq(Markers.userId, user.id), eq(Markers.timeline, "home")),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (found?.noteId) {
|
|
||||||
markers.home = {
|
|
||||||
last_read_id: found.noteId,
|
|
||||||
version: totalCount,
|
|
||||||
updated_at: new Date(found.createdAt).toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timeline.includes("notifications")) {
|
|
||||||
const found = await db.query.Markers.findFirst({
|
|
||||||
where: (marker, { and, eq }): SQL | undefined =>
|
|
||||||
and(
|
|
||||||
eq(marker.userId, user.id),
|
|
||||||
eq(marker.timeline, "notifications"),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalCount = await db.$count(
|
|
||||||
Markers,
|
|
||||||
and(
|
|
||||||
eq(Markers.userId, user.id),
|
|
||||||
eq(Markers.timeline, "notifications"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (found?.notificationId) {
|
|
||||||
markers.notifications = {
|
|
||||||
last_read_id: found.notificationId,
|
|
||||||
version: totalCount,
|
|
||||||
updated_at: new Date(found.createdAt).toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(markers, 200);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.openapi(routePost, async (context) => {
|
|
||||||
const {
|
|
||||||
"home[last_read_id]": homeId,
|
|
||||||
"notifications[last_read_id]": notificationsId,
|
|
||||||
} = context.req.valid("query");
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const markers: ApiMarker = {
|
|
||||||
home: undefined,
|
|
||||||
notifications: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (homeId) {
|
|
||||||
const insertedMarker = (
|
|
||||||
await db
|
|
||||||
.insert(Markers)
|
|
||||||
.values({
|
|
||||||
userId: user.id,
|
|
||||||
timeline: "home",
|
|
||||||
noteId: homeId,
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
const totalCount = await db.$count(
|
|
||||||
Markers,
|
|
||||||
and(eq(Markers.userId, user.id), eq(Markers.timeline, "home")),
|
|
||||||
);
|
|
||||||
|
|
||||||
markers.home = {
|
|
||||||
last_read_id: homeId,
|
|
||||||
version: totalCount,
|
|
||||||
updated_at: new Date(insertedMarker.createdAt).toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notificationsId) {
|
|
||||||
const insertedMarker = (
|
|
||||||
await db
|
|
||||||
.insert(Markers)
|
|
||||||
.values({
|
|
||||||
userId: user.id,
|
|
||||||
timeline: "notifications",
|
|
||||||
notificationId: notificationsId,
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
const totalCount = await db.$count(
|
|
||||||
Markers,
|
|
||||||
and(
|
|
||||||
eq(Markers.userId, user.id),
|
|
||||||
eq(Markers.timeline, "notifications"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
markers.notifications = {
|
|
||||||
last_read_id: notificationsId,
|
|
||||||
version: totalCount,
|
|
||||||
updated_at: new Date(insertedMarker.createdAt).toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(markers, 200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Attachment } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { MediaManager } from "~/classes/media/media-manager";
|
|
||||||
import { config } from "~/packages/config-manager/index.ts";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 10,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/media/:id",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:media"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ManageOwnMedia],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
param: z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
form: z.object({
|
|
||||||
thumbnail: z.instanceof(File).optional(),
|
|
||||||
description: z
|
|
||||||
.string()
|
|
||||||
.max(config.validation.max_media_description_size)
|
|
||||||
.optional(),
|
|
||||||
focus: z.string().optional(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const routePut = createRoute({
|
|
||||||
method: "put",
|
|
||||||
path: "/api/v1/media/{id}",
|
|
||||||
summary: "Update media",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
body: {
|
|
||||||
content: {
|
|
||||||
"multipart/form-data": {
|
|
||||||
schema: schemas.form,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
204: {
|
|
||||||
description: "Media updated",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: Attachment.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "Media not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const routeGet = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/media/{id}",
|
|
||||||
summary: "Get media",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Media",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: Attachment.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
404: {
|
|
||||||
description: "Media not found",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) => {
|
|
||||||
app.openapi(routePut, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
|
|
||||||
const attachment = await Attachment.fromId(id);
|
|
||||||
|
|
||||||
if (!attachment) {
|
|
||||||
return context.json({ error: "Media not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { description, thumbnail } = context.req.valid("form");
|
|
||||||
|
|
||||||
let thumbnailUrl = attachment.data.thumbnailUrl;
|
|
||||||
|
|
||||||
const mediaManager = new MediaManager(config);
|
|
||||||
|
|
||||||
if (thumbnail) {
|
|
||||||
const { path } = await mediaManager.addFile(thumbnail);
|
|
||||||
thumbnailUrl = Attachment.getUrl(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
const descriptionText = description || attachment.data.description;
|
|
||||||
|
|
||||||
if (
|
|
||||||
descriptionText !== attachment.data.description ||
|
|
||||||
thumbnailUrl !== attachment.data.thumbnailUrl
|
|
||||||
) {
|
|
||||||
await attachment.update({
|
|
||||||
description: descriptionText,
|
|
||||||
thumbnailUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
return context.json(attachment.toApi(), 204);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(attachment.toApi(), 204);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.openapi(routeGet, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
|
|
||||||
const attachment = await Attachment.fromId(id);
|
|
||||||
|
|
||||||
if (!attachment) {
|
|
||||||
return context.json({ error: "Media not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(attachment.toApi(), 200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Attachment } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import sharp from "sharp";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { MediaManager } from "~/classes/media/media-manager";
|
|
||||||
import { config } from "~/packages/config-manager/index.ts";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
ratelimits: {
|
|
||||||
max: 10,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
route: "/api/v1/media",
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:media"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ManageOwnMedia],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
form: z.object({
|
|
||||||
file: z.instanceof(File),
|
|
||||||
thumbnail: z.instanceof(File).optional(),
|
|
||||||
description: z
|
|
||||||
.string()
|
|
||||||
.max(config.validation.max_media_description_size)
|
|
||||||
.optional(),
|
|
||||||
focus: z.string().optional(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/media",
|
|
||||||
summary: "Upload media",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
body: {
|
|
||||||
content: {
|
|
||||||
"multipart/form-data": {
|
|
||||||
schema: schemas.form,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Attachment",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: Attachment.schema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
413: {
|
|
||||||
description: "File too large",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
415: {
|
|
||||||
description: "Disallowed file type",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { file, thumbnail, description } = context.req.valid("form");
|
|
||||||
|
|
||||||
if (file.size > config.validation.max_media_size) {
|
|
||||||
return context.json(
|
|
||||||
{
|
|
||||||
error: `File too large, max size is ${config.validation.max_media_size} bytes`,
|
|
||||||
},
|
|
||||||
413,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
config.validation.enforce_mime_types &&
|
|
||||||
!config.validation.allowed_mime_types.includes(file.type)
|
|
||||||
) {
|
|
||||||
return context.json({ error: "Disallowed file type" }, 415);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sha256 = new Bun.SHA256();
|
|
||||||
|
|
||||||
const isImage = file.type.startsWith("image/");
|
|
||||||
|
|
||||||
const metadata = isImage
|
|
||||||
? await sharp(await file.arrayBuffer()).metadata()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const mediaManager = new MediaManager(config);
|
|
||||||
|
|
||||||
const { path, blurhash } = await mediaManager.addFile(file);
|
|
||||||
|
|
||||||
const url = Attachment.getUrl(path);
|
|
||||||
|
|
||||||
let thumbnailUrl = "";
|
|
||||||
|
|
||||||
if (thumbnail) {
|
|
||||||
const { path } = await mediaManager.addFile(thumbnail);
|
|
||||||
|
|
||||||
thumbnailUrl = Attachment.getUrl(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newAttachment = await Attachment.insert({
|
|
||||||
url,
|
|
||||||
thumbnailUrl,
|
|
||||||
sha256: sha256.update(await file.arrayBuffer()).digest("hex"),
|
|
||||||
mimeType: file.type,
|
|
||||||
description: description ?? "",
|
|
||||||
size: file.size,
|
|
||||||
blurhash: blurhash ?? undefined,
|
|
||||||
width: metadata?.width ?? undefined,
|
|
||||||
height: metadata?.height ?? undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: Add job to process videos and other media
|
|
||||||
|
|
||||||
return context.json(newAttachment.toApi(), 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
import { meta } from "./index.ts";
|
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(3);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`/api/v1/accounts/${users[1].id}/mute`,
|
|
||||||
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/v1/mutes
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return 401 if not authenticated", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", users[1].id),
|
|
||||||
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return mutes", async () => {
|
|
||||||
const response = await fakeRequest(meta.route, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
const body = await response.json();
|
|
||||||
expect(body).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
|
||||||
id: users[1].id,
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return mutes after unmute", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
`/api/v1/accounts/${users[1].id}/unmute`,
|
|
||||||
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const response2 = await fakeRequest(meta.route, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response2.status).toBe(200);
|
|
||||||
const body = await response2.json();
|
|
||||||
expect(body).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth, idValidator } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Timeline, User } from "@versia/kit/db";
|
|
||||||
import { RolePermissions, Users } from "@versia/kit/tables";
|
|
||||||
import { and, gt, gte, lt, sql } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/mutes",
|
|
||||||
ratelimits: {
|
|
||||||
max: 100,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["read:mutes"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ManageOwnMutes],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
query: z.object({
|
|
||||||
max_id: z.string().regex(idValidator).optional(),
|
|
||||||
since_id: z.string().regex(idValidator).optional(),
|
|
||||||
min_id: z.string().regex(idValidator).optional(),
|
|
||||||
limit: z.coerce.number().int().min(1).max(80).default(40),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "get",
|
|
||||||
path: "/api/v1/mutes",
|
|
||||||
summary: "Get muted users",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
query: schemas.query,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Muted users",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.array(User.schema),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { max_id, since_id, limit, min_id } = context.req.valid("query");
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { objects: mutes, link } = await Timeline.getUserTimeline(
|
|
||||||
and(
|
|
||||||
max_id ? lt(Users.id, max_id) : undefined,
|
|
||||||
since_id ? gte(Users.id, since_id) : undefined,
|
|
||||||
min_id ? gt(Users.id, min_id) : undefined,
|
|
||||||
sql`EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${Users.id} AND "Relationships"."ownerId" = ${user.id} AND "Relationships"."muting" = true)`,
|
|
||||||
),
|
|
||||||
limit,
|
|
||||||
context.req.url,
|
|
||||||
);
|
|
||||||
|
|
||||||
return context.json(
|
|
||||||
mutes.map((u) => u.toApi()),
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
Link: link,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
||||||
import type { Notification as ApiNotification } from "@versia/client/types";
|
|
||||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
|
||||||
import { meta } from "./dismiss.ts";
|
|
||||||
|
|
||||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
|
||||||
let notifications: ApiNotification[] = [];
|
|
||||||
|
|
||||||
// Create some test notifications: follow, favourite, reblog, mention
|
|
||||||
beforeAll(async () => {
|
|
||||||
await fakeRequest(`/api/v1/accounts/${users[0].id}/follow`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
|
||||||
|
|
||||||
notifications = await fakeRequest("/api/v1/notifications", {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
}).then((r) => r.json());
|
|
||||||
|
|
||||||
expect(notifications.length).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await deleteUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/v1/notifications/:id/dismiss
|
|
||||||
describe(meta.route, () => {
|
|
||||||
test("should return 401 if not authenticated", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", notifications[0].id),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should dismiss notification", async () => {
|
|
||||||
const response = await fakeRequest(
|
|
||||||
meta.route.replace(":id", notifications[0].id),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should not display dismissed notification", async () => {
|
|
||||||
const response = await fakeRequest("/api/v1/notifications", {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
const output = await response.json();
|
|
||||||
|
|
||||||
expect(output.length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import { apiRoute, applyConfig, auth } from "@/api";
|
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
|
||||||
import { Notification } from "@versia/kit/db";
|
|
||||||
import { RolePermissions } from "@versia/kit/tables";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { ErrorSchema } from "~/types/api";
|
|
||||||
|
|
||||||
export const meta = applyConfig({
|
|
||||||
route: "/api/v1/notifications/:id/dismiss",
|
|
||||||
ratelimits: {
|
|
||||||
max: 100,
|
|
||||||
duration: 60,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
oauthPermissions: ["write:notifications"],
|
|
||||||
},
|
|
||||||
permissions: {
|
|
||||||
required: [RolePermissions.ManageOwnNotifications],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const schemas = {
|
|
||||||
param: z.object({
|
|
||||||
id: z.string().uuid(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const route = createRoute({
|
|
||||||
method: "post",
|
|
||||||
path: "/api/v1/notifications/{id}/dismiss",
|
|
||||||
summary: "Dismiss notification",
|
|
||||||
middleware: [auth(meta.auth, meta.permissions)],
|
|
||||||
request: {
|
|
||||||
params: schemas.param,
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Notification dismissed",
|
|
||||||
},
|
|
||||||
401: {
|
|
||||||
description: "Unauthorized",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: ErrorSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
|
||||||
app.openapi(route, async (context) => {
|
|
||||||
const { id } = context.req.valid("param");
|
|
||||||
|
|
||||||
const { user } = context.get("auth");
|
|
||||||
if (!user) {
|
|
||||||
return context.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const notification = await Notification.fromId(id);
|
|
||||||
|
|
||||||
if (!notification) {
|
|
||||||
return context.json({ error: "Notification not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
await notification.update({
|
|
||||||
dismissed: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return context.newResponse(null, 200);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue