Compare commits
418 commits
feature/73
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
667b324572 | ||
|
|
ab9969283b | ||
|
|
7dc90e078e | ||
|
|
69ffa0e520 | ||
|
|
b48ae3e9fa | ||
|
|
b4b72ab2df | ||
|
|
3d3cf5bd7a | ||
|
|
e6e48fb6bc | ||
|
|
495a19540b | ||
|
|
51afbbaf72 | ||
|
|
f0139ae1e0 | ||
|
|
97d17c537b | ||
|
|
82376f312d | ||
|
|
fd2af6dfe6 | ||
|
|
387dc4bb4b | ||
|
|
536060e63c | ||
|
|
0efd5eff2b | ||
|
|
51bc393d58 | ||
|
|
385846d43d | ||
|
|
aa220a0411 | ||
|
|
52976588a7 | ||
|
|
22bb09c6ed | ||
|
|
150d949a3e | ||
|
|
9f640beecc | ||
|
|
7aba846446 | ||
|
|
9309872cff | ||
|
|
3ea85b14a3 | ||
|
|
3164e7b4fc | ||
|
|
3a020d53d1 | ||
|
|
b6db4ef88c | ||
|
|
92db359654 | ||
|
|
531a003a2a | ||
|
|
436e93540a | ||
|
|
cd10e98937 | ||
|
|
ebb6ac195f | ||
|
|
b7e6e13b8d | ||
|
|
16909ed6bd | ||
|
|
1f53eb2ed1 | ||
|
|
1626e50fbf | ||
|
|
fc277839b6 | ||
|
|
57b31366e5 | ||
|
|
2e3eaaddcc | ||
|
|
1ec5e846c5 | ||
|
|
f1168f0165 | ||
|
|
4f1694cd99 | ||
|
|
1e99782666 | ||
|
|
face6527f2 | ||
|
|
43d87270d9 | ||
|
|
3e72d99cf9 | ||
|
|
6f8736c1af | ||
|
|
baf19420dd | ||
|
|
b951b31ef5 | ||
|
|
4ec6bffca7 | ||
|
|
c5f572dcfd | ||
|
|
f4ec837d6e | ||
|
|
f115116454 | ||
|
|
9eb98ae8a5 | ||
|
|
3f5ea11a1f | ||
|
|
4708c0abef | ||
|
|
b9fd7e1b77 | ||
|
|
2afbd251e1 | ||
|
|
ab97b91606 | ||
|
|
e38e408b97 | ||
|
|
0de009f946 | ||
|
|
a47590e64c | ||
|
|
1fb1875ac3 | ||
|
|
b477de1d98 | ||
|
|
b0420c948c | ||
|
|
b4a278ae54 | ||
|
|
a51fef29c0 | ||
|
|
8e07eb7f44 | ||
|
|
caaa78d98d | ||
|
|
0ea0466313 | ||
|
|
3ae9f4e8e6 | ||
|
|
59afb56b5b | ||
|
|
a596718bbf | ||
|
|
ab992422a8 | ||
|
|
3faad0a5e5 | ||
|
|
ebdfb2feb7 | ||
|
|
dbab122a99 | ||
|
|
e3b826db5a | ||
|
|
7e3f519a5b | ||
|
|
6b54405003 | ||
|
|
e32fb4e86d | ||
|
|
2b9c3f0d5c | ||
|
|
ae7b90de6c | ||
|
|
d74cf9e4ff | ||
|
|
9d5bc6cb28 | ||
|
|
9d3321fca4 | ||
|
|
2bbccde2ce | ||
|
|
47eb0daebb | ||
|
|
fd47bf3483 | ||
|
|
9bf18546fc | ||
|
|
fadcabeaa6 | ||
|
|
2ac36e4a5c | ||
|
|
4b5a400264 | ||
|
|
5b72c08a68 | ||
|
|
9f3b97effb | ||
|
|
34a5dbe21b | ||
|
|
241b186a8a | ||
|
|
a150bc53ab | ||
|
|
4b503f88e1 | ||
|
|
faf1b3559a | ||
|
|
2fb2e52312 | ||
|
|
2a4c432f41 | ||
|
|
4c6cec552e | ||
|
|
c48faca707 | ||
|
|
c9afaba0d4 | ||
|
|
5b48032681 | ||
|
|
83472dbd82 | ||
|
|
2b0a622875 | ||
|
|
4a43e1a9e9 | ||
|
|
1ca350e45d | ||
|
|
38e30c0d54 | ||
|
|
38787712d9 | ||
|
|
0515fed92d | ||
|
|
1d16656b39 | ||
|
|
ed6c781426 | ||
|
|
8cbc0761db | ||
|
|
455ccc660e | ||
|
|
a40b77a66b | ||
|
|
194bc20af1 | ||
|
|
ca94959fff | ||
|
|
bcc20d6dc4 | ||
|
|
0de7a084a9 | ||
|
|
cfc3ab4b04 | ||
|
|
8f42e8434e | ||
|
|
dff465000c | ||
|
|
0f6d26e065 | ||
|
|
cc394d9a4b | ||
|
|
c9934c379f | ||
|
|
eb1e326813 | ||
|
|
a62e4f1cf2 | ||
|
|
dcd5b6d972 | ||
|
|
fedf0d7e20 | ||
|
|
984cfe358d | ||
|
|
aabb1945e8 | ||
|
|
4e0f7ced84 | ||
|
|
41536480ce | ||
|
|
59e160147f | ||
|
|
a38d8a91a1 | ||
|
|
6027b516e1 | ||
|
|
757d9aa5ee | ||
|
|
36af07abe2 | ||
|
|
23c4aa2571 | ||
|
|
1758f29364 | ||
|
|
fa3cf6c299 | ||
|
|
4b556efdaa | ||
|
|
b0834ebf55 | ||
|
|
2234fbcb11 | ||
|
|
8e90484b3e | ||
|
|
0fcb23c4c1 | ||
|
|
776f6fd1f5 | ||
|
|
7b3e3f8e25 | ||
|
|
360d71278a | ||
|
|
663c06be00 | ||
|
|
7ccccf5545 | ||
|
|
f36f4b5398 | ||
|
|
cc4e99fdde | ||
|
|
5764fa55cb | ||
|
|
74565f67f7 | ||
|
|
408e782507 | ||
|
|
cbf8cc376e | ||
|
|
c04f344049 | ||
|
|
b9080da75d | ||
|
|
4d925fc086 | ||
|
|
b74e2e9167 | ||
|
|
ebea1a2962 | ||
|
|
4c432c07cb | ||
|
|
322b3b677f | ||
|
|
1c7e05ce9e | ||
|
|
9ab25ede28 | ||
|
|
174dfb83d0 | ||
|
|
ad8e8793c7 | ||
|
|
1e14612f0e | ||
|
|
9090b745e6 | ||
|
|
d883934826 | ||
|
|
e0bb796aff | ||
|
|
fb54546573 | ||
|
|
9e0b759197 | ||
|
|
41c500851b | ||
|
|
27339e03c2 | ||
|
|
680c2a0718 | ||
|
|
f258888408 | ||
|
|
d150e92f41 | ||
|
|
482538c7f8 | ||
|
|
d579687156 | ||
|
|
de970ff54e | ||
|
|
1bfb0dc395 | ||
|
|
da2dfee0a8 | ||
|
|
eaad96aae3 | ||
|
|
0a05841f33 | ||
|
|
68e5b5a84a | ||
|
|
6d6b03dfe2 | ||
|
|
19be113cb4 | ||
|
|
101ca9e0f7 | ||
|
|
906c2863db | ||
|
|
917e67d356 | ||
|
|
cd2e597223 | ||
|
|
03559156b9 | ||
|
|
aebdbf07b4 | ||
|
|
00ab7f5bd1 | ||
|
|
83f780978c | ||
|
|
7f9a151055 | ||
|
|
e0a2e7aedc | ||
|
|
9fe5dc679a | ||
|
|
6ed38f53f5 | ||
|
|
0f07f27642 | ||
|
|
680e3ac7a3 | ||
|
|
c2c2120b76 | ||
|
|
002d0def42 | ||
|
|
a769423c15 | ||
|
|
8477909af2 | ||
|
|
e783359aca | ||
|
|
fa03c4cebe | ||
|
|
ddf572c22f | ||
|
|
872f987845 | ||
|
|
37fd454f70 | ||
|
|
2e6726c81f | ||
|
|
3a72bf453a | ||
|
|
65d81a4ae2 | ||
|
|
8f0df1f01c | ||
|
|
c566fa1f36 | ||
|
|
a15e5c52f4 | ||
|
|
1c181df086 | ||
|
|
f5652605ec | ||
|
|
9d3aa6bb41 | ||
|
|
5b64b9001d | ||
|
|
1906dbe1dc | ||
|
|
b97db55a94 | ||
|
|
56023140cb | ||
|
|
4ad816e0df | ||
|
|
5e054d0218 | ||
|
|
b8b077cbad | ||
|
|
d786e96c2b | ||
|
|
8824422cb5 | ||
|
|
bcc845cdb1 | ||
|
|
c8357a410b | ||
|
|
8b16b0fce9 | ||
|
|
4da262d98c | ||
|
|
ade801ec58 | ||
|
|
37ff2bb0ca | ||
|
|
f36a1a5701 | ||
|
|
173623a24b | ||
|
|
64e4cf8277 | ||
|
|
131fab1032 | ||
|
|
9daecc27a5 | ||
|
|
1520bc1715 | ||
|
|
276b30bdc0 | ||
|
|
473f100b67 | ||
|
|
d72c40d157 | ||
|
|
6e5cbedc75 | ||
|
|
e87dddcca2 | ||
|
|
a541eaba5e | ||
|
|
d2a4d6d9e0 | ||
|
|
75fc3de405 | ||
|
|
b034e1db67 | ||
|
|
27b502fab5 | ||
|
|
c0a5955e0a | ||
|
|
5eb9a263e2 | ||
|
|
78a75171c2 | ||
|
|
ca1cdc4ea3 | ||
|
|
726013057d | ||
|
|
c5d9bde43f | ||
|
|
01e98c75ab | ||
|
|
10d3d9f382 | ||
|
|
a6befca845 | ||
|
|
67185a5d5d | ||
|
|
560ee43dcf | ||
|
|
524ddb9677 | ||
|
|
55df1ad10f | ||
|
|
9562a830ed | ||
|
|
57ce32d44f | ||
|
|
991995673d | ||
|
|
beff26e6f4 | ||
|
|
d7ffc0be62 | ||
|
|
ca91af7fa9 | ||
|
|
ff220bd372 | ||
|
|
4fed355592 | ||
|
|
c59852f834 | ||
|
|
aae7fff494 | ||
|
|
724aff6e4e | ||
|
|
d52f4748f2 | ||
|
|
67f977f4ff | ||
|
|
e05420a92d | ||
|
|
f781c19df1 | ||
|
|
60be692a0a | ||
|
|
b26b7a9570 | ||
|
|
e40b3ec4c7 | ||
|
|
2fb688803f | ||
|
|
cff59ce2aa | ||
|
|
6a3dc40c31 | ||
|
|
a7a630bfd0 | ||
|
|
d466e05eda | ||
|
|
b0bc24f01b | ||
|
|
f11536c927 | ||
|
|
30d53de356 | ||
|
|
dba3277200 | ||
|
|
82674d8718 | ||
|
|
6aaeda13b9 | ||
|
|
42e2a58642 | ||
|
|
bc45ff2103 | ||
|
|
04654b2f84 | ||
|
|
053b47d78a | ||
|
|
6430a191f7 | ||
|
|
24ecef80e7 | ||
|
|
063c597ca6 | ||
|
|
d7a3ec9c5e | ||
|
|
dfee4108f9 | ||
|
|
577e66e2ce | ||
|
|
1811933025 | ||
|
|
d103b76ab0 | ||
|
|
5e4ed13213 | ||
|
|
649b525ab2 | ||
|
|
684be7d709 | ||
|
|
54addd0390 | ||
|
|
4ccc0c4b1e | ||
|
|
90d8050df4 | ||
|
|
5482aac3aa | ||
|
|
65ac5fef46 | ||
|
|
5e49246c1e | ||
|
|
fcd2c93a19 | ||
|
|
7aee3c1617 | ||
|
|
b1d9314d6e | ||
|
|
2deb64486b | ||
|
|
8bf7495c92 | ||
|
|
61e33cb7e3 | ||
|
|
f43aec7c88 | ||
|
|
aa19418037 | ||
|
|
3b89b73d27 | ||
|
|
ba17776b19 | ||
|
|
2a4c91efcc | ||
|
|
290bfd2075 | ||
|
|
52e291af67 | ||
|
|
d4ef030fd9 | ||
|
|
ed064b2193 | ||
|
|
fea7889e0c | ||
|
|
5152192e09 | ||
|
|
42530b5a39 | ||
|
|
360a127ad7 | ||
|
|
1d9cb4fad9 | ||
|
|
786677b079 | ||
|
|
31039821a1 | ||
|
|
fbe6b31878 | ||
|
|
6c30c94b92 | ||
|
|
2c8af72168 | ||
|
|
0c2e113e8e | ||
|
|
af3bb7346e | ||
|
|
1f53df66d4 | ||
|
|
5f3cb09eb1 | ||
|
|
2bd87fa481 | ||
|
|
1a9f2f84b3 | ||
|
|
b20e671452 | ||
|
|
45ac7e50bc | ||
|
|
b5a1c54d65 | ||
|
|
7431866d86 | ||
|
|
9b06347882 | ||
|
|
de61781c4a | ||
|
|
7e220d6e31 | ||
|
|
c5f1279d4b | ||
|
|
4128b38724 | ||
|
|
dedb24fe74 | ||
|
|
6b56163931 | ||
|
|
61cb46b171 | ||
|
|
00bb958874 | ||
|
|
dd58a4aa92 | ||
|
|
8a34d8e9d2 | ||
|
|
68b90df00b | ||
|
|
7647aa637a | ||
|
|
de9b99c937 | ||
|
|
16847ba491 | ||
|
|
e781be3c72 | ||
|
|
e19193c9d0 | ||
|
|
5dc700938d | ||
|
|
93cf2f9045 | ||
|
|
c55af9c3b3 | ||
|
|
b18d7c0f3f | ||
|
|
fa687ecb33 | ||
|
|
d3792ab201 | ||
|
|
9b1bae653d | ||
|
|
24fd35e03d | ||
|
|
e3c79b0c83 | ||
|
|
158cd3649d | ||
|
|
fb7ac68ece | ||
|
|
499e8895c5 | ||
|
|
463b9ac59d | ||
|
|
56e7d7e0b1 | ||
|
|
9d3292e6e9 | ||
|
|
fea993f6b2 | ||
|
|
86a693b182 | ||
|
|
4e592fb1c9 | ||
|
|
5c1d16947c | ||
|
|
8897b191d9 | ||
|
|
652cc8602c | ||
|
|
6213018e62 | ||
|
|
d04e44b552 | ||
|
|
b15f25758a | ||
|
|
2cd41228d8 | ||
|
|
bd2cdd9363 | ||
|
|
edce54ad0f | ||
|
|
19a1f3111b | ||
|
|
0c03f9ead0 | ||
|
|
0e6ba9ccd4 | ||
|
|
1eacf0772c | ||
|
|
45974a53f8 | ||
|
|
75ed3843fa | ||
|
|
fdfb0faab0 | ||
|
|
808963189e | ||
|
|
5085c39440 | ||
|
|
3093707469 | ||
|
|
a44d58781f | ||
|
|
669e5c6ca0 | ||
|
|
bf6ca8efdc | ||
|
|
5af38db74b | ||
|
|
06bd29f209 | ||
|
|
a1375c8ab7 | ||
|
|
a39f5c92b4 | ||
|
|
830565787a |
497 changed files with 8726 additions and 1597 deletions
|
|
@ -167,8 +167,18 @@ id: 'aidx'
|
|||
# IP address family used for outgoing request (ipv4, ipv6 or dual)
|
||||
#outgoingAddressFamily: ipv4
|
||||
|
||||
# Amount of characters that can be used when writing notes (maximum: 100000, minimum: 1)
|
||||
maxNoteLength: 3000
|
||||
# Amount of characters that can be used when writing notes. Longer notes will be rejected. (minimum: 1)
|
||||
#maxNoteLength: 3000
|
||||
# Amount of characters that will be saved for remote notes. Longer notes will be truncated to this length. (minimum: 1)
|
||||
#maxRemoteNoteLength: 100000
|
||||
# Amount of characters that can be used when writing content warnings. Longer warnings will be rejected. (minimum: 1)
|
||||
#maxCwLength: 500
|
||||
# Amount of characters that will be saved for remote content warnings. Longer warnings will be truncated to this length. (minimum: 1)
|
||||
#maxRemoteCwLength: 5000
|
||||
# Amount of characters that can be used when writing media descriptions (alt text). Longer descriptions will be rejected. (minimum: 1)
|
||||
#maxAltTextLength: 20000
|
||||
# Amount of characters that will be saved for remote media descriptions (alt text). Longer descriptions will be truncated to this length. (minimum: 1)
|
||||
#maxRemoteAltTextLength: 100000
|
||||
|
||||
# Proxy for HTTP/HTTPS
|
||||
#proxy: http://127.0.0.1:3128
|
||||
|
|
@ -219,3 +229,8 @@ checkActivityPubGetSignature: false
|
|||
|
||||
# Upload or download file size limits (bytes)
|
||||
#maxFileSize: 262144000
|
||||
|
||||
# CHMod-style permission bits to apply to uploaded files.
|
||||
# Permission bits are specified as a base-8 string representing User/Group/Other permissions.
|
||||
# This setting is only useful for custom deployments, such as using a reverse proxy to serve media.
|
||||
#filePermissionBits: '644'
|
||||
|
|
|
|||
|
|
@ -179,6 +179,19 @@ id: 'aidx'
|
|||
# IP address family used for outgoing request (ipv4, ipv6 or dual)
|
||||
#outgoingAddressFamily: ipv4
|
||||
|
||||
# Amount of characters that can be used when writing notes. Longer notes will be rejected. (minimum: 1)
|
||||
#maxNoteLength: 3000
|
||||
# Amount of characters that will be saved for remote notes. Longer notes will be truncated to this length. (minimum: 1)
|
||||
#maxRemoteNoteLength: 100000
|
||||
# Amount of characters that can be used when writing content warnings. Longer warnings will be rejected. (minimum: 1)
|
||||
#maxCwLength: 500
|
||||
# Amount of characters that will be saved for remote content warnings. Longer warnings will be truncated to this length. (minimum: 1)
|
||||
#maxRemoteCwLength: 5000
|
||||
# Amount of characters that can be used when writing media descriptions (alt text). Longer descriptions will be rejected. (minimum: 1)
|
||||
#maxAltTextLength: 20000
|
||||
# Amount of characters that will be saved for remote media descriptions (alt text). Longer descriptions will be truncated to this length. (minimum: 1)
|
||||
#maxRemoteAltTextLength: 100000
|
||||
|
||||
# Proxy for HTTP/HTTPS
|
||||
#proxy: http://127.0.0.1:3128
|
||||
|
||||
|
|
@ -209,3 +222,8 @@ allowedPrivateNetworks: [
|
|||
|
||||
# Upload or download file size limits (bytes)
|
||||
#maxFileSize: 262144000
|
||||
|
||||
# CHMod-style permission bits to apply to uploaded files.
|
||||
# Permission bits are specified as a base-8 string representing User/Group/Other permissions.
|
||||
# This setting is only useful for custom deployments, such as using a reverse proxy to serve media.
|
||||
#filePermissionBits: '644'
|
||||
|
|
|
|||
|
|
@ -250,8 +250,18 @@ id: 'aidx'
|
|||
# IP address family used for outgoing request (ipv4, ipv6 or dual)
|
||||
#outgoingAddressFamily: ipv4
|
||||
|
||||
# Amount of characters that can be used when writing notes (maximum: 100000, minimum: 1)
|
||||
maxNoteLength: 3000
|
||||
# Amount of characters that can be used when writing notes. Longer notes will be rejected. (minimum: 1)
|
||||
#maxNoteLength: 3000
|
||||
# Amount of characters that will be saved for remote notes. Longer notes will be truncated to this length. (minimum: 1)
|
||||
#maxRemoteNoteLength: 100000
|
||||
# Amount of characters that can be used when writing content warnings. Longer warnings will be rejected. (minimum: 1)
|
||||
#maxCwLength: 500
|
||||
# Amount of characters that will be saved for remote content warnings. Longer warnings will be truncated to this length. (minimum: 1)
|
||||
#maxRemoteCwLength: 5000
|
||||
# Amount of characters that can be used when writing media descriptions (alt text). Longer descriptions will be rejected. (minimum: 1)
|
||||
#maxAltTextLength: 20000
|
||||
# Amount of characters that will be saved for remote media descriptions (alt text). Longer descriptions will be truncated to this length. (minimum: 1)
|
||||
#maxRemoteAltTextLength: 100000
|
||||
|
||||
# Proxy for HTTP/HTTPS
|
||||
#proxy: http://127.0.0.1:3128
|
||||
|
|
@ -302,3 +312,8 @@ checkActivityPubGetSignature: false
|
|||
|
||||
# Upload or download file size limits (bytes)
|
||||
#maxFileSize: 262144000
|
||||
|
||||
# CHMod-style permission bits to apply to uploaded files.
|
||||
# Permission bits are specified as a base-8 string representing User/Group/Other permissions.
|
||||
# This setting is only useful for custom deployments, such as using a reverse proxy to serve media.
|
||||
#filePermissionBits: '644'
|
||||
|
|
|
|||
|
|
@ -99,10 +99,10 @@ db:
|
|||
port: 5432
|
||||
|
||||
# Database name
|
||||
db: misskey
|
||||
db: sharkey
|
||||
|
||||
# Auth
|
||||
user: example-misskey-user
|
||||
user: sharkey
|
||||
pass: example-misskey-pass
|
||||
|
||||
# Whether disable Caching queries
|
||||
|
|
@ -261,8 +261,18 @@ id: 'aidx'
|
|||
# IP address family used for outgoing request (ipv4, ipv6 or dual)
|
||||
#outgoingAddressFamily: ipv4
|
||||
|
||||
# Amount of characters that can be used when writing notes (maximum: 100000, minimum: 1)
|
||||
maxNoteLength: 3000
|
||||
# Amount of characters that can be used when writing notes. Longer notes will be rejected. (minimum: 1)
|
||||
#maxNoteLength: 3000
|
||||
# Amount of characters that will be saved for remote notes. Longer notes will be truncated to this length. (minimum: 1)
|
||||
#maxRemoteNoteLength: 100000
|
||||
# Amount of characters that can be used when writing content warnings. Longer warnings will be rejected. (minimum: 1)
|
||||
#maxCwLength: 500
|
||||
# Amount of characters that will be saved for remote content warnings. Longer warnings will be truncated to this length. (minimum: 1)
|
||||
#maxRemoteCwLength: 5000
|
||||
# Amount of characters that can be used when writing media descriptions (alt text). Longer descriptions will be rejected. (minimum: 1)
|
||||
#maxAltTextLength: 20000
|
||||
# Amount of characters that will be saved for remote media descriptions (alt text). Longer descriptions will be truncated to this length. (minimum: 1)
|
||||
#maxRemoteAltTextLength: 100000
|
||||
|
||||
# Proxy for HTTP/HTTPS
|
||||
#proxy: http://127.0.0.1:3128
|
||||
|
|
@ -324,3 +334,8 @@ checkActivityPubGetSignature: false
|
|||
|
||||
# PID File of master process
|
||||
#pidFile: /tmp/misskey.pid
|
||||
|
||||
# CHMod-style permission bits to apply to uploaded files.
|
||||
# Permission bits are specified as a base-8 string representing User/Group/Other permissions.
|
||||
# This setting is only useful for custom deployments, such as using a reverse proxy to serve media.
|
||||
#filePermissionBits: '644'
|
||||
|
|
|
|||
|
|
@ -3,27 +3,33 @@
|
|||
🔒 Found a security vulnerability? [Please disclose it responsibly.](https://activitypub.software/TransFem-org/Sharkey/-/blob/develop/SECURITY.md)
|
||||
🤝 By submitting this feature request, you agree to follow our [Contribution Guidelines.](https://activitypub.software/TransFem-org/Sharkey/-/blob/develop/CONTRIBUTING.md) -->
|
||||
|
||||
**What happened?** _(Please give us a brief description of what happened.)_
|
||||
# **What happened?**
|
||||
<!-- Please give us a brief description of what happened. -->
|
||||
|
||||
**What did you expect to happen?** _(Please give us a brief description of what you expected to happen.)_
|
||||
# **What did you expect to happen?**
|
||||
<!-- Please give us a brief description of what you expected to happen. -->
|
||||
|
||||
**Version** _(What version of Sharkey is your instance running? You can find this by clicking your instance's logo at the top left and then clicking instance information.)_
|
||||
# **Version**
|
||||
<!-- What version of Sharkey is your instance running? You can find this by clicking your instance's logo at the top left and then clicking instance information. -->
|
||||
|
||||
**Instance** _(What instance of Sharkey are you using?)_
|
||||
# **Instance**
|
||||
<!-- What instance of Sharkey are you using? -->
|
||||
|
||||
**What type of issue is this?** _(If this happens on your device and has to do with the user interface, it's client-side. If this happens on either with the API or the backend, or you got a server-side error in the client, it's server-side.)_
|
||||
# **What type of issue is this?**
|
||||
<!-- If this happens on your device and has to do with the user interface, it's client-side. If this happens on either with the API or the backend, or you got a server-side error in the client, it's server-side. -->
|
||||
|
||||
**What browser are you using? (Client-side issues only)**
|
||||
# **What browser are you using? (Client-side issues only)**
|
||||
|
||||
**What operating system are you using? (Client-side issues only)**
|
||||
# **What operating system are you using? (Client-side issues only)**
|
||||
|
||||
**How do you deploy Sharkey on your server? (Server-side issues only)**
|
||||
# **How do you deploy Sharkey on your server? (Server-side issues only)**
|
||||
|
||||
**What operating system are you using? (Server-side issues only)**
|
||||
# **What operating system are you using? (Server-side issues only)**
|
||||
|
||||
**Relevant log output** _(Please copy and paste any relevant log output. You can find your log by inspecting the page, and going to the "console" tab. This will be automatically formatted into code, so no need for backticks.)_
|
||||
# **Relevant log output**
|
||||
<!-- Please copy and paste any relevant log output. You can find your log by inspecting the page, and going to the "console" tab. This will be automatically formatted into code, so no need for backticks. -->
|
||||
|
||||
**Contribution Guidelines**
|
||||
# **Contribution Guidelines**
|
||||
By submitting this issue, you agree to follow our [Contribution Guidelines](https://activitypub.software/TransFem-org/Sharkey/-/blob/develop/CONTRIBUTING.md)
|
||||
- [ ] I agree to follow this project's Contribution Guidelines
|
||||
- [ ] I have searched the issue tracker for similar issues, and this is not a duplicate.
|
||||
|
|
|
|||
|
|
@ -3,15 +3,19 @@
|
|||
🔒 Found a security vulnerability? [Please disclose it responsibly.](https://activitypub.software/TransFem-org/Sharkey/-/blob/develop/SECURITY.md)
|
||||
🤝 By submitting this feature request, you agree to follow our [Contribution Guidelines.](https://activitypub.software/TransFem-org/Sharkey/-/blob/develop/CONTRIBUTING.md) -->
|
||||
|
||||
**What feature would you like implemented?** _(Please give us a brief description of what you'd like.)_
|
||||
# **What feature would you like implemented?**
|
||||
<!-- Please give us a brief description of what you'd like. -->
|
||||
|
||||
**Why should we add this feature?** _(Please give us a brief description of why your feature is important.)_
|
||||
# **Why should we add this feature?**
|
||||
<!-- Please give us a brief description of why your feature is important. -->
|
||||
|
||||
**Version** _(What version of Sharkey is your instance running? You can find this by clicking your instance's logo at the top left and then clicking instance information.)_
|
||||
# **Version**
|
||||
<!-- What version of Sharkey is your instance running? You can find this by clicking your instance's logo at the top left and then clicking instance information. -->
|
||||
|
||||
**Instance** _(What instance of Sharkey are you using?)_
|
||||
# **Instance**
|
||||
<!-- What instance of Sharkey are you using? -->
|
||||
|
||||
**Contribution Guidelines**
|
||||
# **Contribution Guidelines**
|
||||
By submitting this issue, you agree to follow our [Contribution Guidelines](https://activitypub.software/TransFem-org/Sharkey/-/blob/develop/CONTRIBUTING.md)
|
||||
- [ ] I agree to follow this project's Contribution Guidelines
|
||||
- [ ] I have searched the issue tracker for similar requests, and this is not a duplicate.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
<!-- Thanks for taking the time to make Sharkey better! -->
|
||||
|
||||
**What does this PR do?** _(Please give us a brief description of what this PR does.)_
|
||||
# **What does this MR do?**
|
||||
<!-- Please give us a brief description of what this PR does. -->
|
||||
|
||||
**Contribution Guidelines**
|
||||
# **Contribution Guidelines**
|
||||
By submitting this merge request, you agree to follow our [Contribution Guidelines](https://activitypub.software/TransFem-org/Sharkey/-/blob/develop/CONTRIBUTING.md)
|
||||
- [ ] I agree to follow this project's Contribution Guidelines
|
||||
- [ ] I have made sure to test this pull request
|
||||
- [ ] I have made sure to test this merge request
|
||||
|
||||
<!-- Uncomment if your merge request has multiple authors -->
|
||||
<!-- Co-authored-by: Name <email@email.com> -->
|
||||
|
|
|
|||
|
|
@ -529,7 +529,8 @@ enumの列挙の内容の削除は、その値をもつレコードを全て削
|
|||
### Migration作成方法
|
||||
packages/backendで:
|
||||
```sh
|
||||
pnpm dlx typeorm migration:generate -d ormconfig.js -o <migration name>
|
||||
pnpm run build
|
||||
pnpm dlx typeorm migration:generate -d ormconfig.js -o migration/<migration name>
|
||||
```
|
||||
|
||||
- 生成後、ファイルをmigration下に移してください
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ RUN apk add ffmpeg tini jemalloc \
|
|||
&& corepack enable \
|
||||
&& addgroup -g "${GID}" sharkey \
|
||||
&& adduser -D -u "${UID}" -G sharkey -h /sharkey sharkey \
|
||||
&& mkdir /sharkey/files \
|
||||
&& chown sharkey:sharkey /sharkey/files \
|
||||
&& find / -type d -path /sys -prune -o -type d -path /proc -prune -o -type f -perm /u+s -exec chmod u-s {} \; \
|
||||
&& find / -type d -path /sys -prune -o -type d -path /proc -prune -o -type f -perm /g+s -exec chmod g-s {} \;
|
||||
|
||||
|
|
|
|||
74
UPGRADE_NOTES.md
Normal file
74
UPGRADE_NOTES.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# Upgrade Notes
|
||||
|
||||
## 2024.10.0
|
||||
|
||||
### Hellspawns
|
||||
|
||||
Sharkey versions before 2024.10 suffered from a bug in the "Mark instance as NSFW" feature.
|
||||
When a user from such an instance boosted a note, the boost would be converted to a hellspawn (pure renote with Content Warning).
|
||||
Hellspawns are buggy and do not properly federate, so it may be desirable to correct any that already exist in the database.
|
||||
The following script will correct any local or remote hellspawns in the database.
|
||||
|
||||
```postgresql
|
||||
/* Remove "instance is marked as NSFW" hellspawns */
|
||||
UPDATE "note"
|
||||
SET "cw" = null
|
||||
WHERE
|
||||
"renoteId" IS NOT NULL
|
||||
AND "text" IS NULL
|
||||
AND "cw" = 'Instance is marked as NSFW'
|
||||
AND "replyId" IS NULL
|
||||
AND "hasPoll" = false
|
||||
AND "fileIds" = '{}';
|
||||
|
||||
/* Fix legacy / user-created hellspawns */
|
||||
UPDATE "note"
|
||||
SET "text" = '.'
|
||||
WHERE
|
||||
"renoteId" IS NOT NULL
|
||||
AND "text" IS NULL
|
||||
AND "cw" IS NOT NULL
|
||||
AND "replyId" IS NULL
|
||||
AND "hasPoll" = false
|
||||
AND "fileIds" = '{}';
|
||||
```
|
||||
|
||||
## 2024.9.0
|
||||
|
||||
### Following Feed
|
||||
|
||||
When upgrading an existing instance to version 2024.9.0, the Following Feed will initially be empty.
|
||||
The feed will gradually fill as new posts federate, but it may be desirable to back-fill the feed with existing data.
|
||||
This database script will populate the feed with the latest post of each type for all users, ensuring that data is fully populated after the update.
|
||||
Run this after migrations but before starting the instance.
|
||||
Warning: the script may take a long time to execute!
|
||||
|
||||
```postgresql
|
||||
INSERT INTO latest_note (user_id, note_id, is_public, is_reply, is_quote)
|
||||
SELECT
|
||||
"userId" as user_id,
|
||||
id as note_id,
|
||||
visibility = 'public' AS is_public,
|
||||
"replyId" IS NOT NULL AS is_reply,
|
||||
(
|
||||
"renoteId" IS NOT NULL
|
||||
AND (
|
||||
text IS NOT NULL
|
||||
OR cw IS NOT NULL
|
||||
OR "replyId" IS NOT NULL
|
||||
OR "hasPoll"
|
||||
OR "fileIds" != '{}'
|
||||
)
|
||||
) AS is_quote
|
||||
FROM note
|
||||
WHERE ( -- Exclude pure renotes (boosts)
|
||||
"renoteId" IS NULL
|
||||
OR text IS NOT NULL
|
||||
OR cw IS NOT NULL
|
||||
OR "replyId" IS NOT NULL
|
||||
OR "hasPoll"
|
||||
OR "fileIds" != '{}'
|
||||
)
|
||||
ORDER BY id DESC -- This part is very important: it ensures that we only load the *latest* notes of each type. Do not remove it!
|
||||
ON CONFLICT DO NOTHING; -- Any conflicts are guaranteed to be older notes that we can ignore.
|
||||
```
|
||||
251
eslint/locale.js
Normal file
251
eslint/locale.js
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: dakkar and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* This is a ESLint rule to report use of the `i18n.ts` and `i18n.tsx`
|
||||
* objects that reference translation items that don't actually exist
|
||||
* in the lexicon (the `locale/` files)
|
||||
*/
|
||||
|
||||
/* given a MemberExpression node, collects all the member names
|
||||
*
|
||||
* e.g. for a bit of code like `foo=one.two.three`, `collectMembers`
|
||||
* called on the node for `three` would return `['one', 'two',
|
||||
* 'three']`
|
||||
*/
|
||||
function collectMembers(node) {
|
||||
if (!node) return [];
|
||||
if (node.type !== 'MemberExpression') return [];
|
||||
// this is something like `foo[bar]`
|
||||
if (node.computed) return [];
|
||||
return [ node.property.name, ...collectMembers(node.parent) ];
|
||||
}
|
||||
|
||||
/* given an object and an array of names, recursively descends the
|
||||
* object via those names
|
||||
*
|
||||
* e.g. `walkDown({one:{two:{three:15}}},['one','two','three'])` would
|
||||
* return 15
|
||||
*/
|
||||
function walkDown(locale, path) {
|
||||
if (!locale) return null;
|
||||
if (!path || path.length === 0 || !path[0]) return locale;
|
||||
return walkDown(locale[path[0]], path.slice(1));
|
||||
}
|
||||
|
||||
/* given a MemberExpression node, returns its attached CallExpression
|
||||
* node if present
|
||||
*
|
||||
* e.g. for a bit of code like `foo=one.two.three()`,
|
||||
* `findCallExpression` called on the node for `three` would return
|
||||
* the node for function call (which is the parent of the `one` and
|
||||
* `two` nodes, and holds the nodes for the argument list)
|
||||
*
|
||||
* if the code had been `foo=one.two.three`, `findCallExpression`
|
||||
* would have returned null, because there's no function call attached
|
||||
* to the MemberExpressions
|
||||
*/
|
||||
function findCallExpression(node) {
|
||||
if (!node.parent) return null;
|
||||
|
||||
// the second half of this guard protects from cases like
|
||||
// `foo(one.two.three)` where the CallExpression is parent of the
|
||||
// MemberExpressions, but via `arguments`, not `callee`
|
||||
if (node.parent.type === 'CallExpression' && node.parent.callee === node) return node.parent;
|
||||
if (node.parent.type === 'MemberExpression') return findCallExpression(node.parent);
|
||||
return null;
|
||||
}
|
||||
|
||||
// same, but for Vue expressions (`<I18n :src="i18n.ts.foo">`)
|
||||
function findVueExpression(node) {
|
||||
if (!node.parent) return null;
|
||||
|
||||
if (node.parent.type.match(/^VExpr/) && node.parent.expression === node) return node.parent;
|
||||
if (node.parent.type === 'MemberExpression') return findVueExpression(node.parent);
|
||||
return null;
|
||||
}
|
||||
|
||||
function areArgumentsOneObject(node) {
|
||||
return node.arguments.length === 1 &&
|
||||
node.arguments[0].type === 'ObjectExpression';
|
||||
}
|
||||
|
||||
// only call if `areArgumentsOneObject(node)` is true
|
||||
function getArgumentObjectProperties(node) {
|
||||
return new Set(node.arguments[0].properties.map(
|
||||
p => {
|
||||
if (p.key && p.key.type === 'Identifier') return p.key.name;
|
||||
return null;
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
function getTranslationParameters(translation) {
|
||||
return new Set(Array.from(translation.matchAll(/\{(\w+)\}/g)).map( m => m[1] ));
|
||||
}
|
||||
|
||||
function setDifference(a,b) {
|
||||
const result = [];
|
||||
for (const element of a.values()) {
|
||||
if (!b.has(element)) {
|
||||
result.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/* the actual rule body
|
||||
*/
|
||||
function theRuleBody(context,node) {
|
||||
// we get the locale/translations via the options; it's the data
|
||||
// that goes into a specific language's JSON file, see
|
||||
// `scripts/build-assets.mjs`
|
||||
const locale = context.options[0];
|
||||
|
||||
// sometimes we get MemberExpression nodes that have a
|
||||
// *descendent* with the right identifier: skip them, we'll get
|
||||
// the right ones as well
|
||||
if (node.object?.name !== 'i18n') {
|
||||
return;
|
||||
}
|
||||
|
||||
// `method` is going to be `'ts'` or `'tsx'`, `path` is going to
|
||||
// be the various translation steps/names
|
||||
const [ method, ...path ] = collectMembers(node);
|
||||
const pathStr = `i18n.${method}.${path.join('.')}`;
|
||||
|
||||
// does that path point to a real translation?
|
||||
const translation = walkDown(locale, path);
|
||||
if (!translation) {
|
||||
context.report({
|
||||
node,
|
||||
message: `translation missing for ${pathStr}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// we hit something weird, assume the programmers know what
|
||||
// they're doing (this is usually some complicated slicing of
|
||||
// the translation structure)
|
||||
if (typeof(translation) !== 'string') return;
|
||||
|
||||
const callExpression = findCallExpression(node);
|
||||
const vueExpression = findVueExpression(node);
|
||||
|
||||
// some more checks on how the translation is called
|
||||
if (method === 'ts') {
|
||||
// the `<I18n> component gets parametric translations via
|
||||
// `i18n.ts.*`, but we error out elsewhere
|
||||
if (translation.match(/\{/) && !vueExpression) {
|
||||
context.report({
|
||||
node,
|
||||
message: `translation for ${pathStr} is parametric, but called via 'ts'`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (callExpression) {
|
||||
context.report({
|
||||
node,
|
||||
message: `translation for ${pathStr} is not parametric, but is called as a function`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (method === 'tsx') {
|
||||
if (!translation.match(/\{/)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `translation for ${pathStr} is not parametric, but called via 'tsx'`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!callExpression && !vueExpression) {
|
||||
context.report({
|
||||
node,
|
||||
message: `translation for ${pathStr} is parametric, but not called as a function`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// we're not currently checking arguments when used via the
|
||||
// `<I18n>` component, because it's too complicated (also, it
|
||||
// would have to be done inside the `if (method === 'ts')`)
|
||||
if (!callExpression) return;
|
||||
|
||||
if (!areArgumentsOneObject(callExpression)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `translation for ${pathStr} should be called with a single object as argument`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const translationParameters = getTranslationParameters(translation);
|
||||
const parameterCount = translationParameters.size;
|
||||
const callArguments = getArgumentObjectProperties(callExpression);
|
||||
const argumentCount = callArguments.size;
|
||||
|
||||
if (parameterCount !== argumentCount) {
|
||||
context.report({
|
||||
node,
|
||||
message: `translation for ${pathStr} has ${parameterCount} parameters, but is called with ${argumentCount} arguments`,
|
||||
});
|
||||
}
|
||||
|
||||
// node 20 doesn't have `Set.difference`...
|
||||
const extraArguments = setDifference(callArguments, translationParameters);
|
||||
const missingArguments = setDifference(translationParameters, callArguments);
|
||||
|
||||
if (extraArguments.length > 0) {
|
||||
context.report({
|
||||
node,
|
||||
message: `translation for ${pathStr} passes unused arguments ${extraArguments.join(' ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (missingArguments.length > 0) {
|
||||
context.report({
|
||||
node,
|
||||
message: `translation for ${pathStr} does not pass arguments ${missingArguments.join(' ')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function theRule(context) {
|
||||
// we get the locale/translations via the options; it's the data
|
||||
// that goes into a specific language's JSON file, see
|
||||
// `scripts/build-assets.mjs`
|
||||
const locale = context.options[0];
|
||||
|
||||
// for all object member access that have an identifier 'i18n'...
|
||||
return context.getSourceCode().parserServices.defineTemplateBodyVisitor(
|
||||
{
|
||||
// this is for <template> bits, needs work
|
||||
'MemberExpression:has(Identifier[name=i18n])': (node) => theRuleBody(context, node),
|
||||
},
|
||||
{
|
||||
// this is for normal code
|
||||
'MemberExpression:has(Identifier[name=i18n])': (node) => theRuleBody(context, node),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'assert that all translations used are present in the locale files',
|
||||
},
|
||||
schema: [
|
||||
// here we declare that we need the locale/translation as a
|
||||
// generic object
|
||||
{ type: 'object', additionalProperties: true },
|
||||
],
|
||||
},
|
||||
create: theRule,
|
||||
};
|
||||
54
eslint/locale.test.js
Normal file
54
eslint/locale.test.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: dakkar and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
const {RuleTester} = require("eslint");
|
||||
const localeRule = require("./locale");
|
||||
|
||||
const locale = { foo: { bar: 'ok', baz: 'good {x}' }, top: '123' };
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: require('vue-eslint-parser'),
|
||||
ecmaVersion: 2015,
|
||||
},
|
||||
});
|
||||
|
||||
function testCase(code,errors) {
|
||||
return { code, errors, options: [ locale ], filename: 'test.ts' };
|
||||
}
|
||||
function testCaseVue(code,errors) {
|
||||
return { code, errors, options: [ locale ], filename: 'test.vue' };
|
||||
}
|
||||
|
||||
ruleTester.run(
|
||||
'sharkey-locale',
|
||||
localeRule,
|
||||
{
|
||||
valid: [
|
||||
testCase('i18n.ts.foo.bar'),
|
||||
testCase('i18n.ts.top'),
|
||||
testCase('i18n.tsx.foo.baz({x:1})'),
|
||||
testCase('whatever.i18n.ts.blah.blah'),
|
||||
testCase('whatever.i18n.tsx.does.not.matter'),
|
||||
testCase('whatever(i18n.ts.foo.bar)'),
|
||||
testCaseVue('<template><p>{{ i18n.ts.foo.bar }}</p></template>'),
|
||||
testCaseVue('<template><I18n :src="i18n.ts.foo.baz"/></template>'),
|
||||
// we don't detect the problem here, but should still accept it
|
||||
testCase('i18n.ts.foo["something"]'),
|
||||
testCase('i18n.ts.foo[something]'),
|
||||
],
|
||||
invalid: [
|
||||
testCase('i18n.ts.not', 1),
|
||||
testCase('i18n.tsx.deep.not', 1),
|
||||
testCase('i18n.tsx.deep.not({x:12})', 1),
|
||||
testCase('i18n.tsx.top({x:1})', 1),
|
||||
testCase('i18n.ts.foo.baz', 1),
|
||||
testCase('i18n.tsx.foo.baz', 1),
|
||||
testCase('i18n.tsx.foo.baz({y:2})', 2),
|
||||
testCaseVue('<template><p>{{ i18n.ts.not }}</p></template>', 1),
|
||||
testCaseVue('<template><I18n :src="i18n.ts.not"/></template>', 1),
|
||||
],
|
||||
},
|
||||
);
|
||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1733212471,
|
||||
"narHash": "sha256-M1+uCoV5igihRfcUKrr1riygbe73/dzNnzPsmaLCmpo=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "55d15ad12a74eb7d4646254e13638ad0c4128776",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
38
flake.nix
Normal file
38
flake.nix
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs = { self, nixpkgs }: {
|
||||
|
||||
nixosConfigurations.container = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules =
|
||||
[
|
||||
( import ./sharkey-service.nix )
|
||||
({ pkgs, ... }: {
|
||||
boot.isContainer = true;
|
||||
|
||||
# Let 'nixos-version --json' know about the Git revision
|
||||
# of this flake.
|
||||
system.configurationRevision = nixpkgs.lib.mkIf (self ? rev) self.rev;
|
||||
|
||||
# Network configuration.
|
||||
networking.useDHCP = false;
|
||||
networking.firewall.allowedTCPPorts = [ 3000 ];
|
||||
|
||||
system.stateVersion = "24.04";
|
||||
|
||||
services.sharkey = {
|
||||
enable = true;
|
||||
package = (pkgs.callPackage ./sharkey.nix {});
|
||||
settings = {
|
||||
url = "https://sharkey.localhost";
|
||||
};
|
||||
redis.createLocally = true;
|
||||
database.createLocally = true;
|
||||
};
|
||||
})
|
||||
];
|
||||
};
|
||||
|
||||
};
|
||||
}
|
||||
|
|
@ -1252,7 +1252,6 @@ _theme:
|
|||
buttonBg: "خلفية الأزرار"
|
||||
buttonHoverBg: "خلفية الأزرار (عند التمرير فوقها)"
|
||||
inputBorder: "حواف حقل الإدخال"
|
||||
listItemHoverBg: "خلفية عناصر القائمة (عند التمرير فوقها)"
|
||||
driveFolderBg: "خلفية مجلد قرص التخزين"
|
||||
messageBg: "خلفية المحادثة"
|
||||
_sfx:
|
||||
|
|
|
|||
|
|
@ -1017,7 +1017,6 @@ _theme:
|
|||
buttonBg: "বাটনের পটভূমি"
|
||||
buttonHoverBg: "বাটনের পটভূমি (হভার)"
|
||||
inputBorder: "ইনপুট ফিল্ডের বর্ডার"
|
||||
listItemHoverBg: "লিস্ট আইটেমের পটভূমি (হোভার)"
|
||||
driveFolderBg: "ড্রাইভ ফোল্ডারের পটভূমি"
|
||||
wallpaperOverlay: "ওয়ালপেপার ওভারলে"
|
||||
badge: "ব্যাজ"
|
||||
|
|
|
|||
|
|
@ -453,6 +453,7 @@ totpDescription: "Escriu una contrasenya d'un sol us fent servir l'aplicació d'
|
|||
moderator: "Moderador/a"
|
||||
moderation: "Moderació"
|
||||
moderationNote: "Nota de moderació "
|
||||
moderationNoteDescription: "Pots escriure notes que es compartiran entre els moderadors."
|
||||
addModerationNote: "Afegir una nota de moderació "
|
||||
moderationLogs: "Registre de moderació "
|
||||
nUsersMentioned: "{n} usuaris mencionats"
|
||||
|
|
@ -1284,6 +1285,15 @@ unknownWebAuthnKey: "Passkey desconeguda"
|
|||
passkeyVerificationFailed: "La verificació a fallat"
|
||||
passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verificació de la passkey a estat correcta, però s'ha deshabilitat l'inici de sessió sense contrasenya."
|
||||
messageToFollower: "Missatge als meus seguidors"
|
||||
target: "Assumpte "
|
||||
testCaptchaWarning: "És una característica dissenyada per a la prova de CAPTCHA. <strong>No l'utilitzes en l'entorn real.</strong>"
|
||||
_abuseUserReport:
|
||||
forward: "Reenviar "
|
||||
forwardDescription: "Reenvia l'informe a una altra instància com un compte del sistema anònima."
|
||||
resolve: "Solució "
|
||||
accept: "Acceptar "
|
||||
reject: "Rebutjar"
|
||||
resolveTutorial: "Si l'informe és legítim selecciona \"Acceptar\" per resoldre'l positivament. Però si l'informe no és legítim selecciona \"Rebutjar\" per resoldre'l negativament."
|
||||
_delivery:
|
||||
status: "Estat d'entrega "
|
||||
stop: "Suspés"
|
||||
|
|
@ -1421,6 +1431,7 @@ _serverSettings:
|
|||
reactionsBufferingDescription: "Quan s'activa aquesta opció millora bastant el rendiment en recuperar les línies de temps reduint la càrrega de la base. Com a contrapunt, augmentarà l'ús de memòria de Redís. Desactiva aquesta opció en cas de tenir un servidor amb poca memòria o si tens problemes d'inestabilitat."
|
||||
inquiryUrl: "URL de consulta "
|
||||
inquiryUrlDescription: "Escriu adreça URL per al formulari de consulta per al mantenidor del servidor o una pàgina web amb el contacte d'informació."
|
||||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Si no es detecta activitat per part del moderador durant un període de temps, aquesta opció es desactiva automàticament per evitar el correu brossa."
|
||||
_accountMigration:
|
||||
moveFrom: "Migrar un altre compte a aquest"
|
||||
moveFromSub: "Crear un àlies per un altre compte"
|
||||
|
|
@ -1974,7 +1985,6 @@ _theme:
|
|||
buttonBg: "Fons botó "
|
||||
buttonHoverBg: "Fons botó (en passar-hi per sobre)"
|
||||
inputBorder: "Contorn del cap d'introducció "
|
||||
listItemHoverBg: "Fons dels elements d'una llista"
|
||||
driveFolderBg: "Fons de la carpeta Disc"
|
||||
wallpaperOverlay: "Superposició del fons de pantalla "
|
||||
badge: "Insígnia "
|
||||
|
|
@ -2520,6 +2530,8 @@ _moderationLogTypes:
|
|||
markSensitiveDriveFile: "Fitxer marcat com a sensible"
|
||||
unmarkSensitiveDriveFile: "S'ha tret la marca de sensible del fitxer"
|
||||
resolveAbuseReport: "Informe resolt"
|
||||
forwardAbuseReport: "Informe reenviat"
|
||||
updateAbuseReportNote: "Nota de moderació d'un informe actualitzat"
|
||||
createInvitation: "Crear codi d'invitació "
|
||||
createAd: "Anunci creat"
|
||||
deleteAd: "Anunci esborrat"
|
||||
|
|
|
|||
|
|
@ -1629,7 +1629,6 @@ _theme:
|
|||
buttonBg: "Pozadí tlačítka"
|
||||
buttonHoverBg: "Pozadí tlačítka (Hover)"
|
||||
inputBorder: "Ohraničení vstupního pole"
|
||||
listItemHoverBg: "Pozadí položky seznamu (Hover)"
|
||||
driveFolderBg: "Pozadí složky disku"
|
||||
wallpaperOverlay: "Překrytí tapety"
|
||||
badge: "Odznak"
|
||||
|
|
|
|||
|
|
@ -1784,7 +1784,6 @@ _theme:
|
|||
buttonBg: "Hintergrund von Schaltflächen"
|
||||
buttonHoverBg: "Hintergrund von Schaltflächen (Mouseover)"
|
||||
inputBorder: "Rahmen von Eingabefeldern"
|
||||
listItemHoverBg: "Hintergrund von Listeneinträgen (Mouseover)"
|
||||
driveFolderBg: "Hintergrund von Drive-Ordnern"
|
||||
wallpaperOverlay: "Hintergrundbild-Overlay"
|
||||
badge: "Wappen"
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ enterEmoji: "Enter an emoji"
|
|||
renote: "Renote"
|
||||
unrenote: "Remove renote"
|
||||
renoted: "Renoted."
|
||||
renotedToX: "Renote to {name}."
|
||||
renotedToX: "Renoted to {name}."
|
||||
cantRenote: "This post can't be renoted."
|
||||
cantReRenote: "A renote can't be renoted."
|
||||
quote: "Quote"
|
||||
|
|
@ -121,7 +121,6 @@ inChannelQuote: "Channel-only Quote"
|
|||
renoteToChannel: "Renote to channel"
|
||||
renoteToOtherChannel: "Renote to other channel"
|
||||
pinnedNote: "Pinned note"
|
||||
pinnedOnly: "Pinned"
|
||||
pinned: "Pin to profile"
|
||||
you: "You"
|
||||
clickToShow: "Click to show"
|
||||
|
|
@ -258,7 +257,6 @@ defaultValueIs: "Default: {value}"
|
|||
noCustomEmojis: "There are no emoji"
|
||||
noJobs: "There are no jobs"
|
||||
federating: "Federating"
|
||||
blockingYou: "Blocking you"
|
||||
blocked: "Blocked"
|
||||
suspended: "Suspended"
|
||||
all: "All"
|
||||
|
|
@ -456,6 +454,7 @@ totpDescription: "Use an authenticator app to enter one-time passwords"
|
|||
moderator: "Moderator"
|
||||
moderation: "Moderation"
|
||||
moderationNote: "Moderation note"
|
||||
moderationNoteDescription: "You can fill in notes that will be shared only among moderators."
|
||||
addModerationNote: "Add moderation note"
|
||||
moderationLogs: "Moderation logs"
|
||||
nUsersMentioned: "Mentioned by {n} users"
|
||||
|
|
@ -923,6 +922,7 @@ followersVisibility: "Visibility of followers"
|
|||
continueThread: "View thread continuation"
|
||||
deleteAccountConfirm: "This will irreversibly delete your account. Proceed?"
|
||||
incorrectPassword: "Incorrect password."
|
||||
incorrectTotp: "The one-time password is incorrect or has expired."
|
||||
voteConfirm: "Confirm your vote for \"{choice}\"?"
|
||||
hide: "Hide"
|
||||
useDrawerReactionPickerForMobile: "Display reaction picker as drawer on mobile"
|
||||
|
|
@ -1270,8 +1270,7 @@ alwaysConfirmFollow: "Always confirm when following"
|
|||
inquiry: "Contact"
|
||||
tryAgain: "Please try again later"
|
||||
confirmWhenRevealingSensitiveMedia: "Confirm when revealing sensitive media"
|
||||
sensitiveMediaRevealConfirm: "This media might be sensitive. Are you sure you want to reveal it?"
|
||||
warnExternalUrl: "Show warning when opening external URLs"
|
||||
sensitiveMediaRevealConfirm: "This might be a sensitive media. Are you sure to reveal?"
|
||||
createdLists: "Created lists"
|
||||
createdAntennas: "Created antennas"
|
||||
fromX: "From {x}"
|
||||
|
|
@ -1287,6 +1286,14 @@ unknownWebAuthnKey: "Unknown Passkey"
|
|||
passkeyVerificationFailed: "Passkey verification has failed."
|
||||
passkeyVerificationSucceededButPasswordlessLoginDisabled: "Passkey verification has succeeded but password-less login is disabled."
|
||||
messageToFollower: "Message to followers"
|
||||
target: "Target"
|
||||
_abuseUserReport:
|
||||
forward: "Forward"
|
||||
forwardDescription: "Forward the report to a remote server as an anonymous system account."
|
||||
resolve: "Resolve"
|
||||
accept: "Accept"
|
||||
reject: "Reject"
|
||||
resolveTutorial: "If the report is legitimate in content, select \"Accept\" to mark the case as resolved in the affirmative.\nIf the content of the report is not legitimate, select \"Reject\" to mark the case as resolved in the negative."
|
||||
_delivery:
|
||||
status: "Delivery status"
|
||||
stop: "Suspended"
|
||||
|
|
@ -1295,7 +1302,7 @@ _delivery:
|
|||
none: "Publishing"
|
||||
manuallySuspended: "Manually suspended"
|
||||
goneSuspended: "Server is suspended due to server deletion"
|
||||
autoSuspendedForNotResponding: "Server is suspended due to not responding"
|
||||
autoSuspendedForNotResponding: "Server is suspended due to no responding"
|
||||
_bubbleGame:
|
||||
howToPlay: "How to play"
|
||||
hold: "Hold"
|
||||
|
|
@ -1740,7 +1747,7 @@ _role:
|
|||
canManageAvatarDecorations: "Manage avatar decorations"
|
||||
driveCapacity: "Drive capacity"
|
||||
alwaysMarkNsfw: "Always mark files as NSFW"
|
||||
canUpdateBioMedia: "Allow to edit an icon or a banner image"
|
||||
canUpdateBioMedia: "Can edit an icon or a banner image"
|
||||
pinMax: "Maximum number of pinned notes"
|
||||
antennaMax: "Maximum number of antennas"
|
||||
wordMuteMax: "Maximum number of characters allowed in word mutes"
|
||||
|
|
@ -1977,7 +1984,6 @@ _theme:
|
|||
buttonBg: "Button background"
|
||||
buttonHoverBg: "Button background (Hover)"
|
||||
inputBorder: "Input field border"
|
||||
listItemHoverBg: "List item background (Hover)"
|
||||
driveFolderBg: "Drive folder background"
|
||||
wallpaperOverlay: "Wallpaper overlay"
|
||||
badge: "Badge"
|
||||
|
|
@ -2476,22 +2482,22 @@ _webhookSettings:
|
|||
reaction: "When receiving a reaction"
|
||||
mention: "When being mentioned"
|
||||
_systemEvents:
|
||||
abuseReport: "When received a new abuse report"
|
||||
abuseReportResolved: "When resolved abuse report"
|
||||
abuseReport: "When received a new report"
|
||||
abuseReportResolved: "When resolved report"
|
||||
userCreated: "When user is created"
|
||||
deleteConfirm: "Are you sure you want to delete the Webhook?"
|
||||
testRemarks: "Click the button to the right of the switch to send a test Webhook with dummy data."
|
||||
_abuseReport:
|
||||
_notificationRecipient:
|
||||
createRecipient: "Add a recipient for abuse reports"
|
||||
modifyRecipient: "Edit a recipient for abuse reports"
|
||||
createRecipient: "Add a recipient for reports"
|
||||
modifyRecipient: "Edit a recipient for reports"
|
||||
recipientType: "Notification type"
|
||||
_recipientType:
|
||||
mail: "Email"
|
||||
webhook: "Webhook"
|
||||
_captions:
|
||||
mail: "Send the email to moderators' email addresses when you receive abuse."
|
||||
webhook: "Send a notification to SystemWebhook when you receive or resolve abuse."
|
||||
mail: "Send the email to moderators' email addresses when you receive reports."
|
||||
webhook: "Send a notification to System Webhook when you receive or resolve reports."
|
||||
keywords: "Keywords"
|
||||
notifiedUser: "Users to notify"
|
||||
notifiedWebhook: "Webhook to use"
|
||||
|
|
@ -2524,6 +2530,8 @@ _moderationLogTypes:
|
|||
markSensitiveDriveFile: "File marked as sensitive"
|
||||
unmarkSensitiveDriveFile: "File unmarked as sensitive"
|
||||
resolveAbuseReport: "Report resolved"
|
||||
forwardAbuseReport: "Report forwarded"
|
||||
updateAbuseReportNote: "Moderation note of a report updated"
|
||||
createInvitation: "Invite generated"
|
||||
createAd: "Ad created"
|
||||
deleteAd: "Ad deleted"
|
||||
|
|
@ -2531,18 +2539,18 @@ _moderationLogTypes:
|
|||
createAvatarDecoration: "Avatar decoration created"
|
||||
updateAvatarDecoration: "Avatar decoration updated"
|
||||
deleteAvatarDecoration: "Avatar decoration deleted"
|
||||
unsetUserAvatar: "Unset this user's avatar"
|
||||
unsetUserBanner: "Unset this user's banner"
|
||||
createSystemWebhook: "Create SystemWebhook"
|
||||
updateSystemWebhook: "Update SystemWebhook"
|
||||
deleteSystemWebhook: "Delete SystemWebhook"
|
||||
createAbuseReportNotificationRecipient: "Create a recipient for abuse reports"
|
||||
updateAbuseReportNotificationRecipient: "Update recipients for abuse reports"
|
||||
deleteAbuseReportNotificationRecipient: "Delete a recipient for abuse reports"
|
||||
deleteAccount: "Delete the account"
|
||||
deletePage: "Delete the page"
|
||||
deleteFlash: "Delete Play"
|
||||
deleteGalleryPost: "Delete the gallery post"
|
||||
unsetUserAvatar: "User avatar unset"
|
||||
unsetUserBanner: "User banner unset"
|
||||
createSystemWebhook: "System Webhook created"
|
||||
updateSystemWebhook: "System Webhook updated"
|
||||
deleteSystemWebhook: "System Webhook deleted"
|
||||
createAbuseReportNotificationRecipient: "Recipient for reports created"
|
||||
updateAbuseReportNotificationRecipient: "Recipient for reports updated"
|
||||
deleteAbuseReportNotificationRecipient: "Recipient for reports deleted"
|
||||
deleteAccount: "Account deleted"
|
||||
deletePage: "Page deleted"
|
||||
deleteFlash: "Play deleted"
|
||||
deleteGalleryPost: "Gallery post deleted"
|
||||
_fileViewer:
|
||||
title: "File details"
|
||||
type: "File type"
|
||||
|
|
|
|||
|
|
@ -1915,7 +1915,6 @@ _theme:
|
|||
buttonBg: "Fondo de botón"
|
||||
buttonHoverBg: "Fondo de botón (hover)"
|
||||
inputBorder: "Borde de los campos de entrada"
|
||||
listItemHoverBg: "Fondo de elemento de listas (hover)"
|
||||
driveFolderBg: "Fondo de capeta del drive"
|
||||
wallpaperOverlay: "Transparencia del fondo de pantalla"
|
||||
badge: "Medalla"
|
||||
|
|
|
|||
|
|
@ -1701,7 +1701,6 @@ _theme:
|
|||
buttonBg: "Arrière-plan du bouton"
|
||||
buttonHoverBg: "Arrière-plan du bouton (survolé)"
|
||||
inputBorder: "Cadre de la zone de texte"
|
||||
listItemHoverBg: "Arrière-plan d'item de liste (survolé)"
|
||||
driveFolderBg: "Arrière-plan du dossier de disque"
|
||||
wallpaperOverlay: "Superposition de fond d'écran"
|
||||
badge: "Badge"
|
||||
|
|
|
|||
|
|
@ -57,8 +57,8 @@ function createMembers(record) {
|
|||
}
|
||||
|
||||
export default function generateDTS() {
|
||||
const sharkeyLocale = yaml.load(fs.readFileSync(`${__dirname}/../sharkey-locales/en-US.yml`, 'utf-8'));
|
||||
const misskeyLocale = yaml.load(fs.readFileSync(`${__dirname}/ja-JP.yml`, 'utf-8'));
|
||||
const sharkeyLocale = yaml.load(fs.readFileSync(`${__dirname}/../sharkey-locales/en-US.yml`, 'utf-8'));
|
||||
const misskeyLocale = yaml.load(fs.readFileSync(`${__dirname}/ja-JP.yml`, 'utf-8'));
|
||||
const locale = merge(misskeyLocale, sharkeyLocale);
|
||||
|
||||
const members = createMembers(locale);
|
||||
|
|
|
|||
|
|
@ -1924,7 +1924,6 @@ _theme:
|
|||
buttonBg: "Latar belakang tombol"
|
||||
buttonHoverBg: "Latar belakang tombol (Mengambang)"
|
||||
inputBorder: "Batas bidang masukan"
|
||||
listItemHoverBg: "Latar belakang daftar item (Mengambang)"
|
||||
driveFolderBg: "Latar belakang folder drive"
|
||||
wallpaperOverlay: "Lapisan wallpaper"
|
||||
badge: "Lencana"
|
||||
|
|
|
|||
128
locales/index.d.ts
vendored
128
locales/index.d.ts
vendored
|
|
@ -502,10 +502,6 @@ export interface Locale extends ILocale {
|
|||
* ピン留めされたノート
|
||||
*/
|
||||
"pinnedNote": string;
|
||||
/**
|
||||
* Pinned
|
||||
*/
|
||||
"pinnedOnly": string;
|
||||
/**
|
||||
* ピン留め
|
||||
*/
|
||||
|
|
@ -1050,10 +1046,6 @@ export interface Locale extends ILocale {
|
|||
* 連合中
|
||||
*/
|
||||
"federating": string;
|
||||
/**
|
||||
* Blocking you
|
||||
*/
|
||||
"blockingYou": string;
|
||||
/**
|
||||
* ブロック中
|
||||
*/
|
||||
|
|
@ -4375,6 +4367,10 @@ export interface Locale extends ILocale {
|
|||
* リモートサーバーのチャートを生成
|
||||
*/
|
||||
"enableChartsForFederatedInstances": string;
|
||||
/**
|
||||
* リモートサーバーの情報を取得
|
||||
*/
|
||||
"enableStatsForFederatedInstances": string;
|
||||
/**
|
||||
* ノートのアクションにクリップを追加
|
||||
*/
|
||||
|
|
@ -5111,10 +5107,6 @@ export interface Locale extends ILocale {
|
|||
* This media might be sensitive. Are you sure you want to reveal it?
|
||||
*/
|
||||
"sensitiveMediaRevealConfirm": string;
|
||||
/**
|
||||
* 外部URLを開く際に警告を表示する
|
||||
*/
|
||||
"warnExternalUrl": string;
|
||||
/**
|
||||
* 作成したリスト
|
||||
*/
|
||||
|
|
@ -5179,6 +5171,26 @@ export interface Locale extends ILocale {
|
|||
* 対象
|
||||
*/
|
||||
"target": string;
|
||||
/**
|
||||
* CAPTCHAのテストを目的とした機能です。<strong>本番環境で使用しないでください。</strong>
|
||||
*/
|
||||
"testCaptchaWarning": string;
|
||||
/**
|
||||
* 禁止ワード(ユーザーの名前)
|
||||
*/
|
||||
"prohibitedWordsForNameOfUser": string;
|
||||
/**
|
||||
* このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。
|
||||
*/
|
||||
"prohibitedWordsForNameOfUserDescription": string;
|
||||
/**
|
||||
* 変更しようとした名前に禁止された文字列が含まれています
|
||||
*/
|
||||
"yourNameContainsProhibitedWords": string;
|
||||
/**
|
||||
* 名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。
|
||||
*/
|
||||
"yourNameContainsProhibitedWordsDescription": string;
|
||||
"_abuseUserReport": {
|
||||
/**
|
||||
* 転送
|
||||
|
|
@ -5341,6 +5353,10 @@ export interface Locale extends ILocale {
|
|||
* オンにすると、このお知らせは通知されず、既読にする必要もなくなります。
|
||||
*/
|
||||
"silenceDescription": string;
|
||||
/**
|
||||
* New
|
||||
*/
|
||||
"new": string;
|
||||
};
|
||||
"_initialAccountSetting": {
|
||||
/**
|
||||
|
|
@ -5717,6 +5733,10 @@ export interface Locale extends ILocale {
|
|||
* Specify the URL of a web page that contains a contact form or the instance operators' contact information.
|
||||
*/
|
||||
"inquiryUrlDescription": string;
|
||||
/**
|
||||
* 一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。
|
||||
*/
|
||||
"thisSettingWillAutomaticallyOffWhenModeratorsInactive": string;
|
||||
/**
|
||||
* Logo URL
|
||||
*/
|
||||
|
|
@ -7769,10 +7789,6 @@ export interface Locale extends ILocale {
|
|||
* 入力ボックスの縁取り
|
||||
*/
|
||||
"inputBorder": string;
|
||||
/**
|
||||
* リスト項目の背景 (ホバー)
|
||||
*/
|
||||
"listItemHoverBg": string;
|
||||
/**
|
||||
* ドライブフォルダーの背景
|
||||
*/
|
||||
|
|
@ -8430,6 +8446,10 @@ export interface Locale extends ILocale {
|
|||
* アプリケーションにアクセス許可を与えるには、ログインが必要です。
|
||||
*/
|
||||
"pleaseLogin": string;
|
||||
/**
|
||||
* Allowed
|
||||
*/
|
||||
"allowed": string;
|
||||
};
|
||||
"_antennaSources": {
|
||||
/**
|
||||
|
|
@ -9641,6 +9661,10 @@ export interface Locale extends ILocale {
|
|||
* ロールタイムライン
|
||||
*/
|
||||
"roleTimeline": string;
|
||||
/**
|
||||
* Following
|
||||
*/
|
||||
"following": string;
|
||||
};
|
||||
};
|
||||
"_dialog": {
|
||||
|
|
@ -9741,6 +9765,14 @@ export interface Locale extends ILocale {
|
|||
* ユーザーが作成されたとき
|
||||
*/
|
||||
"userCreated": string;
|
||||
/**
|
||||
* モデレーターが一定期間非アクティブになったとき
|
||||
*/
|
||||
"inactiveModeratorsWarning": string;
|
||||
/**
|
||||
* モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき
|
||||
*/
|
||||
"inactiveModeratorsInvitationOnlyChanged": string;
|
||||
};
|
||||
/**
|
||||
* Webhookを削除しますか?
|
||||
|
|
@ -10583,6 +10615,30 @@ export interface Locale extends ILocale {
|
|||
* Mutuals
|
||||
*/
|
||||
"mutuals": string;
|
||||
/**
|
||||
* Private account
|
||||
*/
|
||||
"isLocked": string;
|
||||
/**
|
||||
* Administrator
|
||||
*/
|
||||
"isAdmin": string;
|
||||
/**
|
||||
* Bot user
|
||||
*/
|
||||
"isBot": string;
|
||||
/**
|
||||
* Open
|
||||
*/
|
||||
"open": string;
|
||||
/**
|
||||
* Destination address
|
||||
*/
|
||||
"emailDestination": string;
|
||||
/**
|
||||
* Date
|
||||
*/
|
||||
"date": string;
|
||||
/**
|
||||
* Quoted.
|
||||
*/
|
||||
|
|
@ -10916,6 +10972,38 @@ export interface Locale extends ILocale {
|
|||
* Severing all follow relations with {host} queued.
|
||||
*/
|
||||
"severAllFollowRelationsQueued": ParameterizedString<"host">;
|
||||
/**
|
||||
* Pending follow requests
|
||||
*/
|
||||
"pendingFollowRequests": string;
|
||||
/**
|
||||
* Show quotes
|
||||
*/
|
||||
"showQuotes": string;
|
||||
/**
|
||||
* Show replies
|
||||
*/
|
||||
"showReplies": string;
|
||||
/**
|
||||
* Show non-public
|
||||
*/
|
||||
"showNonPublicNotes": string;
|
||||
/**
|
||||
* Allow clicking on pop-up notifications
|
||||
*/
|
||||
"allowClickingNotifications": string;
|
||||
/**
|
||||
* Pinned
|
||||
*/
|
||||
"pinnedOnly": string;
|
||||
/**
|
||||
* Blocking you
|
||||
*/
|
||||
"blockingYou": string;
|
||||
/**
|
||||
* Show warning when opening external URLs
|
||||
*/
|
||||
"warnExternalUrl": string;
|
||||
"_mfm": {
|
||||
/**
|
||||
* This is not a widespread feature, it may not display properly on most other fedi software, including other Misskey forks
|
||||
|
|
@ -11286,6 +11374,14 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"trustThisDomain": string;
|
||||
};
|
||||
/**
|
||||
* Remote followers may have incomplete or outdated activity
|
||||
*/
|
||||
"remoteFollowersWarning": string;
|
||||
/**
|
||||
* Select a follow relationship...
|
||||
*/
|
||||
"selectFollowRelationship": string;
|
||||
}
|
||||
declare const locales: {
|
||||
[lang: string]: Locale;
|
||||
|
|
|
|||
|
|
@ -454,6 +454,7 @@ totpDescription: "Puoi autenticarti inserendo un codice OTP tramite la tua App d
|
|||
moderator: "Moderatore"
|
||||
moderation: "moderazione"
|
||||
moderationNote: "Promemoria di moderazione"
|
||||
moderationNoteDescription: "Puoi scrivere promemoria condivisi solo tra moderatori."
|
||||
addModerationNote: "Aggiungi promemoria di moderazione"
|
||||
moderationLogs: "Cronologia di moderazione"
|
||||
nUsersMentioned: "{n} profili ne parlano"
|
||||
|
|
@ -841,7 +842,7 @@ onlineStatus: "Stato di connessione"
|
|||
hideOnlineStatus: "Modalità invisibile"
|
||||
hideOnlineStatusDescription: "Attivando questa opzione potresti ridurre l'usabilità di alcune funzioni, come la ricerca."
|
||||
online: "Online"
|
||||
active: "Attività"
|
||||
active: "Attivo"
|
||||
offline: "Offline"
|
||||
notRecommended: "Sconsigliato"
|
||||
botProtection: "Protezione contro i bot"
|
||||
|
|
@ -1086,6 +1087,7 @@ retryAllQueuesConfirmTitle: "Vuoi ritentare adesso?"
|
|||
retryAllQueuesConfirmText: "Potrebbe sovraccaricare il server temporaneamente."
|
||||
enableChartsForRemoteUser: "Abilita i grafici per i profili remoti"
|
||||
enableChartsForFederatedInstances: "Abilita i grafici per le istanze federate"
|
||||
enableStatsForFederatedInstances: "Informazioni statistiche sui server federati"
|
||||
showClipButtonInNoteFooter: "Aggiungi il bottone Clip tra le azioni delle Note"
|
||||
reactionsDisplaySize: "Grandezza delle reazioni"
|
||||
limitWidthOfReaction: "Limita la larghezza delle reazioni e ridimensionale"
|
||||
|
|
@ -1285,6 +1287,19 @@ unknownWebAuthnKey: "Questa è una passkey sconosciuta."
|
|||
passkeyVerificationFailed: "La verifica della passkey non è riuscita."
|
||||
passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verifica della passkey è riuscita, ma l'accesso senza password è disabilitato."
|
||||
messageToFollower: "Messaggio ai follower"
|
||||
target: "Riferimento"
|
||||
testCaptchaWarning: "Questa funzione è destinata al test CAPTCHA. <strong>Da non utilizzare in ambiente di produzione.</strong>"
|
||||
prohibitedWordsForNameOfUser: "Parole proibite (nome utente)"
|
||||
prohibitedWordsForNameOfUserDescription: "Il sistema rifiuta di rinominare un utente, se il nome contiene qualsiasi parola nell'elenco. Sono esenti i profili con privilegi di moderazione."
|
||||
yourNameContainsProhibitedWords: "Il nome che hai scelto contiene una o più parole vietate"
|
||||
yourNameContainsProhibitedWordsDescription: "Se desideri comunque utilizzare questo nome, contatta l''amministrazione."
|
||||
_abuseUserReport:
|
||||
forward: "Inoltra"
|
||||
forwardDescription: "Inoltra il report al server remoto, per mezzo di account di sistema, anonimo."
|
||||
resolve: "Risolvi"
|
||||
accept: "Approva"
|
||||
reject: "Rifiuta"
|
||||
resolveTutorial: "Se moderi una segnalazione legittima, scegli \"Approva\" per risolvere positivamente.\nSe la segnalazione non è legittima, seleziona \"Rifiuta\" per risolvere negativamente."
|
||||
_delivery:
|
||||
status: "Stato della consegna"
|
||||
stop: "Sospensione"
|
||||
|
|
@ -1312,16 +1327,16 @@ _bubbleGame:
|
|||
_announcement:
|
||||
forExistingUsers: "Solo ai profili attuali"
|
||||
forExistingUsersDescription: "L'annuncio sarà visibile solo ai profili esistenti in questo momento. Se disabilitato, sarà visibile anche ai profili che verranno creati dopo la pubblicazione di questo annuncio."
|
||||
needConfirmationToRead: "Richiede la conferma di lettura"
|
||||
needConfirmationToReadDescription: "Sarà visualizzata una finestra di dialogo che richiede la conferma di lettura. Inoltre, non è soggetto a conferme di lettura massicce."
|
||||
needConfirmationToRead: "Conferma di lettura obbligatoria"
|
||||
needConfirmationToReadDescription: "I profili riceveranno una finestra di dialogo che richiede di accettare obbligatoriamente per procedere. Tale richiesta è esente da \"conferma tutte\"."
|
||||
end: "Archivia l'annuncio"
|
||||
tooManyActiveAnnouncementDescription: "L'esperienza delle persone può peggiorare se ci sono troppi annunci attivi. Considera anche l'archiviazione degli annunci conclusi."
|
||||
readConfirmTitle: "Segnare come già letto?"
|
||||
readConfirmText: "Hai già letto \"{title}˝?"
|
||||
shouldNotBeUsedToPresentPermanentInfo: "Ti consigliamo di utilizzare gli annunci per pubblicare informazioni tempestive e limitate nel tempo, anziché informazioni importanti a lungo andare nel tempo, poiché potrebbero risultare difficili da ritrovare e peggiorare la fruibilità del servizio, specialmente alle nuove persone iscritte."
|
||||
dialogAnnouncementUxWarn: "Ti consigliamo di usarli con cautela, poiché è molto probabile che avere più di un annuncio in stile \"finestra di dialogo\" peggiori sensibilmente la fruibilità del servizio, specialmente alle nuove persone iscritte."
|
||||
silence: "Silenziare gli annunci"
|
||||
silenceDescription: "Se attivi questa opzione, non riceverai notifiche sugli annunci, evitando di contrassegnarle come già lette."
|
||||
silence: "Annuncio silenzioso"
|
||||
silenceDescription: "Attivando questa opzione, non invierai la notifica, evitando che debba essere contrassegnata come già letta."
|
||||
_initialAccountSetting:
|
||||
accountCreated: "Il tuo profilo è stato creato!"
|
||||
letsStartAccountSetup: "Per iniziare, impostiamo il tuo profilo."
|
||||
|
|
@ -1422,6 +1437,7 @@ _serverSettings:
|
|||
reactionsBufferingDescription: "Attivando questa opzione, puoi migliorare significativamente le prestazioni durante la creazione delle reazioni e ridurre il carico sul database. Tuttavia, aumenterà l'impiego di memoria Redis."
|
||||
inquiryUrl: "URL di contatto"
|
||||
inquiryUrlDescription: "Specificare l'URL al modulo di contatto, oppure le informazioni con i dati di contatto dell'amministrazione."
|
||||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Per prevenire SPAM, questa impostazione verrà disattivata automaticamente, se non si rileva alcuna attività di moderazione durante un certo periodo di tempo."
|
||||
_accountMigration:
|
||||
moveFrom: "Migra un altro profilo dentro a questo"
|
||||
moveFromSub: "Crea un alias verso un altro profilo remoto"
|
||||
|
|
@ -1975,7 +1991,6 @@ _theme:
|
|||
buttonBg: "Sfondo del pulsante"
|
||||
buttonHoverBg: "Sfondo del pulsante (sorvolato)"
|
||||
inputBorder: "Inquadra casella di testo"
|
||||
listItemHoverBg: "Sfondo della voce di elenco (sorvolato)"
|
||||
driveFolderBg: "Sfondo della cartella di disco"
|
||||
wallpaperOverlay: "Sovrapposizione dello sfondo"
|
||||
badge: "Distintivo"
|
||||
|
|
@ -2188,7 +2203,7 @@ _widgets:
|
|||
_userList:
|
||||
chooseList: "Seleziona una lista"
|
||||
clicker: "Cliccaggio"
|
||||
birthdayFollowings: "Chi nacque oggi"
|
||||
birthdayFollowings: "Compleanni del giorno"
|
||||
_cw:
|
||||
hide: "Nascondere"
|
||||
show: "Continua la lettura..."
|
||||
|
|
@ -2477,6 +2492,8 @@ _webhookSettings:
|
|||
abuseReport: "Quando arriva una segnalazione"
|
||||
abuseReportResolved: "Quando una segnalazione è risolta"
|
||||
userCreated: "Quando viene creato un profilo"
|
||||
inactiveModeratorsWarning: "Quando un profilo moderatore rimane inattivo per un determinato periodo"
|
||||
inactiveModeratorsInvitationOnlyChanged: "Quando la moderazione è rimasta inattiva per un determinato periodo e il sistema è cambiato in modalità \"solo inviti\""
|
||||
deleteConfirm: "Vuoi davvero eliminare il Webhook?"
|
||||
testRemarks: "Clicca il bottone a destra dell'interruttore, per provare l'invio di un webhook con dati fittizi."
|
||||
_abuseReport:
|
||||
|
|
@ -2522,6 +2539,8 @@ _moderationLogTypes:
|
|||
markSensitiveDriveFile: "File nel Drive segnato come esplicito"
|
||||
unmarkSensitiveDriveFile: "File nel Drive segnato come non esplicito"
|
||||
resolveAbuseReport: "Segnalazione risolta"
|
||||
forwardAbuseReport: "Segnalazione inoltrata"
|
||||
updateAbuseReportNote: "Ha aggiornato la segnalazione"
|
||||
createInvitation: "Genera codice di invito"
|
||||
createAd: "Banner creato"
|
||||
deleteAd: "Banner eliminato"
|
||||
|
|
|
|||
|
|
@ -121,7 +121,6 @@ inChannelQuote: "チャンネル内引用"
|
|||
renoteToChannel: "チャンネルにリノート"
|
||||
renoteToOtherChannel: "他のチャンネルにリノート"
|
||||
pinnedNote: "ピン留めされたノート"
|
||||
pinnedOnly: "Pinned"
|
||||
pinned: "ピン留め"
|
||||
you: "あなた"
|
||||
clickToShow: "クリックして表示"
|
||||
|
|
@ -258,7 +257,6 @@ defaultValueIs: "デフォルト: {value}"
|
|||
noCustomEmojis: "絵文字はありません"
|
||||
noJobs: "ジョブはありません"
|
||||
federating: "連合中"
|
||||
blockingYou: "Blocking you"
|
||||
blocked: "ブロック中"
|
||||
suspended: "配信停止"
|
||||
all: "全て"
|
||||
|
|
@ -1089,6 +1087,7 @@ retryAllQueuesConfirmTitle: "今すぐ再試行しますか?"
|
|||
retryAllQueuesConfirmText: "一時的にサーバーの負荷が増大することがあります。"
|
||||
enableChartsForRemoteUser: "リモートユーザーのチャートを生成"
|
||||
enableChartsForFederatedInstances: "リモートサーバーのチャートを生成"
|
||||
enableStatsForFederatedInstances: "リモートサーバーの情報を取得"
|
||||
showClipButtonInNoteFooter: "ノートのアクションにクリップを追加"
|
||||
reactionsDisplaySize: "リアクションの表示サイズ"
|
||||
limitWidthOfReaction: "リアクションの最大横幅を制限し、縮小して表示する"
|
||||
|
|
@ -1273,7 +1272,6 @@ inquiry: "お問い合わせ"
|
|||
tryAgain: "もう一度お試しください。"
|
||||
confirmWhenRevealingSensitiveMedia: "センシティブなメディアを表示するとき確認する"
|
||||
sensitiveMediaRevealConfirm: "センシティブなメディアです。表示しますか?"
|
||||
warnExternalUrl: "外部URLを開く際に警告を表示する"
|
||||
createdLists: "作成したリスト"
|
||||
createdAntennas: "作成したアンテナ"
|
||||
fromX: "{x}から"
|
||||
|
|
@ -1290,6 +1288,11 @@ passkeyVerificationFailed: "パスキーの検証に失敗しました。"
|
|||
passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。"
|
||||
messageToFollower: "フォロワーへのメッセージ"
|
||||
target: "対象"
|
||||
testCaptchaWarning: "CAPTCHAのテストを目的とした機能です。<strong>本番環境で使用しないでください。</strong>"
|
||||
prohibitedWordsForNameOfUser: "禁止ワード(ユーザーの名前)"
|
||||
prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。"
|
||||
yourNameContainsProhibitedWords: "変更しようとした名前に禁止された文字列が含まれています"
|
||||
yourNameContainsProhibitedWordsDescription: "名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。"
|
||||
|
||||
_abuseUserReport:
|
||||
forward: "転送"
|
||||
|
|
@ -1443,6 +1446,7 @@ _serverSettings:
|
|||
reactionsBufferingDescription: "有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。"
|
||||
inquiryUrl: "問い合わせ先URL"
|
||||
inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。"
|
||||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。"
|
||||
|
||||
_accountMigration:
|
||||
moveFrom: "別のアカウントからこのアカウントに移行"
|
||||
|
|
@ -2021,7 +2025,6 @@ _theme:
|
|||
buttonBg: "ボタンの背景"
|
||||
buttonHoverBg: "ボタンの背景 (ホバー)"
|
||||
inputBorder: "入力ボックスの縁取り"
|
||||
listItemHoverBg: "リスト項目の背景 (ホバー)"
|
||||
driveFolderBg: "ドライブフォルダーの背景"
|
||||
wallpaperOverlay: "壁紙のオーバーレイ"
|
||||
badge: "バッジ"
|
||||
|
|
@ -2556,6 +2559,8 @@ _webhookSettings:
|
|||
abuseReport: "ユーザーから通報があったとき"
|
||||
abuseReportResolved: "ユーザーからの通報を処理したとき"
|
||||
userCreated: "ユーザーが作成されたとき"
|
||||
inactiveModeratorsWarning: "モデレーターが一定期間非アクティブになったとき"
|
||||
inactiveModeratorsInvitationOnlyChanged: "モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき"
|
||||
deleteConfirm: "Webhookを削除しますか?"
|
||||
testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。"
|
||||
|
||||
|
|
|
|||
|
|
@ -1943,7 +1943,6 @@ _theme:
|
|||
buttonBg: "ボタンの背景"
|
||||
buttonHoverBg: "ボタンの背景 (ホバー)"
|
||||
inputBorder: "入力ボックスの縁取り"
|
||||
listItemHoverBg: "リスト項目の背景 (ホバー)"
|
||||
driveFolderBg: "ドライブフォルダーの背景"
|
||||
wallpaperOverlay: "壁紙のオーバーレイ"
|
||||
badge: "バッジ"
|
||||
|
|
|
|||
|
|
@ -601,8 +601,6 @@ reportAbuseOf: "{name}님얼 신고하기"
|
|||
reporter: "신고한 사람"
|
||||
reporteeOrigin: "신고덴 사람"
|
||||
reporterOrigin: "신고한 곳"
|
||||
forwardReport: "웬겍 서버에 신고 보내기"
|
||||
forwardReportIsAnonymous: "웬겍 서버서는 나으 정보럴 몬 보고 익멩으 시스템 게정어로 보입니다."
|
||||
waitingFor: "{x}(얼)럴 지달리고 잇십니다"
|
||||
random: "무작이"
|
||||
system: "시스템"
|
||||
|
|
|
|||
|
|
@ -1087,6 +1087,7 @@ retryAllQueuesConfirmTitle: "지금 다시 시도하시겠습니까?"
|
|||
retryAllQueuesConfirmText: "일시적으로 서버의 부하가 증가할 수 있습니다."
|
||||
enableChartsForRemoteUser: "리모트 유저의 차트를 생성"
|
||||
enableChartsForFederatedInstances: "리모트 서버의 차트를 생성"
|
||||
enableStatsForFederatedInstances: "리모트 서버 정보 받아오기"
|
||||
showClipButtonInNoteFooter: "노트 동작에 클립을 추가"
|
||||
reactionsDisplaySize: "리액션 표시 크기"
|
||||
limitWidthOfReaction: "리액션의 최대 폭을 제한하고 작게 표시하기"
|
||||
|
|
@ -1122,7 +1123,7 @@ preservedUsernames: "예약한 사용자 이름"
|
|||
preservedUsernamesDescription: "예약할 사용자명을 한 줄에 하나씩 입력합니다. 여기에서 지정한 사용자명으로는 계정을 생성할 수 없게 됩니다. 단, 관리자 권한으로 계정을 생성할 때에는 해당되지 않으며, 이미 존재하는 계정도 영향을 받지 않습니다."
|
||||
createNoteFromTheFile: "이 파일로 노트를 작성"
|
||||
archive: "아카이브"
|
||||
archived: "보관됨"
|
||||
archived: "아카이브 됨"
|
||||
unarchive: "보관 취소"
|
||||
channelArchiveConfirmTitle: "{name} 채널을 보존하시겠습니까?"
|
||||
channelArchiveConfirmDescription: "보존한 채널은 채널 목록과 검색 결과에 표시되지 않으며 새로운 노트도 작성할 수 없습니다."
|
||||
|
|
@ -1287,6 +1288,11 @@ passkeyVerificationFailed: "패스키 검증을 실패했습니다."
|
|||
passkeyVerificationSucceededButPasswordlessLoginDisabled: "패스키를 검증했으나, 비밀번호 없이 로그인하기가 꺼져 있습니다."
|
||||
messageToFollower: "팔로워에 보낼 메시지"
|
||||
target: "대상"
|
||||
testCaptchaWarning: "CAPTCHA를 테스트하기 위한 기능입니다. <strong>실제 환경에서는 사용하지 마세요.</strong>"
|
||||
prohibitedWordsForNameOfUser: "금지 단어 (사용자 이름)"
|
||||
prohibitedWordsForNameOfUserDescription: "이 목록에 포함되는 키워드가 사용자 이름에 있는 경우, 일반 사용자는 이름을 바꿀 수 없습니다. 모더레이터 권한을 가진 사용자는 제한 대상에서 제외됩니다."
|
||||
yourNameContainsProhibitedWords: "바꾸려는 이름에 금지된 키워드가 포함되어 있습니다."
|
||||
yourNameContainsProhibitedWordsDescription: "이름에 금지된 키워드가 있습니다. 이름을 사용해야 하는 경우, 서버 관리자에 문의하세요."
|
||||
_abuseUserReport:
|
||||
forward: "전달"
|
||||
forwardDescription: "익명 시스템 계정을 사용하여 리모트 서버에 신고 내용을 전달할 수 있습니다."
|
||||
|
|
@ -1431,6 +1437,7 @@ _serverSettings:
|
|||
reactionsBufferingDescription: "활성화 한 경우, 리액션 작성 퍼포먼스가 대폭 향상되어 DB의 부하를 줄일 수 있으나, Redis의 메모리 사용량이 많아집니다."
|
||||
inquiryUrl: "문의처 URL"
|
||||
inquiryUrlDescription: "서버 운영자에게 보내는 문의 양식의 URL이나 운영자의 연락처 등이 적힌 웹 페이지의 URL을 설정합니다."
|
||||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "일정 기간동안 모더레이터의 활동이 감지되지 않는 경우, 스팸 방지를 위해 이 설정은 자동으로 꺼집니다."
|
||||
_accountMigration:
|
||||
moveFrom: "다른 계정에서 이 계정으로 이사"
|
||||
moveFromSub: "다른 계정에 대한 별칭을 생성"
|
||||
|
|
@ -1984,7 +1991,6 @@ _theme:
|
|||
buttonBg: "버튼 배경"
|
||||
buttonHoverBg: "버튼 배경 (호버)"
|
||||
inputBorder: "입력 필드 테두리"
|
||||
listItemHoverBg: "리스트 항목 배경 (호버)"
|
||||
driveFolderBg: "드라이브 폴더 배경"
|
||||
wallpaperOverlay: "배경화면 오버레이"
|
||||
badge: "배지"
|
||||
|
|
@ -2486,6 +2492,8 @@ _webhookSettings:
|
|||
abuseReport: "유저롭"
|
||||
abuseReportResolved: "받은 신고를 처리했을 때"
|
||||
userCreated: "유저가 생성되었을 때"
|
||||
inactiveModeratorsWarning: "모더레이터가 일정 기간동안 활동하지 않은 경우"
|
||||
inactiveModeratorsInvitationOnlyChanged: "모더레이터가 일정 기간 활동하지 않아 시스템에 의해 초대제로 바뀐 경우"
|
||||
deleteConfirm: "Webhook을 삭제할까요?"
|
||||
testRemarks: "스위치 오른쪽에 있는 버튼을 클릭하여 더미 데이터를 사용한 테스트용 웹 훅을 보낼 수 있습니다."
|
||||
_abuseReport:
|
||||
|
|
|
|||
|
|
@ -1205,7 +1205,6 @@ _theme:
|
|||
buttonBg: "Tło przycisku"
|
||||
buttonHoverBg: "Tło przycisku (po najechaniu)"
|
||||
inputBorder: "Obramowanie pola wejścia"
|
||||
listItemHoverBg: "Tło elementu listy (po najechaniu)"
|
||||
driveFolderBg: "Tło folderu na dysku"
|
||||
wallpaperOverlay: "Nakładka tapety"
|
||||
badge: "Odznaka"
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ basicSettings: "Configurações básicas"
|
|||
otherSettings: "Outras configurações"
|
||||
openInWindow: "Abrir em um janela"
|
||||
profile: "Perfil"
|
||||
timeline: "Cronologia"
|
||||
timeline: "Linha do tempo"
|
||||
noAccountDescription: "Este usuário não tem uma descrição."
|
||||
login: "Iniciar sessão"
|
||||
loggingIn: "Iniciando sessão…"
|
||||
|
|
@ -1058,7 +1058,7 @@ resetPasswordConfirm: "Deseja realmente mudar a sua senha?"
|
|||
sensitiveWords: "Palavras sensíveis"
|
||||
sensitiveWordsDescription: "A visibilidade de todas as notas contendo as palavras configuradas será colocadas como \"Início\" automaticamente. Você pode listar várias delas separando-as por linha."
|
||||
sensitiveWordsDescription2: "Utilizar espaços irá criar expressões aditivas (AND) e cercar palavras-chave com barras irá transformá-las em expressões regulares (RegEx)"
|
||||
prohibitedWords: "Palavras proibídas"
|
||||
prohibitedWords: "Palavras proibidas"
|
||||
prohibitedWordsDescription: "Habilita um erro ao tentar publicar uma nota contendo as palavras escolhidas. Várias palavras podem ser escolhidas, separando-as por linha."
|
||||
prohibitedWordsDescription2: "Utilizar espaços irá criar expressões aditivas (AND) e cercar palavras-chave com barras irá transformá-las em expressões regulares (RegEx)"
|
||||
hiddenTags: "Hashtags escondidas"
|
||||
|
|
@ -1416,7 +1416,7 @@ _achievements:
|
|||
_types:
|
||||
_notes1:
|
||||
title: "Configurando o meu misskey"
|
||||
description: "Post uma nota pela primeira vez"
|
||||
description: "Poste uma nota pela primeira vez"
|
||||
flavor: "Divirta-se com o Misskey!"
|
||||
_notes10:
|
||||
title: "Algumas notas"
|
||||
|
|
@ -1944,7 +1944,6 @@ _theme:
|
|||
buttonBg: "Plano de fundo de botão"
|
||||
buttonHoverBg: "Plano de fundo de botão (Selecionado)"
|
||||
inputBorder: "Borda de campo digitável"
|
||||
listItemHoverBg: "Plano de fundo do item de uma lista (Selecionado)"
|
||||
driveFolderBg: "Plano de fundo da pasta no Drive"
|
||||
wallpaperOverlay: "Sobreposição do papel de parede."
|
||||
badge: "Emblema"
|
||||
|
|
|
|||
|
|
@ -1694,7 +1694,6 @@ _theme:
|
|||
buttonBg: "Фон кнопки"
|
||||
buttonHoverBg: "Текст кнопки"
|
||||
inputBorder: "Рамка поля ввода"
|
||||
listItemHoverBg: "Фон пункта списка (под указателем)"
|
||||
driveFolderBg: "Фон папки «Диска»"
|
||||
wallpaperOverlay: "Слой обоев"
|
||||
badge: "Значок"
|
||||
|
|
|
|||
|
|
@ -1108,7 +1108,6 @@ _theme:
|
|||
buttonBg: "Pozadie tlačidla"
|
||||
buttonHoverBg: "Pozadie tlačidla (pod kurzorom)"
|
||||
inputBorder: "Okraj vstupného poľa"
|
||||
listItemHoverBg: "Pozadie položky zoznamu (pod kurzorom)"
|
||||
driveFolderBg: "Pozadie priečinu disku"
|
||||
wallpaperOverlay: "Vrstvenie pozadia"
|
||||
badge: "Odznak"
|
||||
|
|
|
|||
|
|
@ -1943,7 +1943,6 @@ _theme:
|
|||
buttonBg: "ปุ่มพื้นหลัง"
|
||||
buttonHoverBg: "ปุ่มพื้นหลัง (โฮเวอร์)"
|
||||
inputBorder: "เส้นขอบของช่องป้อนข้อมูล"
|
||||
listItemHoverBg: "รายการไอเทมพื้นหลัง (โฮเวอร์)"
|
||||
driveFolderBg: "พื้นหลังโฟลเดอร์ไดรฟ์"
|
||||
wallpaperOverlay: "วอลล์เปเปอร์ซ้อนทับ"
|
||||
badge: "ตรา"
|
||||
|
|
|
|||
|
|
@ -1302,7 +1302,6 @@ _theme:
|
|||
buttonBg: "Фон кнопки"
|
||||
buttonHoverBg: "Фон кнопки (при наведенні)"
|
||||
inputBorder: "Край поля вводу"
|
||||
listItemHoverBg: "Фон елементу в списку (при наведенні)"
|
||||
driveFolderBg: "Фон папки на диску"
|
||||
wallpaperOverlay: "Накладання шпалер"
|
||||
badge: "Значок"
|
||||
|
|
|
|||
1
locales/version.d.ts
vendored
Normal file
1
locales/version.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const localesVersion: string;
|
||||
14
locales/version.js
Normal file
14
locales/version.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { createHash } from 'crypto';
|
||||
import locales from './index.js';
|
||||
|
||||
// MD5 is acceptable because we don't need cryptographic security.
|
||||
const hash = createHash('md5');
|
||||
|
||||
// Derive the version hash from locale content exclusively.
|
||||
// This avoids the problem of "stuck" translations after modifying locale files.
|
||||
const localesText = JSON.stringify(locales);
|
||||
hash.update(localesText, 'utf8');
|
||||
|
||||
// We can't use regular base64 since this becomes part of a filename.
|
||||
// Base64URL avoids special characters that would cause an issue.
|
||||
export const localesVersion = hash.digest().toString('base64url');
|
||||
|
|
@ -1546,7 +1546,6 @@ _theme:
|
|||
buttonBg: "Nền nút"
|
||||
buttonHoverBg: "Nền nút (Chạm)"
|
||||
inputBorder: "Đường viền khung soạn thảo"
|
||||
listItemHoverBg: "Nền mục liệt kê (Chạm)"
|
||||
driveFolderBg: "Nền thư mục Ổ đĩa"
|
||||
wallpaperOverlay: "Lớp phủ hình nền"
|
||||
badge: "Huy hiệu"
|
||||
|
|
|
|||
|
|
@ -1087,6 +1087,7 @@ retryAllQueuesConfirmTitle: "要再尝试一次吗?"
|
|||
retryAllQueuesConfirmText: "可能会使服务器负荷在一定时间内增加"
|
||||
enableChartsForRemoteUser: "生成远程用户的图表"
|
||||
enableChartsForFederatedInstances: "生成远程服务器的图表"
|
||||
enableStatsForFederatedInstances: "获取远程服务器的信息"
|
||||
showClipButtonInNoteFooter: "在贴文下方显示便签按钮"
|
||||
reactionsDisplaySize: "回应显示大小"
|
||||
limitWidthOfReaction: "限制回应的最大宽度,并将其缩小显示"
|
||||
|
|
@ -1199,10 +1200,10 @@ followingOrFollower: "关注中或关注者"
|
|||
fileAttachedOnly: "仅限媒体"
|
||||
showRepliesToOthersInTimeline: "在时间线中包含给别人的回复"
|
||||
hideRepliesToOthersInTimeline: "在时间线中隐藏给别人的回复"
|
||||
showRepliesToOthersInTimelineAll: "在时间线中包含现在关注的所有人的回复"
|
||||
hideRepliesToOthersInTimelineAll: "在时间线中隐藏现在关注的所有人的回复"
|
||||
confirmShowRepliesAll: "此操作不可撤销。确认要在时间线中包含现在关注的所有人的回复吗?"
|
||||
confirmHideRepliesAll: "此操作不可撤销。确认要在时间线中隐藏现在关注的所有人的回复吗?"
|
||||
showRepliesToOthersInTimelineAll: "在时间线中显示所有现在关注的人的回复"
|
||||
hideRepliesToOthersInTimelineAll: "在时间线中隐藏所有现在关注的人的回复"
|
||||
confirmShowRepliesAll: "此操作不可撤销。确认要在时间线中显示所有现在关注的人的回复吗?"
|
||||
confirmHideRepliesAll: "此操作不可撤销。确认要在时间线中隐藏所有现在关注的人的回复吗?"
|
||||
externalServices: "外部服务"
|
||||
sourceCode: "源代码"
|
||||
sourceCodeIsNotYetProvided: "还未提供源代码。要解决此问题请联系管理员。"
|
||||
|
|
@ -1287,9 +1288,18 @@ passkeyVerificationFailed: "验证通行密钥失败。"
|
|||
passkeyVerificationSucceededButPasswordlessLoginDisabled: "通行密钥验证成功,但账户未开启无密码登录。"
|
||||
messageToFollower: "给关注者的消息"
|
||||
target: "对象"
|
||||
testCaptchaWarning: "此功能为测试 CAPTCHA 用。<strong>请勿在正式环境中使用。</strong>"
|
||||
prohibitedWordsForNameOfUser: "用户名中禁止的词"
|
||||
prohibitedWordsForNameOfUserDescription: "更改用户名时,如果用户名中包含此列表里的词汇,用户的改名请求将被拒绝。持有管理员权限的用户不受此限制。"
|
||||
yourNameContainsProhibitedWords: "目标用户名包含违禁词"
|
||||
yourNameContainsProhibitedWordsDescription: "用户名内含有违禁词。若想使用此用户名,请联系服务器管理员。"
|
||||
_abuseUserReport:
|
||||
forward: "转发"
|
||||
forwardDescription: "目标是匿名系统账户,将把举报转发给远程服务器。"
|
||||
resolve: "解决"
|
||||
accept: "确认"
|
||||
reject: "拒绝"
|
||||
resolveTutorial: "如果举报内容有理且已解决,选择「确认」将案件以肯定的态度标记为已解决。\n如果举报内容站不住脚,选择「拒绝」将案件以否定的态度标记为已解决。"
|
||||
_delivery:
|
||||
status: "投递状态"
|
||||
stop: "停止投递"
|
||||
|
|
@ -1427,6 +1437,7 @@ _serverSettings:
|
|||
reactionsBufferingDescription: "开启时可显著提高发送回应时的性能,及减轻数据库负荷。但 Redis 的内存用量会相应增加。"
|
||||
inquiryUrl: "联络地址"
|
||||
inquiryUrlDescription: "用来指定诸如向服务运营商咨询的论坛地址,或记载了运营商联系方式之类的网页地址。"
|
||||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "若在一段时间内没有检测到管理活动,为防止垃圾信息,此设定将自动关闭。"
|
||||
_accountMigration:
|
||||
moveFrom: "从别的账号迁移到此账户"
|
||||
moveFromSub: "为另一个账户建立别名"
|
||||
|
|
@ -1626,7 +1637,7 @@ _achievements:
|
|||
_postedAt0min0sec:
|
||||
title: "报时"
|
||||
description: "在 0 点发布一篇帖子"
|
||||
flavor: "报时信号最后一响,零点整"
|
||||
flavor: "嘟 · 嘟 · 嘟 · 哔——"
|
||||
_selfQuote:
|
||||
title: "自我引用"
|
||||
description: "引用了自己的帖子"
|
||||
|
|
@ -1980,7 +1991,6 @@ _theme:
|
|||
buttonBg: "按钮背景"
|
||||
buttonHoverBg: "按钮背景(悬停)"
|
||||
inputBorder: "输入框边框"
|
||||
listItemHoverBg: "下拉列表项目背景(悬停)"
|
||||
driveFolderBg: "网盘的文件夹背景"
|
||||
wallpaperOverlay: "壁纸叠加层"
|
||||
badge: "徽章"
|
||||
|
|
@ -2259,7 +2269,7 @@ _profile:
|
|||
avatarDecorationMax: "最多可添加 {max} 个挂件"
|
||||
followedMessage: "被关注时显示的消息"
|
||||
followedMessageDescription: "可以设置被关注时向对方显示的短消息。"
|
||||
followedMessageDescriptionForLockedAccount: "需要批准才能关注的情况下,消息是在被请求被批准后显示。"
|
||||
followedMessageDescriptionForLockedAccount: "需要批准才能关注的情况下,消息是在请求被批准后显示。"
|
||||
_exportOrImport:
|
||||
allNotes: "所有帖子"
|
||||
favoritedNotes: "收藏的帖子"
|
||||
|
|
@ -2482,6 +2492,8 @@ _webhookSettings:
|
|||
abuseReport: "当收到举报时"
|
||||
abuseReportResolved: "当举报被处理时"
|
||||
userCreated: "当用户被创建时"
|
||||
inactiveModeratorsWarning: "当管理员在一段时间内不活跃时"
|
||||
inactiveModeratorsInvitationOnlyChanged: "当因为管理员在一段时间内不活跃,导致服务器变为邀请制时"
|
||||
deleteConfirm: "要删除 webhook 吗?"
|
||||
testRemarks: "点击开关右侧的按钮,可以发送使用假数据的测试 Webhook。"
|
||||
_abuseReport:
|
||||
|
|
|
|||
|
|
@ -454,6 +454,7 @@ totpDescription: "以驗證應用程式輸入一次性密碼"
|
|||
moderator: "審查員"
|
||||
moderation: "審查"
|
||||
moderationNote: "管理筆記"
|
||||
moderationNoteDescription: "您可以編寫僅在審查員之間共用的註解。"
|
||||
addModerationNote: "新增管理筆記"
|
||||
moderationLogs: "管理日誌"
|
||||
nUsersMentioned: "被 {n} 個人提及"
|
||||
|
|
@ -519,7 +520,7 @@ menuStyle: "選單風格"
|
|||
style: "風格"
|
||||
drawer: "側邊欄"
|
||||
popup: "彈出式視窗"
|
||||
showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的操作選項"
|
||||
showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的"
|
||||
showReactionsCount: "顯示貼文的反應數目"
|
||||
noHistory: "沒有歷史紀錄"
|
||||
signinHistory: "登入歷史"
|
||||
|
|
@ -1018,7 +1019,7 @@ show: "檢視"
|
|||
neverShow: "不再顯示"
|
||||
remindMeLater: "以後再說"
|
||||
didYouLikeMisskey: "您喜歡 Misskey 嗎?"
|
||||
pleaseDonate: "Misskey 是由 {host} 使用的免費軟體。請贊助我們,讓開發得以持續!"
|
||||
pleaseDonate: "Misskey是由{host}使用的免費軟體。請贊助我們,讓開發的工作能夠持續!"
|
||||
correspondingSourceIsAvailable: "對應的原始碼可以在 {anchor} 處找到。"
|
||||
roles: "角色"
|
||||
role: "角色"
|
||||
|
|
@ -1086,6 +1087,7 @@ retryAllQueuesConfirmTitle: "要現在重試嗎?"
|
|||
retryAllQueuesConfirmText: "伺服器的負荷可能會暫時增加。"
|
||||
enableChartsForRemoteUser: "生成遠端使用者的圖表"
|
||||
enableChartsForFederatedInstances: "生成遠端伺服器的圖表"
|
||||
enableStatsForFederatedInstances: "取得遠端伺服器資訊"
|
||||
showClipButtonInNoteFooter: "新增摘錄按鈕至貼文"
|
||||
reactionsDisplaySize: "反應的顯示尺寸"
|
||||
limitWidthOfReaction: "限制反應的最大寬度,並縮小顯示尺寸。"
|
||||
|
|
@ -1194,7 +1196,7 @@ showRenotes: "顯示其他人的轉發貼文"
|
|||
edited: "已編輯"
|
||||
notificationRecieveConfig: "接受通知的設定"
|
||||
mutualFollow: "互相追隨"
|
||||
followingOrFollower: "追隨中或追隨者"
|
||||
followingOrFollower: "追隨中或者追隨者"
|
||||
fileAttachedOnly: "只顯示包含附件的貼文"
|
||||
showRepliesToOthersInTimeline: "顯示給其他人的回覆"
|
||||
hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆"
|
||||
|
|
@ -1265,7 +1267,7 @@ useNativeUIForVideoAudioPlayer: "使用瀏覽器的 UI 播放影片與音訊"
|
|||
keepOriginalFilename: "保留原始檔名"
|
||||
keepOriginalFilenameDescription: "如果關閉此設置,上傳時檔案名稱會自動替換為隨機字串。"
|
||||
noDescription: "沒有說明文字"
|
||||
alwaysConfirmFollow: "點擊追隨時總是顯示確認訊息"
|
||||
alwaysConfirmFollow: "跟隨時總是確認"
|
||||
inquiry: "聯絡我們"
|
||||
tryAgain: "請再試一次。"
|
||||
confirmWhenRevealingSensitiveMedia: "要顯示敏感媒體時需確認"
|
||||
|
|
@ -1285,6 +1287,19 @@ unknownWebAuthnKey: "未註冊的金鑰。"
|
|||
passkeyVerificationFailed: "驗證金鑰失敗。"
|
||||
passkeyVerificationSucceededButPasswordlessLoginDisabled: "雖然驗證金鑰成功,但是無密碼登入的方式是停用的。"
|
||||
messageToFollower: "給追隨者的訊息"
|
||||
target: "目標 "
|
||||
testCaptchaWarning: "此功能用於 CAPTCHA 的測試。<strong>請勿在正式環境中使用。</strong>"
|
||||
prohibitedWordsForNameOfUser: "禁止使用的字詞(使用者名稱)"
|
||||
prohibitedWordsForNameOfUserDescription: "如果使用者名稱包含此清單中的任何字串,則拒絕重新命名使用者。 具有審查員權限的使用者不受此限制的影響。"
|
||||
yourNameContainsProhibitedWords: "您嘗試更改的名稱包含禁止的字串"
|
||||
yourNameContainsProhibitedWordsDescription: "名稱中包含禁止使用的字串。 如果您想使用此名稱,請聯絡您的伺服器管理員。"
|
||||
_abuseUserReport:
|
||||
forward: "轉發"
|
||||
forwardDescription: "以匿名系統帳戶將檢舉轉發至遠端伺服器。"
|
||||
resolve: "解決"
|
||||
accept: "接受"
|
||||
reject: "拒絕"
|
||||
resolveTutorial: "如果您已回覆正當的檢舉,請選擇「接受」以將案件標記為已解決。\n 如果檢舉的內容不正當,請選擇「拒絕」將案件標記為已解決。"
|
||||
_delivery:
|
||||
status: "傳送狀態"
|
||||
stop: "停止發送"
|
||||
|
|
@ -1422,6 +1437,7 @@ _serverSettings:
|
|||
reactionsBufferingDescription: "啟用時,可以顯著提高建立反應時的效能並減少資料庫的負載。 但是,Redis 記憶體使用量會增加。"
|
||||
inquiryUrl: "聯絡表單網址"
|
||||
inquiryUrlDescription: "指定伺服器運營者的聯絡表單網址,或包含運營者聯絡資訊網頁的網址。"
|
||||
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "為了防止 spam,如果一段期間內沒有偵測到審查員的活動,此設定將自動關閉。"
|
||||
_accountMigration:
|
||||
moveFrom: "從其他帳戶遷移到這個帳戶"
|
||||
moveFromSub: "為另一個帳戶建立別名"
|
||||
|
|
@ -1435,7 +1451,7 @@ _accountMigration:
|
|||
startMigration: "遷移"
|
||||
migrationConfirm: "確定要將這個帳戶遷移至 {account} 嗎?一旦遷移就無法撤銷,也就無法以原來的狀態使用這個帳戶。\n另外,請確認在要遷移到的帳戶已經建立了一個別名。"
|
||||
movedAndCannotBeUndone: "帳戶已遷移。\n遷移無法撤消。"
|
||||
postMigrationNote: "在完成遷移的 24 小時後解除此帳戶的追隨。此帳戶的追隨中、追隨者數量變為 0。由於不會解除追隨者,你的追隨者仍然可以繼續檢視這個帳戶發布給追隨者的貼文。"
|
||||
postMigrationNote: "取消追蹤此帳戶將在遷移操作後 24 小時執行。\n 此帳戶有 0 個關注者/關注者。 您的關注者仍然可以看到此帳戶的關注者帖子,因為您不會被取消關注。"
|
||||
movedTo: "要遷移到的帳戶:"
|
||||
_achievements:
|
||||
earnedAt: "獲得日期"
|
||||
|
|
@ -1555,7 +1571,7 @@ _achievements:
|
|||
_markedAsCat:
|
||||
title: "我是貓"
|
||||
description: "已將帳戶設定為貓"
|
||||
flavor: "還沒有名字。"
|
||||
flavor: "沒有名字。"
|
||||
_following1:
|
||||
title: "首次追隨"
|
||||
description: "首次追隨了"
|
||||
|
|
@ -1569,7 +1585,7 @@ _achievements:
|
|||
title: "一百位朋友"
|
||||
description: "追隨超過100人了"
|
||||
_following300:
|
||||
title: "朋友過多"
|
||||
title: "朋友太多"
|
||||
description: "追隨超過300人了"
|
||||
_followers1:
|
||||
title: "第一個追隨者"
|
||||
|
|
@ -1895,7 +1911,7 @@ _channel:
|
|||
following: "追隨中"
|
||||
usersCount: "有 {n} 人參與"
|
||||
notesCount: "有 {n} 篇貼文"
|
||||
nameAndDescription: "名稱與說明"
|
||||
nameAndDescription: "名稱"
|
||||
nameOnly: "僅名稱"
|
||||
allowRenoteToExternal: "允許在頻道外轉發和引用"
|
||||
_menuDisplay:
|
||||
|
|
@ -1975,7 +1991,6 @@ _theme:
|
|||
buttonBg: "按鈕背景"
|
||||
buttonHoverBg: "按鈕背景 (漂浮)"
|
||||
inputBorder: "輸入框邊框"
|
||||
listItemHoverBg: "列表物品背景 (漂浮)"
|
||||
driveFolderBg: "雲端硬碟文件夾背景"
|
||||
wallpaperOverlay: "壁紙覆蓋層"
|
||||
badge: "徽章"
|
||||
|
|
@ -2477,6 +2492,8 @@ _webhookSettings:
|
|||
abuseReport: "當使用者檢舉時"
|
||||
abuseReportResolved: "當處理了使用者的檢舉時"
|
||||
userCreated: "使用者被新增時"
|
||||
inactiveModeratorsWarning: "當審查員在一段時間內沒有活動時"
|
||||
inactiveModeratorsInvitationOnlyChanged: "當審查員在一段時間內不活動時,系統會將模式變更為邀請制"
|
||||
deleteConfirm: "請問是否要刪除 Webhook?"
|
||||
testRemarks: "按下切換開關右側的按鈕,就會將假資料發送至 Webhook。"
|
||||
_abuseReport:
|
||||
|
|
@ -2491,7 +2508,7 @@ _abuseReport:
|
|||
mail: "寄送到擁有監察員權限的使用者電子郵件地址(僅在收到檢舉時)"
|
||||
webhook: "向指定的 SystemWebhook 發送通知(在收到檢舉和解決檢舉時發送)"
|
||||
keywords: "關鍵字"
|
||||
notifiedUser: "被通知的使用者"
|
||||
notifiedUser: "通知的使用者"
|
||||
notifiedWebhook: "使用的 Webhook"
|
||||
deleteConfirm: "確定要刪除通知對象嗎?"
|
||||
_moderationLogTypes:
|
||||
|
|
@ -2522,6 +2539,8 @@ _moderationLogTypes:
|
|||
markSensitiveDriveFile: "標記為敏感檔案"
|
||||
unmarkSensitiveDriveFile: "撤銷標記為敏感檔案"
|
||||
resolveAbuseReport: "解決檢舉"
|
||||
forwardAbuseReport: "轉發檢舉"
|
||||
updateAbuseReportNote: "更新檢舉的審查備註"
|
||||
createInvitation: "建立邀請碼"
|
||||
createAd: "建立廣告"
|
||||
deleteAd: "刪除廣告"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "sharkey",
|
||||
"version": "2024.9.1-rc",
|
||||
"version": "2024.10.0-dev",
|
||||
"codename": "shonk",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -34,6 +34,9 @@
|
|||
"watch": "pnpm dev",
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"lint": "pnpm -r lint",
|
||||
"lint-all": "pnpm -r --no-bail lint",
|
||||
"eslint": "pnpm -r eslint",
|
||||
"eslint-all": "pnpm -r --no-bail eslint",
|
||||
"cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts",
|
||||
"cy:run": "pnpm cypress run",
|
||||
"e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run",
|
||||
|
|
@ -63,13 +66,15 @@
|
|||
"esbuild": "0.23.1",
|
||||
"glob": "11.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cypress": "13.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/eslint-plugin": "2.0.3",
|
||||
"@types/node": "20.14.12",
|
||||
"@typescript-eslint/eslint-plugin": "7.17.0",
|
||||
"@typescript-eslint/parser": "7.17.0",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "13.14.2",
|
||||
"eslint": "9.8.0",
|
||||
"globals": "15.9.0",
|
||||
"ncp": "2.0.0",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import tsParser from '@typescript-eslint/parser';
|
||||
import sharedConfig from '../shared/eslint.config.js';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
...sharedConfig,
|
||||
|
|
@ -43,4 +44,25 @@ export default [
|
|||
}],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/server/web/**/*.js', 'src/server/web/**/*.ts'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
LANGS: true,
|
||||
CLIENT_ENTRY: true,
|
||||
LANGS_VERSION: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
"**/lib/",
|
||||
"**/temp/",
|
||||
"**/built/",
|
||||
"**/coverage/",
|
||||
"**/node_modules/",
|
||||
"**/migration/",
|
||||
]
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class SoftLimitDriveComment1728348353115 {
|
||||
name = 'SoftLimitDriveComment1728348353115'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "drive_file" ALTER COLUMN "comment" TYPE text`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "drive_file" ALTER COLUMN "comment" TYPE varchar(100000)`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class TrackLatestNoteType1728420772835 {
|
||||
name = 'TrackLatestNoteType1728420772835'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "latest_note" DROP CONSTRAINT "PK_f619b62bfaafabe68f52fb50c9a"`);
|
||||
await queryRunner.query(`ALTER TABLE "latest_note" ADD "is_public" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`ALTER TABLE "latest_note" ADD "is_reply" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`ALTER TABLE "latest_note" ADD "is_quote" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`ALTER TABLE "latest_note" ADD CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd" PRIMARY KEY ("user_id", is_public, is_reply, is_quote)`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "latest_note" DROP CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd"`);
|
||||
await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN is_quote`);
|
||||
await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN is_reply`);
|
||||
await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN is_public`);
|
||||
await queryRunner.query(`ALTER TABLE "latest_note" ADD CONSTRAINT "PK_f619b62bfaafabe68f52fb50c9a" PRIMARY KEY ("user_id")`);
|
||||
}
|
||||
}
|
||||
16
packages/backend/migration/1729414690009-defaultSensitive.js
Normal file
16
packages/backend/migration/1729414690009-defaultSensitive.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: marie and sharkey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class DefaultSensitive1729414690009 {
|
||||
name = 'DefaultSensitive1729414690009'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ADD "defaultSensitive" boolean NOT NULL DEFAULT false`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "defaultSensitive"`);
|
||||
}
|
||||
}
|
||||
20
packages/backend/migration/1730505338000-friendlyCaptcha.js
Normal file
20
packages/backend/migration/1730505338000-friendlyCaptcha.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: marie and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class friendlyCaptcha1730505338000 {
|
||||
name = 'friendlyCaptcha1730505338000';
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "enableFC" boolean NOT NULL DEFAULT false`, undefined);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "fcSiteKey" character varying(1024)`, undefined);
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "fcSecretKey" character varying(1024)`, undefined);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "fcSecretKey"`, undefined);
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "fcSiteKey"`, undefined);
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableFC"`, undefined);
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
"restart": "pnpm build && pnpm start",
|
||||
"dev": "node ./scripts/dev.mjs",
|
||||
"typecheck": "pnpm --filter megalodon build && tsc --noEmit && tsc -p test --noEmit",
|
||||
"eslint": "eslint --quiet \"src/**/*.ts\" --cache",
|
||||
"eslint": "eslint --quiet \"{src,test,js,@types}/**/*.{js,jsx,ts,tsx,vue}\" --cache",
|
||||
"lint": "pnpm typecheck && pnpm eslint",
|
||||
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs",
|
||||
"jest:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.e2e.cjs",
|
||||
|
|
@ -93,6 +93,7 @@
|
|||
"@swc/core": "1.6.6",
|
||||
"@transfem-org/sfm-js": "0.24.5",
|
||||
"@twemoji/parser": "15.1.1",
|
||||
"@types/psl": "^1.1.3",
|
||||
"accepts": "1.3.8",
|
||||
"ajv": "8.17.1",
|
||||
"archiver": "7.0.1",
|
||||
|
|
@ -112,7 +113,7 @@
|
|||
"content-disposition": "0.5.4",
|
||||
"date-fns": "2.30.0",
|
||||
"deep-email-validator": "0.1.21",
|
||||
"fast-xml-parser": "^4.4.0",
|
||||
"fast-xml-parser": "4.4.1",
|
||||
"fastify": "5.0.0",
|
||||
"fastify-multer": "^2.0.3",
|
||||
"fastify-raw-body": "5.0.0",
|
||||
|
|
@ -135,9 +136,9 @@
|
|||
"json5": "2.2.3",
|
||||
"jsonld": "8.3.2",
|
||||
"jsrsasign": "11.1.0",
|
||||
"juice": "11.0.0",
|
||||
"megalodon": "workspace:*",
|
||||
"meilisearch": "0.42.0",
|
||||
"juice": "11.0.0",
|
||||
"microformats-parser": "2.0.2",
|
||||
"mime-types": "2.1.35",
|
||||
"misskey-js": "workspace:*",
|
||||
|
|
@ -158,6 +159,7 @@
|
|||
"probe-image-size": "7.2.3",
|
||||
"promise-limit": "2.7.0",
|
||||
"proxy-addr": "^2.0.7",
|
||||
"psl": "^1.13.0",
|
||||
"pug": "3.0.3",
|
||||
"punycode": "2.3.1",
|
||||
"qrcode": "1.5.4",
|
||||
|
|
|
|||
|
|
@ -12,27 +12,49 @@ const config = loadConfig();
|
|||
// createPostgresDataSource handels primaries and replicas automatically.
|
||||
// usually, it only opens connections first use, so we force it using
|
||||
// .initialize()
|
||||
createPostgresDataSource(config)
|
||||
.initialize()
|
||||
.then(c => { c.destroy() })
|
||||
.catch(e => { throw e });
|
||||
|
||||
async function connectToPostgres(){
|
||||
const source = createPostgresDataSource(config);
|
||||
await source.initialize();
|
||||
await source.destroy();
|
||||
}
|
||||
|
||||
// Connect to all redis servers
|
||||
function connectToRedis(redisOptions) {
|
||||
const redis = new Redis(redisOptions);
|
||||
redis.on('connect', () => redis.disconnect());
|
||||
redis.on('error', (e) => {
|
||||
throw e;
|
||||
async function connectToRedis(redisOptions) {
|
||||
return await new Promise(async (resolve, reject) => {
|
||||
const redis = new Redis({
|
||||
...redisOptions,
|
||||
lazyConnect: true,
|
||||
reconnectOnError: false,
|
||||
showFriendlyErrorStack: true,
|
||||
});
|
||||
redis.on('error', e => reject(e));
|
||||
|
||||
try {
|
||||
await redis.connect();
|
||||
resolve();
|
||||
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
|
||||
} finally {
|
||||
redis.disconnect(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If not all of these are defined, the default one gets reused.
|
||||
// so we use a Set to only try connecting once to each **uniq** redis.
|
||||
(new Set([
|
||||
config.redis,
|
||||
config.redisForPubsub,
|
||||
config.redisForJobQueue,
|
||||
config.redisForTimelines,
|
||||
config.redisForReactions,
|
||||
])).forEach(connectToRedis);
|
||||
const promises = Array
|
||||
.from(new Set([
|
||||
config.redis,
|
||||
config.redisForPubsub,
|
||||
config.redisForJobQueue,
|
||||
config.redisForTimelines,
|
||||
config.redisForReactions,
|
||||
]))
|
||||
.map(connectToRedis)
|
||||
.concat([
|
||||
connectToPostgres()
|
||||
]);
|
||||
|
||||
await Promise.all(promises);
|
||||
|
|
|
|||
|
|
@ -73,6 +73,11 @@ type Source = {
|
|||
|
||||
maxFileSize?: number;
|
||||
maxNoteLength?: number;
|
||||
maxCwLength?: number;
|
||||
maxRemoteCwLength?: number;
|
||||
maxRemoteNoteLength?: number;
|
||||
maxAltTextLength?: number;
|
||||
maxRemoteAltTextLength?: number;
|
||||
|
||||
clusterLimit?: number;
|
||||
|
||||
|
|
@ -110,6 +115,7 @@ type Source = {
|
|||
};
|
||||
|
||||
pidFile: string;
|
||||
filePermissionBits?: string;
|
||||
};
|
||||
|
||||
export type Config = {
|
||||
|
|
@ -149,6 +155,11 @@ export type Config = {
|
|||
allowedPrivateNetworks: string[] | undefined;
|
||||
maxFileSize: number;
|
||||
maxNoteLength: number;
|
||||
maxRemoteNoteLength: number;
|
||||
maxCwLength: number;
|
||||
maxRemoteCwLength: number;
|
||||
maxAltTextLength: number;
|
||||
maxRemoteAltTextLength: number;
|
||||
clusterLimit: number | undefined;
|
||||
id: string;
|
||||
outgoingAddress: string | undefined;
|
||||
|
|
@ -202,6 +213,7 @@ export type Config = {
|
|||
} | undefined;
|
||||
|
||||
pidFile: string;
|
||||
filePermissionBits?: string;
|
||||
};
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
|
|
@ -301,6 +313,11 @@ export function loadConfig(): Config {
|
|||
allowedPrivateNetworks: config.allowedPrivateNetworks,
|
||||
maxFileSize: config.maxFileSize ?? 262144000,
|
||||
maxNoteLength: config.maxNoteLength ?? 3000,
|
||||
maxRemoteNoteLength: config.maxRemoteNoteLength ?? 100000,
|
||||
maxCwLength: config.maxCwLength ?? 500,
|
||||
maxRemoteCwLength: config.maxRemoteCwLength ?? 5000,
|
||||
maxAltTextLength: config.maxAltTextLength ?? 20000,
|
||||
maxRemoteAltTextLength: config.maxRemoteAltTextLength ?? 100000,
|
||||
clusterLimit: config.clusterLimit,
|
||||
outgoingAddress: config.outgoingAddress,
|
||||
outgoingAddressFamily: config.outgoingAddressFamily,
|
||||
|
|
@ -332,6 +349,7 @@ export function loadConfig(): Config {
|
|||
deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7),
|
||||
import: config.import,
|
||||
pidFile: config.pidFile,
|
||||
filePermissionBits: config.filePermissionBits,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -437,7 +455,10 @@ function applyEnvOverrides(config: Source) {
|
|||
}
|
||||
}
|
||||
|
||||
const alwaysStrings = { 'chmodSocket': true } as { [key: string]: boolean };
|
||||
const alwaysStrings: { [key in string]?: boolean } = {
|
||||
'chmodSocket': true,
|
||||
'filePermissionBits': true,
|
||||
};
|
||||
|
||||
function _assign(path: (string | number)[], lastStep: string | number, value: string) {
|
||||
let thisConfig = config as any;
|
||||
|
|
@ -475,7 +496,7 @@ function applyEnvOverrides(config: Source) {
|
|||
_apply_top(['sentryForBackend', 'enableNodeProfiling']);
|
||||
_apply_top([['clusterLimit', 'deliverJobConcurrency', 'inboxJobConcurrency', 'relashionshipJobConcurrency', 'deliverJobPerSec', 'inboxJobPerSec', 'relashionshipJobPerSec', 'deliverJobMaxAttempts', 'inboxJobMaxAttempts']]);
|
||||
_apply_top([['outgoingAddress', 'outgoingAddressFamily', 'proxy', 'proxySmtp', 'mediaProxy', 'proxyRemoteFiles', 'videoThumbnailGenerator']]);
|
||||
_apply_top([['maxFileSize', 'maxNoteLength', 'pidFile']]);
|
||||
_apply_top([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'pidFile', 'filePermissionBits']]);
|
||||
_apply_top(['import', ['downloadTimeout', 'maxFileSize']]);
|
||||
_apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature']]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,30 +3,11 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export const MAX_NOTE_TEXT_LENGTH = 3000;
|
||||
|
||||
export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min
|
||||
export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days
|
||||
|
||||
export const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16;
|
||||
|
||||
//#region hard limits
|
||||
// If you change DB_* values, you must also change the DB schema.
|
||||
|
||||
/**
|
||||
* Maximum note text length that can be stored in DB.
|
||||
* Content Warnings are included in this limit.
|
||||
* Surrogate pairs count as one
|
||||
*/
|
||||
export const DB_MAX_NOTE_TEXT_LENGTH = 100000;
|
||||
|
||||
/**
|
||||
* Maximum image description length that can be stored in DB.
|
||||
* Surrogate pairs count as one
|
||||
*/
|
||||
export const DB_MAX_IMAGE_COMMENT_LENGTH = 100000;
|
||||
//#endregion
|
||||
|
||||
// ブラウザで直接表示することを許可するファイルの種類のリスト
|
||||
// ここに含まれないものは application/octet-stream としてレスポンスされる
|
||||
// SVGはXSSを生むので許可しない
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { bindThis } from '@/decorators.js';
|
|||
type CaptchaResponse = {
|
||||
success: boolean;
|
||||
'error-codes'?: string[];
|
||||
'errors'?: string[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -73,6 +74,35 @@ export class CaptchaService {
|
|||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async verifyFriendlyCaptcha(secret: string, response: string | null | undefined): Promise<void> {
|
||||
if (response == null) {
|
||||
throw new Error('frc-failed: no response provided');
|
||||
}
|
||||
|
||||
const result = await this.httpRequestService.send('https://api.friendlycaptcha.com/api/v1/siteverify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
secret: secret,
|
||||
solution: response,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (result.status !== 200) {
|
||||
throw new Error('frc-failed: frc didn\'t return 200 OK');
|
||||
}
|
||||
|
||||
const resp = await result.json() as CaptchaResponse;
|
||||
|
||||
if (resp.success !== true) {
|
||||
const errorCodes = resp['errors'] ? resp['errors'].join(', ') : '';
|
||||
throw new Error(`frc-failed: ${errorCodes}`);
|
||||
}
|
||||
}
|
||||
|
||||
// https://codeberg.org/Gusted/mCaptcha/src/branch/main/mcaptcha.go
|
||||
@bindThis
|
||||
public async verifyMcaptcha(secret: string, siteKey: string, instanceHost: string, response: string | null | undefined): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { ModerationLogService } from './ModerationLogService.js';
|
|||
import { NoteCreateService } from './NoteCreateService.js';
|
||||
import { NoteEditService } from './NoteEditService.js';
|
||||
import { NoteDeleteService } from './NoteDeleteService.js';
|
||||
import { LatestNoteService } from './LatestNoteService.js';
|
||||
import { NotePiningService } from './NotePiningService.js';
|
||||
import { NoteReadService } from './NoteReadService.js';
|
||||
import { NotificationService } from './NotificationService.js';
|
||||
|
|
@ -187,6 +188,7 @@ const $ModerationLogService: Provider = { provide: 'ModerationLogService', useEx
|
|||
const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService };
|
||||
const $NoteEditService: Provider = { provide: 'NoteEditService', useExisting: NoteEditService };
|
||||
const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService };
|
||||
const $LatestNoteService: Provider = { provide: 'LatestNoteService', useExisting: LatestNoteService };
|
||||
const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
|
||||
const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService };
|
||||
const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService };
|
||||
|
|
@ -339,6 +341,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
NoteCreateService,
|
||||
NoteEditService,
|
||||
NoteDeleteService,
|
||||
LatestNoteService,
|
||||
NotePiningService,
|
||||
NoteReadService,
|
||||
NotificationService,
|
||||
|
|
@ -487,6 +490,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
$NoteCreateService,
|
||||
$NoteEditService,
|
||||
$NoteDeleteService,
|
||||
$LatestNoteService,
|
||||
$NotePiningService,
|
||||
$NoteReadService,
|
||||
$NotificationService,
|
||||
|
|
@ -636,6 +640,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
NoteCreateService,
|
||||
NoteEditService,
|
||||
NoteDeleteService,
|
||||
LatestNoteService,
|
||||
NotePiningService,
|
||||
NoteReadService,
|
||||
NotificationService,
|
||||
|
|
@ -783,6 +788,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
$NoteCreateService,
|
||||
$NoteEditService,
|
||||
$NoteDeleteService,
|
||||
$LatestNoteService,
|
||||
$NotePiningService,
|
||||
$NoteReadService,
|
||||
$NotificationService,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
import * as fs from 'node:fs';
|
||||
import * as stream from 'node:stream/promises';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import chalk from 'chalk';
|
||||
import got, * as Got from 'got';
|
||||
import { parse } from 'content-disposition';
|
||||
|
|
@ -70,13 +69,6 @@ export class DownloadService {
|
|||
},
|
||||
enableUnixSockets: false,
|
||||
}).on('response', (res: Got.Response) => {
|
||||
if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !this.config.proxy && res.ip) {
|
||||
if (this.isPrivateIp(res.ip)) {
|
||||
this.logger.warn(`Blocked address: ${res.ip}`);
|
||||
req.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
const contentLength = res.headers['content-length'];
|
||||
if (contentLength != null) {
|
||||
const size = Number(contentLength);
|
||||
|
|
@ -139,18 +131,4 @@ export class DownloadService {
|
|||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private isPrivateIp(ip: string): boolean {
|
||||
const parsedIp = ipaddr.parse(ip);
|
||||
|
||||
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||
const cidr = ipaddr.parseCIDR(net);
|
||||
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return parsedIp.range() !== 'unicast';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -227,25 +227,33 @@ export class DriveService {
|
|||
const thumbnailAccessKey = 'thumbnail-' + randomUUID();
|
||||
const webpublicAccessKey = 'webpublic-' + randomUUID();
|
||||
|
||||
const url = this.internalStorageService.saveFromPath(accessKey, path);
|
||||
|
||||
let thumbnailUrl: string | null = null;
|
||||
let webpublicUrl: string | null = null;
|
||||
// Ugly type is just to help TS figure out that 2nd / 3rd promises are optional.
|
||||
const promises: [Promise<string>, ...(Promise<string> | undefined)[]] = [
|
||||
this.internalStorageService.saveFromPath(accessKey, path),
|
||||
];
|
||||
|
||||
if (alts.thumbnail) {
|
||||
thumbnailUrl = this.internalStorageService.saveFromBuffer(thumbnailAccessKey, alts.thumbnail.data);
|
||||
this.registerLogger.info(`thumbnail stored: ${thumbnailAccessKey}`);
|
||||
promises.push(this.internalStorageService.saveFromBuffer(thumbnailAccessKey, alts.thumbnail.data));
|
||||
}
|
||||
|
||||
if (alts.webpublic) {
|
||||
webpublicUrl = this.internalStorageService.saveFromBuffer(webpublicAccessKey, alts.webpublic.data);
|
||||
promises.push(this.internalStorageService.saveFromBuffer(webpublicAccessKey, alts.webpublic.data));
|
||||
}
|
||||
|
||||
const [url, thumbnailUrl, webpublicUrl] = await Promise.all(promises);
|
||||
|
||||
if (thumbnailUrl) {
|
||||
this.registerLogger.info(`thumbnail stored: ${thumbnailAccessKey}`);
|
||||
}
|
||||
|
||||
if (webpublicUrl) {
|
||||
this.registerLogger.info(`web stored: ${webpublicAccessKey}`);
|
||||
}
|
||||
|
||||
file.storedInternal = true;
|
||||
file.url = url;
|
||||
file.thumbnailUrl = thumbnailUrl;
|
||||
file.webpublicUrl = webpublicUrl;
|
||||
file.thumbnailUrl = thumbnailUrl ?? null;
|
||||
file.webpublicUrl = webpublicUrl ?? null;
|
||||
file.accessKey = accessKey;
|
||||
file.thumbnailAccessKey = thumbnailAccessKey;
|
||||
file.webpublicAccessKey = webpublicAccessKey;
|
||||
|
|
@ -560,7 +568,7 @@ export class DriveService {
|
|||
file.maybeSensitive = info.sensitive;
|
||||
file.maybePorn = info.porn;
|
||||
file.isSensitive = user
|
||||
? this.userEntityService.isLocalUser(user) && profile!.alwaysMarkNsfw ? true :
|
||||
? this.userEntityService.isLocalUser(user) && (profile!.alwaysMarkNsfw || profile!.defaultSensitive) ? true :
|
||||
sensitive ?? false
|
||||
: false;
|
||||
|
||||
|
|
@ -720,19 +728,19 @@ export class DriveService {
|
|||
|
||||
@bindThis
|
||||
public async deleteFileSync(file: MiDriveFile, isExpired = false, deleter?: MiUser) {
|
||||
const promises = [];
|
||||
|
||||
if (file.storedInternal) {
|
||||
this.internalStorageService.del(file.accessKey!);
|
||||
promises.push(this.internalStorageService.del(file.accessKey!));
|
||||
|
||||
if (file.thumbnailUrl) {
|
||||
this.internalStorageService.del(file.thumbnailAccessKey!);
|
||||
promises.push(this.internalStorageService.del(file.thumbnailAccessKey!));
|
||||
}
|
||||
|
||||
if (file.webpublicUrl) {
|
||||
this.internalStorageService.del(file.webpublicAccessKey!);
|
||||
promises.push(this.internalStorageService.del(file.webpublicAccessKey!));
|
||||
}
|
||||
} else if (!file.isLink) {
|
||||
const promises = [];
|
||||
|
||||
promises.push(this.deleteObjectStorageFile(file.accessKey!));
|
||||
|
||||
if (file.thumbnailUrl) {
|
||||
|
|
@ -742,10 +750,10 @@ export class DriveService {
|
|||
if (file.webpublicUrl) {
|
||||
promises.push(this.deleteObjectStorageFile(file.webpublicAccessKey!));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
this.deletePostProcess(file, isExpired, deleter);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -312,6 +312,7 @@ export class EmailService {
|
|||
Accept: 'application/json',
|
||||
Authorization: truemailAuthKey,
|
||||
},
|
||||
isLocalAddressAllowed: true,
|
||||
});
|
||||
|
||||
const json = (await res.json()) as {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import { IdService } from '@/core/IdService.js';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
|
||||
@Injectable()
|
||||
export class FederatedInstanceService implements OnApplicationShutdown {
|
||||
|
|
@ -56,11 +58,24 @@ export class FederatedInstanceService implements OnApplicationShutdown {
|
|||
const index = await this.instancesRepository.findOneBy({ host });
|
||||
|
||||
if (index == null) {
|
||||
const i = await this.instancesRepository.insertOne({
|
||||
id: this.idService.gen(),
|
||||
host,
|
||||
firstRetrievedAt: new Date(),
|
||||
});
|
||||
let i;
|
||||
try {
|
||||
i = await this.instancesRepository.insertOne({
|
||||
id: this.idService.gen(),
|
||||
host,
|
||||
firstRetrievedAt: new Date(),
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof QueryFailedError) {
|
||||
if (isDuplicateKeyValueError(e)) {
|
||||
i = await this.instancesRepository.findOneBy({ host });
|
||||
}
|
||||
}
|
||||
|
||||
if (i == null) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
this.federatedInstanceCache.set(host, i);
|
||||
return i;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import * as net from 'node:net';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import CacheableLookup from 'cacheable-lookup';
|
||||
import fetch from 'node-fetch';
|
||||
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
|
||||
|
|
@ -25,8 +26,102 @@ export type HttpRequestSendOptions = {
|
|||
validators?: ((res: Response) => void)[];
|
||||
};
|
||||
|
||||
declare module 'node:http' {
|
||||
interface Agent {
|
||||
createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket;
|
||||
}
|
||||
}
|
||||
|
||||
class HttpRequestServiceAgent extends http.Agent {
|
||||
constructor(
|
||||
private config: Config,
|
||||
options?: http.AgentOptions,
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
|
||||
const socket = super.createConnection(options, callback)
|
||||
.on('connect', () => {
|
||||
const address = socket.remoteAddress;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (address && ipaddr.isValid(address)) {
|
||||
if (this.isPrivateIp(address)) {
|
||||
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private isPrivateIp(ip: string): boolean {
|
||||
const parsedIp = ipaddr.parse(ip);
|
||||
|
||||
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||
const cidr = ipaddr.parseCIDR(net);
|
||||
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return parsedIp.range() !== 'unicast';
|
||||
}
|
||||
}
|
||||
|
||||
class HttpsRequestServiceAgent extends https.Agent {
|
||||
constructor(
|
||||
private config: Config,
|
||||
options?: https.AgentOptions,
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
|
||||
const socket = super.createConnection(options, callback)
|
||||
.on('connect', () => {
|
||||
const address = socket.remoteAddress;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (address && ipaddr.isValid(address)) {
|
||||
if (this.isPrivateIp(address)) {
|
||||
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private isPrivateIp(ip: string): boolean {
|
||||
const parsedIp = ipaddr.parse(ip);
|
||||
|
||||
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||
const cidr = ipaddr.parseCIDR(net);
|
||||
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return parsedIp.range() !== 'unicast';
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HttpRequestService {
|
||||
/**
|
||||
* Get http non-proxy agent (without local address filtering)
|
||||
*/
|
||||
private httpNative: http.Agent;
|
||||
|
||||
/**
|
||||
* Get https non-proxy agent (without local address filtering)
|
||||
*/
|
||||
private httpsNative: https.Agent;
|
||||
|
||||
/**
|
||||
* Get http non-proxy agent
|
||||
*/
|
||||
|
|
@ -57,19 +152,20 @@ export class HttpRequestService {
|
|||
lookup: false, // nativeのdns.lookupにfallbackしない
|
||||
});
|
||||
|
||||
this.http = new http.Agent({
|
||||
const agentOption = {
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
lookup: cache.lookup as unknown as net.LookupFunction,
|
||||
localAddress: config.outgoingAddress,
|
||||
});
|
||||
};
|
||||
|
||||
this.https = new https.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
lookup: cache.lookup as unknown as net.LookupFunction,
|
||||
localAddress: config.outgoingAddress,
|
||||
});
|
||||
this.httpNative = new http.Agent(agentOption);
|
||||
|
||||
this.httpsNative = new https.Agent(agentOption);
|
||||
|
||||
this.http = new HttpRequestServiceAgent(config, agentOption);
|
||||
|
||||
this.https = new HttpsRequestServiceAgent(config, agentOption);
|
||||
|
||||
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
|
||||
|
||||
|
|
@ -104,16 +200,22 @@ export class HttpRequestService {
|
|||
* @param bypassProxy Allways bypass proxy
|
||||
*/
|
||||
@bindThis
|
||||
public getAgentByUrl(url: URL, bypassProxy = false): http.Agent | https.Agent {
|
||||
public getAgentByUrl(url: URL, bypassProxy = false, isLocalAddressAllowed = false): http.Agent | https.Agent {
|
||||
if (bypassProxy || (this.config.proxyBypassHosts ?? []).includes(url.hostname)) {
|
||||
if (isLocalAddressAllowed) {
|
||||
return url.protocol === 'http:' ? this.httpNative : this.httpsNative;
|
||||
}
|
||||
return url.protocol === 'http:' ? this.http : this.https;
|
||||
} else {
|
||||
if (isLocalAddressAllowed && (!this.config.proxy)) {
|
||||
return url.protocol === 'http:' ? this.httpNative : this.httpsNative;
|
||||
}
|
||||
return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getActivityJson(url: string): Promise<IObject> {
|
||||
public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise<IObject> {
|
||||
const res = await this.send(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
|
@ -121,6 +223,7 @@ export class HttpRequestService {
|
|||
},
|
||||
timeout: 5000,
|
||||
size: 1024 * 256,
|
||||
isLocalAddressAllowed: isLocalAddressAllowed,
|
||||
}, {
|
||||
throwErrorWhenResponseNotOk: true,
|
||||
validators: [validateContentTypeSetAsActivityPub],
|
||||
|
|
@ -129,13 +232,13 @@ export class HttpRequestService {
|
|||
const finalUrl = res.url; // redirects may have been involved
|
||||
const activity = await res.json() as IObject;
|
||||
|
||||
assertActivityMatchesUrls(activity, [url, finalUrl]);
|
||||
assertActivityMatchesUrls(activity, [finalUrl]);
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>): Promise<T> {
|
||||
public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>, isLocalAddressAllowed = false): Promise<T> {
|
||||
const res = await this.send(url, {
|
||||
method: 'GET',
|
||||
headers: Object.assign({
|
||||
|
|
@ -143,19 +246,21 @@ export class HttpRequestService {
|
|||
}, headers ?? {}),
|
||||
timeout: 5000,
|
||||
size: 1024 * 256,
|
||||
isLocalAddressAllowed: isLocalAddressAllowed,
|
||||
});
|
||||
|
||||
return await res.json() as T;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getHtml(url: string, accept = 'text/html, */*', headers?: Record<string, string>): Promise<string> {
|
||||
public async getHtml(url: string, accept = 'text/html, */*', headers?: Record<string, string>, isLocalAddressAllowed = false): Promise<string> {
|
||||
const res = await this.send(url, {
|
||||
method: 'GET',
|
||||
headers: Object.assign({
|
||||
Accept: accept,
|
||||
}, headers ?? {}),
|
||||
timeout: 5000,
|
||||
isLocalAddressAllowed: isLocalAddressAllowed,
|
||||
});
|
||||
|
||||
return await res.text();
|
||||
|
|
@ -170,6 +275,7 @@ export class HttpRequestService {
|
|||
headers?: Record<string, string>,
|
||||
timeout?: number,
|
||||
size?: number,
|
||||
isLocalAddressAllowed?: boolean,
|
||||
} = {},
|
||||
extra: HttpRequestSendOptions = {
|
||||
throwErrorWhenResponseNotOk: true,
|
||||
|
|
@ -183,6 +289,8 @@ export class HttpRequestService {
|
|||
controller.abort();
|
||||
}, timeout);
|
||||
|
||||
const isLocalAddressAllowed = args.isLocalAddressAllowed ?? false;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: args.method ?? 'GET',
|
||||
headers: {
|
||||
|
|
@ -191,7 +299,7 @@ export class HttpRequestService {
|
|||
},
|
||||
body: args.body,
|
||||
size: args.size ?? 10 * 1024 * 1024,
|
||||
agent: (url) => this.getAgentByUrl(url),
|
||||
agent: (url) => this.getAgentByUrl(url, false, isLocalAddressAllowed),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { copyFile, unlink, writeFile, chmod } from 'node:fs/promises';
|
||||
import * as Path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
|
|
@ -23,6 +24,8 @@ export class InternalStorageService {
|
|||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
// No one should erase the working directory *while the server is running*.
|
||||
fs.mkdirSync(path, { recursive: true });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -36,21 +39,27 @@ export class InternalStorageService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public saveFromPath(key: string, srcPath: string) {
|
||||
fs.mkdirSync(path, { recursive: true });
|
||||
fs.copyFileSync(srcPath, this.resolvePath(key));
|
||||
public async saveFromPath(key: string, srcPath: string): Promise<string> {
|
||||
await copyFile(srcPath, this.resolvePath(key));
|
||||
return await this.finalizeSavedFile(key);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async saveFromBuffer(key: string, data: Buffer): Promise<string> {
|
||||
await writeFile(this.resolvePath(key), data);
|
||||
return await this.finalizeSavedFile(key);
|
||||
}
|
||||
|
||||
private async finalizeSavedFile(key: string): Promise<string> {
|
||||
if (this.config.filePermissionBits) {
|
||||
const path = this.resolvePath(key);
|
||||
await chmod(path, this.config.filePermissionBits);
|
||||
}
|
||||
return `${this.config.url}/files/${key}`;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public saveFromBuffer(key: string, data: Buffer) {
|
||||
fs.mkdirSync(path, { recursive: true });
|
||||
fs.writeFileSync(this.resolvePath(key), data);
|
||||
return `${this.config.url}/files/${key}`;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public del(key: string) {
|
||||
fs.unlink(this.resolvePath(key), () => {});
|
||||
public async del(key: string): Promise<void> {
|
||||
await unlink(this.resolvePath(key));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
139
packages/backend/src/core/LatestNoteService.ts
Normal file
139
packages/backend/src/core/LatestNoteService.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Not } from 'typeorm';
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
import { isPureRenote } from '@/misc/is-renote.js';
|
||||
import { SkLatestNote } from '@/models/LatestNote.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { LatestNotesRepository, NotesRepository } from '@/models/_.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import Logger from '@/logger.js';
|
||||
|
||||
@Injectable()
|
||||
export class LatestNoteService {
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.latestNotesRepository)
|
||||
private latestNotesRepository: LatestNotesRepository,
|
||||
|
||||
loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = loggerService.getLogger('LatestNoteService');
|
||||
}
|
||||
|
||||
handleUpdatedNoteBG(before: MiNote, after: MiNote): void {
|
||||
this
|
||||
.handleUpdatedNote(before, after)
|
||||
.catch(err => this.logger.error('Unhandled exception while updating latest_note (after update):', err));
|
||||
}
|
||||
|
||||
async handleUpdatedNote(before: MiNote, after: MiNote): Promise<void> {
|
||||
// If the key didn't change, then there's nothing to update
|
||||
if (SkLatestNote.areEquivalent(before, after)) return;
|
||||
|
||||
// Simulate update as delete + create
|
||||
await this.handleDeletedNote(before);
|
||||
await this.handleCreatedNote(after);
|
||||
}
|
||||
|
||||
handleCreatedNoteBG(note: MiNote): void {
|
||||
this
|
||||
.handleCreatedNote(note)
|
||||
.catch(err => this.logger.error('Unhandled exception while updating latest_note (after create):', err));
|
||||
}
|
||||
|
||||
async handleCreatedNote(note: MiNote): Promise<void> {
|
||||
// Ignore DMs.
|
||||
// Followers-only posts are *included*, as this table is used to back the "following" feed.
|
||||
if (note.visibility === 'specified') return;
|
||||
|
||||
// Ignore pure renotes
|
||||
if (isPureRenote(note)) return;
|
||||
|
||||
// Compute the compound key of the entry to check
|
||||
const key = SkLatestNote.keyFor(note);
|
||||
|
||||
// Make sure that this isn't an *older* post.
|
||||
// We can get older posts through replies, lookups, updates, etc.
|
||||
const currentLatest = await this.latestNotesRepository.findOneBy(key);
|
||||
if (currentLatest != null && currentLatest.noteId >= note.id) return;
|
||||
|
||||
// Record this as the latest note for the given user
|
||||
const latestNote = new SkLatestNote({
|
||||
...key,
|
||||
noteId: note.id,
|
||||
});
|
||||
await this.latestNotesRepository.upsert(latestNote, ['userId', 'isPublic', 'isReply', 'isQuote']);
|
||||
}
|
||||
|
||||
handleDeletedNoteBG(note: MiNote): void {
|
||||
this
|
||||
.handleDeletedNote(note)
|
||||
.catch(err => this.logger.error('Unhandled exception while updating latest_note (after delete):', err));
|
||||
}
|
||||
|
||||
async handleDeletedNote(note: MiNote): Promise<void> {
|
||||
// If it's a DM, then it can't possibly be the latest note so we can safely skip this.
|
||||
if (note.visibility === 'specified') return;
|
||||
|
||||
// If it's a pure renote, then it can't possibly be the latest note so we can safely skip this.
|
||||
if (isPureRenote(note)) return;
|
||||
|
||||
// Compute the compound key of the entry to check
|
||||
const key = SkLatestNote.keyFor(note);
|
||||
|
||||
// Check if the deleted note was possibly the latest for the user
|
||||
const existingLatest = await this.latestNotesRepository.findOneBy(key);
|
||||
if (existingLatest == null || existingLatest.noteId !== note.id) return;
|
||||
|
||||
// Find the newest remaining note for the user.
|
||||
// We exclude DMs and pure renotes.
|
||||
const nextLatest = await this.notesRepository
|
||||
.createQueryBuilder('note')
|
||||
.select()
|
||||
.where({
|
||||
userId: key.userId,
|
||||
visibility: key.isPublic
|
||||
? 'public'
|
||||
: Not('specified'),
|
||||
replyId: key.isReply
|
||||
? Not(null)
|
||||
: null,
|
||||
renoteId: key.isQuote
|
||||
? Not(null)
|
||||
: null,
|
||||
})
|
||||
.andWhere(`
|
||||
(
|
||||
note."renoteId" IS NULL
|
||||
OR note.text IS NOT NULL
|
||||
OR note.cw IS NOT NULL
|
||||
OR note."replyId" IS NOT NULL
|
||||
OR note."hasPoll"
|
||||
OR note."fileIds" != '{}'
|
||||
)
|
||||
`)
|
||||
.orderBy({ id: 'DESC' })
|
||||
.getOne();
|
||||
if (!nextLatest) return;
|
||||
|
||||
// Record it as the latest
|
||||
const latestNote = new SkLatestNote({
|
||||
...key,
|
||||
noteId: nextLatest.id,
|
||||
});
|
||||
|
||||
// When inserting the latest note, it's possible that another worker has "raced" the insert and already added a newer note.
|
||||
// We must use orIgnore() to ensure that the query ignores conflicts, otherwise an exception may be thrown.
|
||||
await this.latestNotesRepository
|
||||
.createQueryBuilder('latest')
|
||||
.insert()
|
||||
.into(SkLatestNote)
|
||||
.values(latestNote)
|
||||
.orIgnore()
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
|
@ -412,8 +412,10 @@ export class MfmService {
|
|||
mention: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
const { username, host, acct } = node.props;
|
||||
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
|
||||
a.setAttribute('href', remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`);
|
||||
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username.toLowerCase() === username.toLowerCase() && remoteUser.host?.toLowerCase() === host?.toLowerCase());
|
||||
a.setAttribute('href', remoteUserInfo
|
||||
? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri)
|
||||
: `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`);
|
||||
a.className = 'u-url mention';
|
||||
a.textContent = acct;
|
||||
return a;
|
||||
|
|
|
|||
|
|
@ -13,8 +13,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
|
|||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||
import type { IMentionedRemoteUsers } from '@/models/Note.js';
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
import { LatestNote } from '@/models/LatestNote.js';
|
||||
import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, LatestNotesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import type { MiApp } from '@/models/App.js';
|
||||
import { concat } from '@/misc/prelude/array.js';
|
||||
|
|
@ -46,7 +45,6 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ
|
|||
import { NoteReadService } from '@/core/NoteReadService.js';
|
||||
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { SearchService } from '@/core/SearchService.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
|
|
@ -58,7 +56,7 @@ import { isReply } from '@/misc/is-reply.js';
|
|||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { isQuote, isRenote } from '@/misc/is-renote.js';
|
||||
import { LatestNoteService } from '@/core/LatestNoteService.js';
|
||||
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
|
||||
|
||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||
|
|
@ -148,6 +146,8 @@ type Option = {
|
|||
app?: MiApp | null;
|
||||
};
|
||||
|
||||
export type PureRenoteOption = Option & { renote: MiNote } & ({ text?: null } | { cw?: null } | { reply?: null } | { poll?: null } | { files?: null | [] });
|
||||
|
||||
@Injectable()
|
||||
export class NoteCreateService implements OnApplicationShutdown {
|
||||
#shutdownController = new AbortController();
|
||||
|
|
@ -172,9 +172,6 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.latestNotesRepository)
|
||||
private latestNotesRepository: LatestNotesRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
|
|
@ -225,6 +222,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
private utilityService: UtilityService,
|
||||
private userBlockingService: UserBlockingService,
|
||||
private cacheService: CacheService,
|
||||
private latestNoteService: LatestNoteService,
|
||||
) {
|
||||
this.updateNotesCountQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseNotesCount, this.performUpdateNotesCount);
|
||||
}
|
||||
|
|
@ -338,9 +336,13 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
data.localOnly = true;
|
||||
}
|
||||
|
||||
const maxTextLength = user.host == null
|
||||
? this.config.maxNoteLength
|
||||
: this.config.maxRemoteNoteLength;
|
||||
|
||||
if (data.text) {
|
||||
if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) {
|
||||
data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH);
|
||||
if (data.text.length > maxTextLength) {
|
||||
data.text = data.text.slice(0, maxTextLength);
|
||||
}
|
||||
data.text = data.text.trim();
|
||||
if (data.text === '') {
|
||||
|
|
@ -350,9 +352,13 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
data.text = null;
|
||||
}
|
||||
|
||||
const maxCwLength = user.host == null
|
||||
? this.config.maxCwLength
|
||||
: this.config.maxRemoteCwLength;
|
||||
|
||||
if (data.cw) {
|
||||
if (data.cw.length > DB_MAX_NOTE_TEXT_LENGTH) {
|
||||
data.cw = data.cw.slice(0, DB_MAX_NOTE_TEXT_LENGTH);
|
||||
if (data.cw.length > maxCwLength) {
|
||||
data.cw = data.cw.slice(0, maxCwLength);
|
||||
}
|
||||
data.cw = data.cw.trim();
|
||||
if (data.cw === '') {
|
||||
|
|
@ -408,7 +414,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
|
||||
if (user.host && !data.cw) {
|
||||
await this.federatedInstanceService.fetch(user.host).then(async i => {
|
||||
if (i.isNSFW) {
|
||||
if (i.isNSFW && !this.isPureRenote(data)) {
|
||||
data.cw = 'Instance is marked as NSFW';
|
||||
}
|
||||
});
|
||||
|
|
@ -530,8 +536,6 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
await this.notesRepository.insert(insert);
|
||||
}
|
||||
|
||||
await this.updateLatestNote(insert);
|
||||
|
||||
return insert;
|
||||
} catch (e) {
|
||||
// duplicate key error
|
||||
|
|
@ -625,6 +629,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
this.queueService.endedPollNotificationQueue.add(note.id, {
|
||||
noteId: note.id,
|
||||
}, {
|
||||
jobId: `pollEnd:${note.id}`,
|
||||
delay,
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
|
@ -812,10 +817,18 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
});
|
||||
}
|
||||
|
||||
// Update the Latest Note index / following feed
|
||||
this.latestNoteService.handleCreatedNoteBG(note);
|
||||
|
||||
// Register to search database
|
||||
if (!user.noindex) this.index(note);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isPureRenote(note: Option): note is PureRenoteOption {
|
||||
return this.isRenote(note) && !this.isQuote(note);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private isRenote(note: Option): note is Option & { renote: MiNote } {
|
||||
return note.renote != null;
|
||||
|
|
@ -1151,25 +1164,4 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
public async onApplicationShutdown(signal?: string | undefined): Promise<void> {
|
||||
await this.dispose();
|
||||
}
|
||||
|
||||
private async updateLatestNote(note: MiNote) {
|
||||
// Ignore DMs.
|
||||
// Followers-only posts are *included*, as this table is used to back the "following" feed.
|
||||
if (note.visibility === 'specified') return;
|
||||
|
||||
// Ignore pure renotes
|
||||
if (isRenote(note) && !isQuote(note)) return;
|
||||
|
||||
// Make sure that this isn't an *older* post.
|
||||
// We can get older posts through replies, lookups, etc.
|
||||
const currentLatest = await this.latestNotesRepository.findOneBy({ userId: note.userId });
|
||||
if (currentLatest != null && currentLatest.noteId >= note.id) return;
|
||||
|
||||
// Record this as the latest note for the given user
|
||||
const latestNote = new LatestNote({
|
||||
userId: note.userId,
|
||||
noteId: note.id,
|
||||
});
|
||||
await this.latestNotesRepository.upsert(latestNote, ['userId']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,11 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Brackets, In, Not } from 'typeorm';
|
||||
import { Brackets, In } from 'typeorm';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
import type { MiNote, IMentionedRemoteUsers } from '@/models/Note.js';
|
||||
import { LatestNote } from '@/models/LatestNote.js';
|
||||
import type { InstancesRepository, MiMeta, LatestNotesRepository, NotesRepository, UsersRepository } from '@/models/_.js';
|
||||
import { MiNote, IMentionedRemoteUsers } from '@/models/Note.js';
|
||||
import type { InstancesRepository, MiMeta, NotesRepository, UsersRepository } from '@/models/_.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
|
@ -24,6 +23,7 @@ import { bindThis } from '@/decorators.js';
|
|||
import { SearchService } from '@/core/SearchService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { isQuote, isRenote } from '@/misc/is-renote.js';
|
||||
import { LatestNoteService } from '@/core/LatestNoteService.js';
|
||||
|
||||
@Injectable()
|
||||
export class NoteDeleteService {
|
||||
|
|
@ -40,9 +40,6 @@ export class NoteDeleteService {
|
|||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.latestNotesRepository)
|
||||
private latestNotesRepository: LatestNotesRepository,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
||||
|
|
@ -57,6 +54,7 @@ export class NoteDeleteService {
|
|||
private notesChart: NotesChart,
|
||||
private perUserNotesChart: PerUserNotesChart,
|
||||
private instanceChart: InstanceChart,
|
||||
private latestNoteService: LatestNoteService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -149,7 +147,7 @@ export class NoteDeleteService {
|
|||
userId: user.id,
|
||||
});
|
||||
|
||||
await this.updateLatestNote(note);
|
||||
this.latestNoteService.handleDeletedNoteBG(note);
|
||||
|
||||
if (deleter && (note.userId !== deleter.id)) {
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: note.userId });
|
||||
|
|
@ -232,52 +230,4 @@ export class NoteDeleteService {
|
|||
this.apDeliverManagerService.deliverToUser(user, content, remoteUser);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateLatestNote(note: MiNote) {
|
||||
// If it's a DM, then it can't possibly be the latest note so we can safely skip this.
|
||||
if (note.visibility === 'specified') return;
|
||||
|
||||
// Check if the deleted note was possibly the latest for the user
|
||||
const hasLatestNote = await this.latestNotesRepository.existsBy({ userId: note.userId });
|
||||
if (hasLatestNote) return;
|
||||
|
||||
// Find the newest remaining note for the user.
|
||||
// We exclude DMs and pure renotes.
|
||||
const nextLatest = await this.notesRepository
|
||||
.createQueryBuilder('note')
|
||||
.select()
|
||||
.where({
|
||||
userId: note.userId,
|
||||
visibility: Not('specified'),
|
||||
})
|
||||
.andWhere(`
|
||||
(
|
||||
note."renoteId" IS NULL
|
||||
OR note.text IS NOT NULL
|
||||
OR note.cw IS NOT NULL
|
||||
OR note."replyId" IS NOT NULL
|
||||
OR note."hasPoll"
|
||||
OR note."fileIds" != '{}'
|
||||
)
|
||||
`)
|
||||
.orderBy({ id: 'DESC' })
|
||||
.getOne();
|
||||
if (!nextLatest) return;
|
||||
|
||||
// Record it as the latest
|
||||
const latestNote = new LatestNote({
|
||||
userId: note.userId,
|
||||
noteId: nextLatest.id,
|
||||
});
|
||||
|
||||
// When inserting the latest note, it's possible that another worker has "raced" the insert and already added a newer note.
|
||||
// We must use orIgnore() to ensure that the query ignores conflicts, otherwise an exception may be thrown.
|
||||
await this.latestNotesRepository
|
||||
.createQueryBuilder('latest')
|
||||
.insert()
|
||||
.into(LatestNote)
|
||||
.values(latestNote)
|
||||
.orIgnore()
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ
|
|||
import { NoteReadService } from '@/core/NoteReadService.js';
|
||||
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { SearchService } from '@/core/SearchService.js';
|
||||
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
|
||||
|
|
@ -50,6 +49,7 @@ import { isReply } from '@/misc/is-reply.js';
|
|||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { LatestNoteService } from '@/core/LatestNoteService.js';
|
||||
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
|
||||
import { NoteCreateService } from '@/core/NoteCreateService.js';
|
||||
|
||||
|
|
@ -217,6 +217,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
private utilityService: UtilityService,
|
||||
private userBlockingService: UserBlockingService,
|
||||
private cacheService: CacheService,
|
||||
private latestNoteService: LatestNoteService,
|
||||
private noteCreateService: NoteCreateService,
|
||||
) {
|
||||
this.updateNotesCountQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseNotesCount, this.performUpdateNotesCount);
|
||||
|
|
@ -363,9 +364,13 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
data.localOnly = true;
|
||||
}
|
||||
|
||||
const maxTextLength = user.host == null
|
||||
? this.config.maxNoteLength
|
||||
: this.config.maxRemoteNoteLength;
|
||||
|
||||
if (data.text) {
|
||||
if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) {
|
||||
data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH);
|
||||
if (data.text.length > maxTextLength) {
|
||||
data.text = data.text.slice(0, maxTextLength);
|
||||
}
|
||||
data.text = data.text.trim();
|
||||
if (data.text === '') {
|
||||
|
|
@ -375,9 +380,13 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
data.text = null;
|
||||
}
|
||||
|
||||
const maxCwLength = user.host == null
|
||||
? this.config.maxCwLength
|
||||
: this.config.maxRemoteCwLength;
|
||||
|
||||
if (data.cw) {
|
||||
if (data.cw.length > DB_MAX_NOTE_TEXT_LENGTH) {
|
||||
data.cw = data.cw.slice(0, DB_MAX_NOTE_TEXT_LENGTH);
|
||||
if (data.cw.length > maxCwLength) {
|
||||
data.cw = data.cw.slice(0, maxCwLength);
|
||||
}
|
||||
data.cw = data.cw.trim();
|
||||
if (data.cw === '') {
|
||||
|
|
@ -433,7 +442,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
|
||||
if (user.host && !data.cw) {
|
||||
await this.federatedInstanceService.fetch(user.host).then(async i => {
|
||||
if (i.isNSFW) {
|
||||
if (i.isNSFW && !this.noteCreateService.isPureRenote(data)) {
|
||||
data.cw = 'Instance is marked as NSFW';
|
||||
}
|
||||
});
|
||||
|
|
@ -462,8 +471,9 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
const poll = await this.pollsRepository.findOneBy({ noteId: oldnote.id });
|
||||
|
||||
const oldPoll = poll ? { choices: poll.choices, multiple: poll.multiple, expiresAt: poll.expiresAt } : null;
|
||||
const pollChanged = data.poll != null && JSON.stringify(data.poll) !== JSON.stringify(oldPoll);
|
||||
|
||||
if (Object.keys(update).length > 0 || filesChanged) {
|
||||
if (Object.keys(update).length > 0 || filesChanged || pollChanged) {
|
||||
const exists = await this.noteEditRepository.findOneBy({ noteId: oldnote.id });
|
||||
|
||||
await this.noteEditRepository.insert({
|
||||
|
|
@ -535,7 +545,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
}));
|
||||
}
|
||||
|
||||
if (data.poll != null && JSON.stringify(data.poll) !== JSON.stringify(oldPoll)) {
|
||||
if (pollChanged) {
|
||||
// Start transaction
|
||||
await this.db.transaction(async transactionalEntityManager => {
|
||||
await transactionalEntityManager.update(MiNote, oldnote.id, note);
|
||||
|
|
@ -563,7 +573,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
setImmediate('post edited', { signal: this.#shutdownController.signal }).then(
|
||||
() => this.postNoteEdited(note, user, data, silent, tags!, mentionedUsers!),
|
||||
() => this.postNoteEdited(note, oldnote, user, data, silent, tags!, mentionedUsers!),
|
||||
() => { /* aborted, ignore this */ },
|
||||
);
|
||||
|
||||
|
|
@ -574,7 +584,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async postNoteEdited(note: MiNote, user: {
|
||||
private async postNoteEdited(note: MiNote, oldNote: MiNote, user: {
|
||||
id: MiUser['id'];
|
||||
username: MiUser['username'];
|
||||
host: MiUser['host'];
|
||||
|
|
@ -596,10 +606,11 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
|
||||
if (data.poll && data.poll.expiresAt) {
|
||||
const delay = data.poll.expiresAt.getTime() - Date.now();
|
||||
this.queueService.endedPollNotificationQueue.remove(note.id);
|
||||
this.queueService.endedPollNotificationQueue.remove(`pollEnd:${note.id}`);
|
||||
this.queueService.endedPollNotificationQueue.add(note.id, {
|
||||
noteId: note.id,
|
||||
}, {
|
||||
jobId: `pollEnd:${note.id}`,
|
||||
delay,
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
|
@ -771,6 +782,9 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
});
|
||||
}
|
||||
|
||||
// Update the Latest Note index / following feed
|
||||
this.latestNoteService.handleUpdatedNoteBG(oldNote, note);
|
||||
|
||||
// Register to search database
|
||||
if (!user.noindex) this.index(note);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export class RemoteUserResolveService {
|
|||
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
if (this.config.host === host) {
|
||||
if (host === this.utilityService.toPuny(this.config.host)) {
|
||||
this.logger.info(`return local user: ${usernameLower}`);
|
||||
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
|
||||
if (u == null) {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export class SignupService {
|
|||
host?: string | null;
|
||||
reason?: string | null;
|
||||
ignorePreservedUsernames?: boolean;
|
||||
approved?: boolean;
|
||||
}) {
|
||||
const { username, password, passwordHash, host, reason } = opts;
|
||||
let hash = passwordHash;
|
||||
|
|
@ -115,9 +116,6 @@ export class SignupService {
|
|||
));
|
||||
|
||||
let account!: MiUser;
|
||||
let defaultApproval = false;
|
||||
|
||||
if (!this.meta.approvalRequiredForSignup) defaultApproval = true;
|
||||
|
||||
// Start transaction
|
||||
await this.db.transaction(async transactionalEntityManager => {
|
||||
|
|
@ -135,7 +133,7 @@ export class SignupService {
|
|||
host: this.utilityService.toPunyNullable(host),
|
||||
token: secret,
|
||||
isRoot: isTheFirstUser,
|
||||
approved: defaultApproval,
|
||||
approved: isTheFirstUser || (opts.approved ?? !this.meta.approvalRequiredForSignup),
|
||||
signupReason: reason,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@
|
|||
*/
|
||||
|
||||
import { URL } from 'node:url';
|
||||
import { toASCII } from 'punycode';
|
||||
import punycode from 'punycode/punycode.js';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import RE2 from 're2';
|
||||
import psl from 'psl';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
|
@ -34,6 +35,11 @@ export class UtilityService {
|
|||
return this.toPuny(this.config.host) === this.toPuny(host);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isUriLocal(uri: string): boolean {
|
||||
return this.punyHost(uri) === this.toPuny(this.config.host);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isBlockedHost(blockedHosts: string[], host: string | null): boolean {
|
||||
if (host == null) return false;
|
||||
|
|
@ -101,13 +107,13 @@ export class UtilityService {
|
|||
|
||||
@bindThis
|
||||
public toPuny(host: string): string {
|
||||
return toASCII(host.toLowerCase());
|
||||
return punycode.toASCII(host.toLowerCase());
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public toPunyNullable(host: string | null | undefined): string | null {
|
||||
if (host == null) return null;
|
||||
return toASCII(host.toLowerCase());
|
||||
return punycode.toASCII(host.toLowerCase());
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -117,6 +123,26 @@ export class UtilityService {
|
|||
return host;
|
||||
}
|
||||
|
||||
private specialSuffix(hostname: string): string | null {
|
||||
// masto.host provides domain names for its clients, we have to
|
||||
// treat it as if it were a public suffix
|
||||
const mastoHost = hostname.match(/\.?([a-zA-Z0-9-]+\.masto\.host)$/i);
|
||||
if (mastoHost) {
|
||||
return mastoHost[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public punyHostPSLDomain(url: string): string {
|
||||
const urlObj = new URL(url);
|
||||
const hostname = urlObj.hostname;
|
||||
const domain = this.specialSuffix(hostname) ?? psl.get(hostname) ?? hostname;
|
||||
const host = `${this.toPuny(domain)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`;
|
||||
return host;
|
||||
}
|
||||
|
||||
public isFederationAllowedHost(host: string): boolean {
|
||||
if (this.meta.federation === 'none') return false;
|
||||
if (this.meta.federation === 'specified' && !this.meta.federationHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`))) return false;
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailed
|
|||
isRenoteMuted: false,
|
||||
notify: 'none',
|
||||
withReplies: true,
|
||||
ListenBrainz: null,
|
||||
listenbrainz: null,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,12 +10,14 @@ import type { Config } from '@/config.js';
|
|||
import { MemoryKVCache } from '@/misc/cache.js';
|
||||
import type { MiUserPublickey } from '@/models/UserPublickey.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
import { getApId } from './type.js';
|
||||
import { ApPersonService } from './models/ApPersonService.js';
|
||||
import type { IObject } from './type.js';
|
||||
import { ApLoggerService } from '@/core/activitypub/ApLoggerService.js';
|
||||
|
||||
export type UriParseResult = {
|
||||
/** wether the URI was generated by us */
|
||||
|
|
@ -53,17 +55,22 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
|||
|
||||
private cacheService: CacheService,
|
||||
private apPersonService: ApPersonService,
|
||||
private apLoggerService: ApLoggerService,
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
|
||||
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public parseUri(value: string | IObject): UriParseResult {
|
||||
public parseUri(value: string | IObject | [string | IObject]): UriParseResult {
|
||||
const separator = '/';
|
||||
|
||||
const uri = new URL(getApId(value));
|
||||
if (uri.origin !== this.config.url) return { local: false, uri: uri.href };
|
||||
const apId = getApId(value);
|
||||
const uri = new URL(apId);
|
||||
if (this.utilityService.toPuny(uri.host) !== this.utilityService.toPuny(this.config.host)) {
|
||||
return { local: false, uri: apId };
|
||||
}
|
||||
|
||||
const [, type, id, ...rest] = uri.pathname.split(separator);
|
||||
return {
|
||||
|
|
@ -78,7 +85,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
|||
* AP Note => Misskey Note in DB
|
||||
*/
|
||||
@bindThis
|
||||
public async getNoteFromApId(value: string | IObject): Promise<MiNote | null> {
|
||||
public async getNoteFromApId(value: string | IObject | [string | IObject]): Promise<MiNote | null> {
|
||||
const parsed = this.parseUri(value);
|
||||
|
||||
if (parsed.local) {
|
||||
|
|
@ -98,7 +105,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
|||
* AP Person => Misskey User in DB
|
||||
*/
|
||||
@bindThis
|
||||
public async getUserFromApId(value: string | IObject): Promise<MiLocalUser | MiRemoteUser | null> {
|
||||
public async getUserFromApId(value: string | IObject | [string | IObject]): Promise<MiLocalUser | MiRemoteUser | null> {
|
||||
const parsed = this.parseUri(value);
|
||||
|
||||
if (parsed.local) {
|
||||
|
|
@ -174,10 +181,16 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
|||
*/
|
||||
@bindThis
|
||||
public async refetchPublicKeyForApId(user: MiRemoteUser): Promise<MiUserPublickey | null> {
|
||||
this.apLoggerService.logger.debug('Re-fetching public key for user', { userId: user.id, uri: user.uri });
|
||||
await this.apPersonService.updatePerson(user.uri);
|
||||
|
||||
const key = await this.userPublickeysRepository.findOneBy({ userId: user.id });
|
||||
if (key != null) {
|
||||
await this.publicKeyByUserIdCache.set(user.id, key);
|
||||
this.publicKeyByUserIdCache.set(user.id, key);
|
||||
|
||||
if (key) {
|
||||
this.apLoggerService.logger.info('Re-fetched public key for user', { userId: user.id, uri: user.uri });
|
||||
} else {
|
||||
this.apLoggerService.logger.warn('Failed to re-fetch key for user', { userId: user.id, uri: user.uri });
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { FollowingsRepository } from '@/models/_.js';
|
||||
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
|
|
@ -128,7 +129,7 @@ class DeliverManager {
|
|||
|
||||
for (const following of followers) {
|
||||
const inbox = following.followerSharedInbox ?? following.followerInbox;
|
||||
if (inbox === null) throw new Error('inbox is null');
|
||||
if (inbox === null) throw new UnrecoverableError(`inbox is null: following ${following.id}`);
|
||||
inboxes.set(inbox, following.followerSharedInbox != null);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,9 @@ import type { MiRemoteUser } from '@/models/User.js';
|
|||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { AbuseReportService } from '@/core/AbuseReportService.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
|
||||
import { fromTuple } from '@/misc/from-tuple.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { getApHrefNullable, getApId, getApIds, getApType, getNullableApId, isAccept, isActor, isAdd, isAnnounce, isApObject, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isDislike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
|
||||
import { ApNoteService } from './models/ApNoteService.js';
|
||||
import { ApLoggerService } from './ApLoggerService.js';
|
||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||
|
|
@ -39,7 +41,7 @@ import { ApAudienceService } from './ApAudienceService.js';
|
|||
import { ApPersonService } from './models/ApPersonService.js';
|
||||
import { ApQuestionService } from './models/ApQuestionService.js';
|
||||
import type { Resolver } from './ApResolverService.js';
|
||||
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove, IPost } from './type.js';
|
||||
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IDislike, IObject, IReject, IRemove, IUndo, IUpdate, IMove, IPost } from './type.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApInboxService {
|
||||
|
|
@ -92,15 +94,26 @@ export class ApInboxService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async performActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> {
|
||||
public async performActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver): Promise<string | void> {
|
||||
let result = undefined as string | void;
|
||||
if (isCollectionOrOrderedCollection(activity)) {
|
||||
const results = [] as [string, string | void][];
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems);
|
||||
if (items.length >= resolver.getRecursionLimit()) {
|
||||
throw new Error(`skipping activity: collection would surpass recursion limit: ${this.utilityService.extractDbHost(actor.uri)}`);
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const act = await resolver.resolve(item);
|
||||
if (act.id == null || this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) {
|
||||
this.logger.debug('skipping activity: activity id is null or mismatching');
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
results.push([getApId(item), await this.performOneActivity(actor, act)]);
|
||||
results.push([getApId(item), await this.performOneActivity(actor, act, resolver)]);
|
||||
} catch (err) {
|
||||
if (err instanceof Error || typeof err === 'string') {
|
||||
this.logger.error(err);
|
||||
|
|
@ -115,7 +128,7 @@ export class ApInboxService {
|
|||
result = results.map(([id, reason]) => `${id}: ${reason}`).join('\n');
|
||||
}
|
||||
} else {
|
||||
result = await this.performOneActivity(actor, activity);
|
||||
result = await this.performOneActivity(actor, activity, resolver);
|
||||
}
|
||||
|
||||
// ついでにリモートユーザーの情報が古かったら更新しておく
|
||||
|
|
@ -130,37 +143,39 @@ export class ApInboxService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> {
|
||||
public async performOneActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver): Promise<string | void> {
|
||||
if (actor.isSuspended) return;
|
||||
|
||||
if (isCreate(activity)) {
|
||||
return await this.create(actor, activity);
|
||||
return await this.create(actor, activity, resolver);
|
||||
} else if (isDelete(activity)) {
|
||||
return await this.delete(actor, activity);
|
||||
} else if (isUpdate(activity)) {
|
||||
return await this.update(actor, activity);
|
||||
return await this.update(actor, activity, resolver);
|
||||
} else if (isFollow(activity)) {
|
||||
return await this.follow(actor, activity);
|
||||
} else if (isAccept(activity)) {
|
||||
return await this.accept(actor, activity);
|
||||
return await this.accept(actor, activity, resolver);
|
||||
} else if (isReject(activity)) {
|
||||
return await this.reject(actor, activity);
|
||||
return await this.reject(actor, activity, resolver);
|
||||
} else if (isAdd(activity)) {
|
||||
return await this.add(actor, activity);
|
||||
return await this.add(actor, activity, resolver);
|
||||
} else if (isRemove(activity)) {
|
||||
return await this.remove(actor, activity);
|
||||
return await this.remove(actor, activity, resolver);
|
||||
} else if (isAnnounce(activity)) {
|
||||
return await this.announce(actor, activity);
|
||||
return await this.announce(actor, activity, resolver);
|
||||
} else if (isLike(activity)) {
|
||||
return await this.like(actor, activity);
|
||||
return await this.like(actor, activity, resolver);
|
||||
} else if (isDislike(activity)) {
|
||||
return await this.dislike(actor, activity);
|
||||
} else if (isUndo(activity)) {
|
||||
return await this.undo(actor, activity);
|
||||
return await this.undo(actor, activity, resolver);
|
||||
} else if (isBlock(activity)) {
|
||||
return await this.block(actor, activity);
|
||||
} else if (isFlag(activity)) {
|
||||
return await this.flag(actor, activity);
|
||||
} else if (isMove(activity)) {
|
||||
return await this.move(actor, activity);
|
||||
return await this.move(actor, activity, resolver);
|
||||
} else {
|
||||
return `unrecognized activity type: ${activity.type}`;
|
||||
}
|
||||
|
|
@ -184,30 +199,42 @@ export class ApInboxService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async like(actor: MiRemoteUser, activity: ILike): Promise<string> {
|
||||
private async like(actor: MiRemoteUser, activity: ILike, resolver?: Resolver): Promise<string> {
|
||||
const targetUri = getApId(activity.object);
|
||||
|
||||
const note = await this.apNoteService.fetchNote(targetUri);
|
||||
const object = fromTuple(activity.object);
|
||||
if (!object) return 'skip: activity has no object property';
|
||||
|
||||
const note = await this.apNoteService.resolveNote(object, { resolver });
|
||||
if (!note) return `skip: target note not found ${targetUri}`;
|
||||
|
||||
await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null);
|
||||
|
||||
return await this.reactionService.create(actor, note, activity._misskey_reaction ?? activity.content ?? activity.name).catch(err => {
|
||||
if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') {
|
||||
try {
|
||||
await this.reactionService.create(actor, note, activity._misskey_reaction ?? activity.content ?? activity.name);
|
||||
return 'ok';
|
||||
} catch (err) {
|
||||
if (err instanceof IdentifiableError && err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') {
|
||||
return 'skip: already reacted';
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}).then(() => 'ok');
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async accept(actor: MiRemoteUser, activity: IAccept): Promise<string> {
|
||||
private async dislike(actor: MiRemoteUser, dislike: IDislike): Promise<string> {
|
||||
return await this.undoLike(actor, dislike);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async accept(actor: MiRemoteUser, activity: IAccept, resolver?: Resolver): Promise<string> {
|
||||
const uri = activity.id ?? activity;
|
||||
|
||||
this.logger.info(`Accept: ${uri}`);
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(err => {
|
||||
this.logger.error(`Resolution failed: ${err}`);
|
||||
|
|
@ -244,7 +271,7 @@ export class ApInboxService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async add(actor: MiRemoteUser, activity: IAdd): Promise<string | void> {
|
||||
private async add(actor: MiRemoteUser, activity: IAdd, resolver?: Resolver): Promise<string | void> {
|
||||
if (actor.uri !== activity.actor) {
|
||||
return 'invalid actor';
|
||||
}
|
||||
|
|
@ -254,7 +281,12 @@ export class ApInboxService {
|
|||
}
|
||||
|
||||
if (activity.target === actor.featured) {
|
||||
const note = await this.apNoteService.resolveNote(activity.object);
|
||||
const activityObject = fromTuple(activity.object);
|
||||
if (isApObject(activityObject) && !isPost(activityObject)) {
|
||||
return `unsupported featured object type: ${getApType(activityObject)}`;
|
||||
}
|
||||
|
||||
const note = await this.apNoteService.resolveNote(activityObject, { resolver });
|
||||
if (note == null) return 'note not found';
|
||||
await this.notePiningService.addPinned(actor, note.id);
|
||||
return;
|
||||
|
|
@ -264,20 +296,22 @@ export class ApInboxService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async announce(actor: MiRemoteUser, activity: IAnnounce): Promise<string | void> {
|
||||
private async announce(actor: MiRemoteUser, activity: IAnnounce, resolver?: Resolver): Promise<string | void> {
|
||||
const uri = getApId(activity);
|
||||
|
||||
this.logger.info(`Announce: ${uri}`);
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
if (!activity.object) return 'skip: activity has no object property';
|
||||
const targetUri = getApId(activity.object);
|
||||
const activityObject = fromTuple(activity.object);
|
||||
if (!activityObject) return 'skip: activity has no object property';
|
||||
const targetUri = getApId(activityObject);
|
||||
if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.';
|
||||
|
||||
const target = await resolver.resolve(activity.object).catch(e => {
|
||||
const target = await resolver.resolve(activityObject).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
return e;
|
||||
throw e;
|
||||
});
|
||||
|
||||
if (isPost(target)) return await this.announceNote(actor, activity, target);
|
||||
|
|
@ -286,7 +320,7 @@ export class ApInboxService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost): Promise<string | void> {
|
||||
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost, resolver?: Resolver): Promise<string | void> {
|
||||
const uri = getApId(activity);
|
||||
|
||||
if (actor.isSuspended) {
|
||||
|
|
@ -308,13 +342,13 @@ export class ApInboxService {
|
|||
// Announce対象をresolve
|
||||
let renote;
|
||||
try {
|
||||
renote = await this.apNoteService.resolveNote(target);
|
||||
renote = await this.apNoteService.resolveNote(target, { resolver });
|
||||
if (renote == null) return 'announce target is null';
|
||||
} catch (err) {
|
||||
// 対象が4xxならスキップ
|
||||
if (err instanceof StatusError) {
|
||||
if (!err.isRetryable) {
|
||||
return `Ignored announce target ${target.id} - ${err.statusCode}`;
|
||||
return `skip: ignored announce target ${target.id} - ${err.statusCode}`;
|
||||
}
|
||||
return `Error in announce target ${target.id} - ${err.statusCode}`;
|
||||
}
|
||||
|
|
@ -327,7 +361,7 @@ export class ApInboxService {
|
|||
|
||||
this.logger.info(`Creating the (Re)Note: ${uri}`);
|
||||
|
||||
const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc);
|
||||
const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc, resolver);
|
||||
const createdAt = activity.published ? new Date(activity.published) : null;
|
||||
|
||||
if (createdAt && createdAt < this.idService.parse(renote.id).date) {
|
||||
|
|
@ -365,47 +399,49 @@ export class ApInboxService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async create(actor: MiRemoteUser, activity: ICreate): Promise<string | void> {
|
||||
private async create(actor: MiRemoteUser, activity: ICreate | IUpdate, resolver?: Resolver): Promise<string | void> {
|
||||
const uri = getApId(activity);
|
||||
|
||||
this.logger.info(`Create: ${uri}`);
|
||||
|
||||
if (!activity.object) return 'skip: activity has no object property';
|
||||
const targetUri = getApId(activity.object);
|
||||
const activityObject = fromTuple(activity.object);
|
||||
if (!activityObject) return 'skip: activity has no object property';
|
||||
const targetUri = getApId(activityObject);
|
||||
if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.';
|
||||
|
||||
// copy audiences between activity <=> object.
|
||||
if (typeof activity.object === 'object') {
|
||||
const to = unique(concat([toArray(activity.to), toArray(activity.object.to)]));
|
||||
const cc = unique(concat([toArray(activity.cc), toArray(activity.object.cc)]));
|
||||
if (typeof activityObject === 'object') {
|
||||
const to = unique(concat([toArray(activity.to), toArray(activityObject.to)]));
|
||||
const cc = unique(concat([toArray(activity.cc), toArray(activityObject.cc)]));
|
||||
|
||||
activity.to = to;
|
||||
activity.cc = cc;
|
||||
activity.object.to = to;
|
||||
activity.object.cc = cc;
|
||||
activityObject.to = to;
|
||||
activityObject.cc = cc;
|
||||
}
|
||||
|
||||
// If there is no attributedTo, use Activity actor.
|
||||
if (typeof activity.object === 'object' && !activity.object.attributedTo) {
|
||||
activity.object.attributedTo = activity.actor;
|
||||
if (typeof activityObject === 'object' && !activityObject.attributedTo) {
|
||||
activityObject.attributedTo = activity.actor;
|
||||
}
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
const object = await resolver.resolve(activityObject).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
throw e;
|
||||
});
|
||||
|
||||
if (isPost(object)) {
|
||||
await this.createNote(resolver, actor, object, false, activity);
|
||||
await this.createNote(resolver, actor, object, false);
|
||||
} else {
|
||||
return `Unknown type: ${getApType(object)}`;
|
||||
return `skip: Unsupported type for Create: ${getApType(object)} ${getNullableApId(object)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async createNote(resolver: Resolver, actor: MiRemoteUser, note: IObject, silent = false, activity?: ICreate): Promise<string> {
|
||||
private async createNote(resolver: Resolver, actor: MiRemoteUser, note: IObject, silent = false): Promise<string> {
|
||||
const uri = getApId(note);
|
||||
|
||||
if (typeof note === 'object') {
|
||||
|
|
@ -417,6 +453,8 @@ export class ApInboxService {
|
|||
if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) {
|
||||
return 'skip: host in actor.uri !== note.id';
|
||||
}
|
||||
} else {
|
||||
return 'skip: note.id is not a string';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -426,11 +464,11 @@ export class ApInboxService {
|
|||
const exist = await this.apNoteService.fetchNote(note);
|
||||
if (exist) return 'skip: note exists';
|
||||
|
||||
await this.apNoteService.createNote(note, resolver, silent);
|
||||
await this.apNoteService.createNote(note, actor, resolver, silent);
|
||||
return 'ok';
|
||||
} catch (err) {
|
||||
if (err instanceof StatusError && !err.isRetryable) {
|
||||
return `skip ${err.statusCode}`;
|
||||
return `skip: ${err.statusCode}`;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
|
|
@ -448,15 +486,15 @@ export class ApInboxService {
|
|||
// 削除対象objectのtype
|
||||
let formerType: string | undefined;
|
||||
|
||||
if (typeof activity.object === 'string') {
|
||||
const activityObject = fromTuple(activity.object);
|
||||
if (typeof activityObject === 'string') {
|
||||
// typeが不明だけど、どうせ消えてるのでremote resolveしない
|
||||
formerType = undefined;
|
||||
} else {
|
||||
const object = activity.object;
|
||||
if (isTombstone(object)) {
|
||||
formerType = toSingle(object.formerType);
|
||||
if (isTombstone(activityObject)) {
|
||||
formerType = toSingle(activityObject.formerType);
|
||||
} else {
|
||||
formerType = toSingle(object.type);
|
||||
formerType = toSingle(activityObject.type);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -517,7 +555,7 @@ export class ApInboxService {
|
|||
const note = await this.apDbResolverService.getNoteFromApId(uri);
|
||||
|
||||
if (note == null) {
|
||||
return 'message not found';
|
||||
return 'skip: ignoring deleted note on both ends';
|
||||
}
|
||||
|
||||
if (note.userId !== actor.id) {
|
||||
|
|
@ -564,12 +602,13 @@ export class ApInboxService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async reject(actor: MiRemoteUser, activity: IReject): Promise<string> {
|
||||
private async reject(actor: MiRemoteUser, activity: IReject, resolver?: Resolver): Promise<string> {
|
||||
const uri = activity.id ?? activity;
|
||||
|
||||
this.logger.info(`Reject: ${uri}`);
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
|
|
@ -606,7 +645,7 @@ export class ApInboxService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async remove(actor: MiRemoteUser, activity: IRemove): Promise<string | void> {
|
||||
private async remove(actor: MiRemoteUser, activity: IRemove, resolver?: Resolver): Promise<string | void> {
|
||||
if (actor.uri !== activity.actor) {
|
||||
return 'invalid actor';
|
||||
}
|
||||
|
|
@ -616,7 +655,12 @@ export class ApInboxService {
|
|||
}
|
||||
|
||||
if (activity.target === actor.featured) {
|
||||
const note = await this.apNoteService.resolveNote(activity.object);
|
||||
const activityObject = fromTuple(activity.object);
|
||||
if (isApObject(activityObject) && !isPost(activityObject)) {
|
||||
return `unsupported featured object type: ${getApType(activityObject)}`;
|
||||
}
|
||||
|
||||
const note = await this.apNoteService.resolveNote(activityObject, { resolver });
|
||||
if (note == null) return 'note not found';
|
||||
await this.notePiningService.removePinned(actor, note.id);
|
||||
return;
|
||||
|
|
@ -626,7 +670,7 @@ export class ApInboxService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async undo(actor: MiRemoteUser, activity: IUndo): Promise<string> {
|
||||
private async undo(actor: MiRemoteUser, activity: IUndo, resolver?: Resolver): Promise<string> {
|
||||
if (actor.uri !== activity.actor) {
|
||||
return 'invalid actor';
|
||||
}
|
||||
|
|
@ -635,11 +679,12 @@ export class ApInboxService {
|
|||
|
||||
this.logger.info(`Undo: ${uri}`);
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
return e;
|
||||
throw e;
|
||||
});
|
||||
|
||||
// don't queue because the sender may attempt again when timeout
|
||||
|
|
@ -649,7 +694,7 @@ export class ApInboxService {
|
|||
if (isAnnounce(object)) return await this.undoAnnounce(actor, object);
|
||||
if (isAccept(object)) return await this.undoAccept(actor, object);
|
||||
|
||||
return `skip: unknown object type ${getApType(object)}`;
|
||||
return `skip: unknown activity type ${getApType(object)}`;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -744,7 +789,7 @@ export class ApInboxService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async undoLike(actor: MiRemoteUser, activity: ILike): Promise<string> {
|
||||
private async undoLike(actor: MiRemoteUser, activity: ILike | IDislike): Promise<string> {
|
||||
const targetUri = getApId(activity.object);
|
||||
|
||||
const note = await this.apNoteService.fetchNote(targetUri);
|
||||
|
|
@ -759,14 +804,15 @@ export class ApInboxService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
private async update(actor: MiRemoteUser, activity: IUpdate): Promise<string> {
|
||||
private async update(actor: MiRemoteUser, activity: IUpdate, resolver?: Resolver): Promise<string | void> {
|
||||
if (actor.uri !== activity.actor) {
|
||||
return 'skip: invalid actor';
|
||||
}
|
||||
|
||||
this.logger.debug('Update');
|
||||
|
||||
const resolver = this.apResolverService.createResolver();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
resolver ??= this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(activity.object).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
|
|
@ -777,22 +823,32 @@ export class ApInboxService {
|
|||
await this.apPersonService.updatePerson(actor.uri, resolver, object);
|
||||
return 'ok: Person updated';
|
||||
} else if (getApType(object) === 'Question') {
|
||||
await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err));
|
||||
// If we get an Update(Question) for a note that doesn't exist, then create it instead
|
||||
if (!await this.apNoteService.hasNote(object)) {
|
||||
return await this.create(actor, activity, resolver);
|
||||
}
|
||||
|
||||
await this.apQuestionService.updateQuestion(object, actor, resolver);
|
||||
return 'ok: Question updated';
|
||||
} else if (getApType(object) === 'Note') {
|
||||
await this.apNoteService.updateNote(object, resolver).catch(err => console.error(err));
|
||||
} else if (isPost(object)) {
|
||||
// If we get an Update(Note) for a note that doesn't exist, then create it instead
|
||||
if (!await this.apNoteService.hasNote(object)) {
|
||||
return await this.create(actor, activity, resolver);
|
||||
}
|
||||
|
||||
await this.apNoteService.updateNote(object, actor, resolver);
|
||||
return 'ok: Note updated';
|
||||
} else {
|
||||
return `skip: Unknown type: ${getApType(object)}`;
|
||||
return `skip: Unsupported type for Update: ${getApType(object)} ${getNullableApId(object)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async move(actor: MiRemoteUser, activity: IMove): Promise<string> {
|
||||
private async move(actor: MiRemoteUser, activity: IMove, resolver?: Resolver): Promise<string> {
|
||||
// fetch the new and old accounts
|
||||
const targetUri = getApHrefNullable(activity.target);
|
||||
if (!targetUri) return 'skip: invalid activity target';
|
||||
|
||||
return await this.apPersonService.updatePerson(actor.uri) ?? 'skip: nothing to do';
|
||||
return await this.apPersonService.updatePerson(actor.uri, resolver) ?? 'skip: nothing to do';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { createPublicKey, randomUUID } from 'node:crypto';
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import * as mfm from '@transfem-org/sfm-js';
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { MiPartialLocalUser, MiLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
|
|
@ -30,6 +31,7 @@ import { IdService } from '@/core/IdService.js';
|
|||
import { JsonLdService } from './JsonLdService.js';
|
||||
import { ApMfmService } from './ApMfmService.js';
|
||||
import { CONTEXT } from './misc/contexts.js';
|
||||
import { getApId } from './type.js';
|
||||
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -106,7 +108,7 @@ export class ApRendererService {
|
|||
to = [`${attributedTo}/followers`];
|
||||
cc = [];
|
||||
} else {
|
||||
throw new Error('renderAnnounce: cannot render non-public note');
|
||||
throw new UnrecoverableError(`renderAnnounce: cannot render non-public note: ${getApId(object)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -200,7 +202,8 @@ export class ApRendererService {
|
|||
type: 'Flag',
|
||||
actor: this.userEntityService.genLocalUserUri(user.id),
|
||||
content,
|
||||
object,
|
||||
// This MUST be an array for Pleroma compatibility: https://activitypub.software/TransFem-org/Sharkey/-/issues/641#note_7301
|
||||
object: [object],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -419,7 +422,7 @@ export class ApRendererService {
|
|||
|
||||
const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
|
||||
|
||||
const { content, noMisskeyContent } = this.apMfmService.getNoteHtml(note, apAppend);
|
||||
const { content } = this.apMfmService.getNoteHtml(note, apAppend);
|
||||
|
||||
const emojis = await this.getEmojis(note.emojis);
|
||||
const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
|
||||
|
|
@ -449,13 +452,11 @@ export class ApRendererService {
|
|||
attributedTo,
|
||||
summary: summary ?? undefined,
|
||||
content: content ?? undefined,
|
||||
...(noMisskeyContent ? {} : {
|
||||
_misskey_content: text,
|
||||
source: {
|
||||
content: text,
|
||||
mediaType: 'text/x.misskeymarkdown',
|
||||
},
|
||||
}),
|
||||
_misskey_content: text,
|
||||
source: {
|
||||
content: text,
|
||||
mediaType: 'text/x.misskeymarkdown',
|
||||
},
|
||||
_misskey_quote: quote,
|
||||
quoteUrl: quote,
|
||||
quoteUri: quote,
|
||||
|
|
@ -470,6 +471,7 @@ export class ApRendererService {
|
|||
};
|
||||
}
|
||||
|
||||
// if you change this, also change `server/api/endpoints/i/update.ts`
|
||||
@bindThis
|
||||
public async renderPerson(user: MiLocalUser) {
|
||||
const id = this.userEntityService.genLocalUserUri(user.id);
|
||||
|
|
@ -712,7 +714,7 @@ export class ApRendererService {
|
|||
|
||||
const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
|
||||
|
||||
const { content, noMisskeyContent } = this.apMfmService.getNoteHtml(note, apAppend);
|
||||
const { content } = this.apMfmService.getNoteHtml(note, apAppend);
|
||||
|
||||
const emojis = await this.getEmojis(note.emojis);
|
||||
const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
|
||||
|
|
@ -743,13 +745,11 @@ export class ApRendererService {
|
|||
summary: summary ?? undefined,
|
||||
content: content ?? undefined,
|
||||
updated: note.updatedAt?.toISOString(),
|
||||
...(noMisskeyContent ? {} : {
|
||||
_misskey_content: text,
|
||||
source: {
|
||||
content: text,
|
||||
mediaType: 'text/x.misskeymarkdown',
|
||||
},
|
||||
}),
|
||||
_misskey_content: text,
|
||||
source: {
|
||||
content: text,
|
||||
mediaType: 'text/x.misskeymarkdown',
|
||||
},
|
||||
_misskey_quote: quote,
|
||||
quoteUrl: quote,
|
||||
quoteUri: quote,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import type Logger from '@/logger.js';
|
|||
import type { IObject } from './type.js';
|
||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
|
||||
import { UtilityService } from "@/core/UtilityService.js";
|
||||
|
||||
type Request = {
|
||||
url: string;
|
||||
|
|
@ -147,6 +148,7 @@ export class ApRequestService {
|
|||
private userKeypairService: UserKeypairService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private loggerService: LoggerService,
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
|
||||
|
|
@ -241,7 +243,9 @@ export class ApRequestService {
|
|||
if (alternate) {
|
||||
const href = alternate.getAttribute('href');
|
||||
if (href) {
|
||||
return await this.signedGet(href, user, false);
|
||||
if (this.utilityService.punyHostPSLDomain(url) === this.utilityService.punyHostPSLDomain(href)) {
|
||||
return await this.signedGet(href, user, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -257,7 +261,7 @@ export class ApRequestService {
|
|||
const finalUrl = res.url; // redirects may have been involved
|
||||
const activity = await res.json() as IObject;
|
||||
|
||||
assertActivityMatchesUrls(activity, [url, finalUrl]);
|
||||
assertActivityMatchesUrls(activity, [finalUrl]);
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||
import { InstanceActorService } from '@/core/InstanceActorService.js';
|
||||
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js';
|
||||
|
|
@ -15,6 +16,7 @@ import { UtilityService } from '@/core/UtilityService.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { fromTuple } from '@/misc/from-tuple.js';
|
||||
import { isCollectionOrOrderedCollection } from './type.js';
|
||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||
import { ApRendererService } from './ApRendererService.js';
|
||||
|
|
@ -41,7 +43,7 @@ export class Resolver {
|
|||
private apRendererService: ApRendererService,
|
||||
private apDbResolverService: ApDbResolverService,
|
||||
private loggerService: LoggerService,
|
||||
private recursionLimit = 100,
|
||||
private recursionLimit = 256,
|
||||
) {
|
||||
this.history = new Set();
|
||||
this.logger = this.loggerService.getLogger('ap-resolve');
|
||||
|
|
@ -52,6 +54,11 @@ export class Resolver {
|
|||
return Array.from(this.history);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getRecursionLimit(): number {
|
||||
return this.recursionLimit;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
|
||||
const collection = typeof value === 'string'
|
||||
|
|
@ -61,12 +68,15 @@ export class Resolver {
|
|||
if (isCollectionOrOrderedCollection(collection)) {
|
||||
return collection;
|
||||
} else {
|
||||
throw new Error(`unrecognized collection type: ${collection.type}`);
|
||||
throw new UnrecoverableError(`unrecognized collection type: ${collection.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async resolve(value: string | IObject): Promise<IObject> {
|
||||
public async resolve(value: string | IObject | [string | IObject]): Promise<IObject> {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
value = fromTuple(value);
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
|
@ -75,15 +85,15 @@ export class Resolver {
|
|||
// URLs with fragment parts cannot be resolved correctly because
|
||||
// the fragment part does not get transmitted over HTTP(S).
|
||||
// Avoid strange behaviour by not trying to resolve these at all.
|
||||
throw new Error(`cannot resolve URL with fragment: ${value}`);
|
||||
throw new UnrecoverableError(`cannot resolve URL with fragment: ${value}`);
|
||||
}
|
||||
|
||||
if (this.history.has(value)) {
|
||||
throw new Error('cannot resolve already resolved one');
|
||||
throw new Error(`cannot resolve already resolved URL: ${value}`);
|
||||
}
|
||||
|
||||
if (this.history.size > this.recursionLimit) {
|
||||
throw new Error(`hit recursion limit: ${this.utilityService.extractDbHost(value)}`);
|
||||
throw new Error(`hit recursion limit: ${value}`);
|
||||
}
|
||||
|
||||
this.history.add(value);
|
||||
|
|
@ -94,7 +104,7 @@ export class Resolver {
|
|||
}
|
||||
|
||||
if (!this.utilityService.isFederationAllowedHost(host)) {
|
||||
throw new Error('Instance is blocked');
|
||||
throw new UnrecoverableError(`instance is blocked: ${value}`);
|
||||
}
|
||||
|
||||
if (this.config.signToActivityPubGet && !this.user) {
|
||||
|
|
@ -110,15 +120,19 @@ export class Resolver {
|
|||
!(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') :
|
||||
object['@context'] !== 'https://www.w3.org/ns/activitystreams'
|
||||
) {
|
||||
throw new Error('invalid response');
|
||||
throw new UnrecoverableError(`invalid AP object ${value}: does not have ActivityStreams context`);
|
||||
}
|
||||
|
||||
// HttpRequestService / ApRequestService have already checked that
|
||||
// `object.id` or `object.url` matches the URL used to fetch the
|
||||
// object after redirects; here we double-check that no redirects
|
||||
// bounced between hosts
|
||||
if (object.id && (this.utilityService.punyHost(object.id) !== this.utilityService.punyHost(value))) {
|
||||
throw new Error(`invalid AP object ${value}: id ${object.id} has different host`);
|
||||
if (object.id == null) {
|
||||
throw new UnrecoverableError(`invalid AP object ${value}: missing id`);
|
||||
}
|
||||
|
||||
if (this.utilityService.punyHostPSLDomain(object.id) !== this.utilityService.punyHostPSLDomain(value)) {
|
||||
throw new UnrecoverableError(`invalid AP object ${value}: id ${object.id} has different host`);
|
||||
}
|
||||
|
||||
return object;
|
||||
|
|
@ -127,7 +141,7 @@ export class Resolver {
|
|||
@bindThis
|
||||
private resolveLocal(url: string): Promise<IObject> {
|
||||
const parsed = this.apDbResolverService.parseUri(url);
|
||||
if (!parsed.local) throw new Error('resolveLocal: not local');
|
||||
if (!parsed.local) throw new UnrecoverableError(`resolveLocal - not a local URL: ${url}`);
|
||||
|
||||
switch (parsed.type) {
|
||||
case 'notes':
|
||||
|
|
@ -156,7 +170,7 @@ export class Resolver {
|
|||
case 'follows':
|
||||
return this.followRequestsRepository.findOneBy({ id: parsed.id })
|
||||
.then(async followRequest => {
|
||||
if (followRequest == null) throw new Error('resolveLocal: invalid follow request ID');
|
||||
if (followRequest == null) throw new UnrecoverableError(`resolveLocal - invalid follow request ID ${parsed.id}: ${url}`);
|
||||
const [follower, followee] = await Promise.all([
|
||||
this.usersRepository.findOneBy({
|
||||
id: followRequest.followerId,
|
||||
|
|
@ -168,12 +182,12 @@ export class Resolver {
|
|||
}),
|
||||
]);
|
||||
if (follower == null || followee == null) {
|
||||
throw new Error('resolveLocal: follower or followee does not exist');
|
||||
throw new Error(`resolveLocal - follower or followee does not exist: ${url}`);
|
||||
}
|
||||
return this.apRendererService.addContext(this.apRendererService.renderFollow(follower as MiLocalUser | MiRemoteUser, followee as MiLocalUser | MiRemoteUser, url));
|
||||
});
|
||||
default:
|
||||
throw new Error(`resolveLocal: type ${parsed.type} unhandled`);
|
||||
throw new UnrecoverableError(`resolveLocal: type ${parsed.type} unhandled: ${url}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import * as crypto from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { CONTEXT, PRELOADED_CONTEXTS } from './misc/contexts.js';
|
||||
|
|
@ -109,7 +110,7 @@ class JsonLd {
|
|||
@bindThis
|
||||
private getLoader() {
|
||||
return async (url: string): Promise<RemoteDocument> => {
|
||||
if (!/^https?:\/\//.test(url)) throw new Error(`Invalid URL ${url}`);
|
||||
if (!/^https?:\/\//.test(url)) throw new UnrecoverableError(`Invalid URL: ${url}`);
|
||||
|
||||
if (this.preLoad) {
|
||||
if (url in PRELOADED_CONTEXTS) {
|
||||
|
|
@ -148,7 +149,7 @@ class JsonLd {
|
|||
},
|
||||
).then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`${res.status} ${res.statusText}`);
|
||||
throw new Error(`JSON-LD fetch failed with ${res.status} ${res.statusText}: ${url}`);
|
||||
} else {
|
||||
return res.json();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,26 +2,30 @@
|
|||
* SPDX-FileCopyrightText: dakkar and sharkey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
import type { IObject } from '../type.js';
|
||||
|
||||
function getHrefFrom(one: IObject|string): string | undefined {
|
||||
if (typeof(one) === 'string') return one;
|
||||
return one.href;
|
||||
function getHrefsFrom(one: IObject | string | undefined | (IObject | string | undefined)[]): (string | undefined)[] {
|
||||
if (Array.isArray(one)) {
|
||||
return one.flatMap(h => getHrefsFrom(h));
|
||||
}
|
||||
return [
|
||||
typeof(one) === 'object' ? one.href : one,
|
||||
];
|
||||
}
|
||||
|
||||
export function assertActivityMatchesUrls(activity: IObject, urls: string[]) {
|
||||
const idOk = activity.id !== undefined && urls.includes(activity.id);
|
||||
if (idOk) return;
|
||||
const expectedUrls = new Set(urls
|
||||
.filter(u => URL.canParse(u))
|
||||
.map(u => new URL(u).href),
|
||||
);
|
||||
|
||||
const url = activity.url;
|
||||
if (url) {
|
||||
// `activity.url` can be an `ApObject = IObject | string | (IObject
|
||||
// | string)[]`, we have to look inside it
|
||||
const activityUrls = Array.isArray(url) ? url.map(getHrefFrom) : [getHrefFrom(url)];
|
||||
const goodUrl = activityUrls.find(u => u && urls.includes(u));
|
||||
const actualUrls = [activity.id, ...getHrefsFrom(activity.url)]
|
||||
.filter(u => u && URL.canParse(u))
|
||||
.map(u => new URL(u as string).href);
|
||||
|
||||
if (goodUrl) return;
|
||||
if (!actualUrls.some(u => expectedUrls.has(u))) {
|
||||
throw new UnrecoverableError(`bad Activity: neither id(${activity.id}) nor url(${JSON.stringify(activity.url)}) match location(${urls})`);
|
||||
}
|
||||
|
||||
throw new Error(`bad Activity: neither id(${activity?.id}) nor url(${JSON.stringify(activity?.url)}) match location(${urls})`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export function validateContentTypeSetAsActivityPub(response: Response): void {
|
|||
const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
|
||||
|
||||
if (contentType === '') {
|
||||
throw new Error('Validate content type of AP response: No content-type header');
|
||||
throw new Error(`invalid content type of AP response - no content-type header: ${response.url}`);
|
||||
}
|
||||
if (
|
||||
contentType.startsWith('application/activity+json') ||
|
||||
|
|
@ -17,7 +17,7 @@ export function validateContentTypeSetAsActivityPub(response: Response): void {
|
|||
) {
|
||||
return;
|
||||
}
|
||||
throw new Error('Validate content type of AP response: Content type is not application/activity+json or application/ld+json');
|
||||
throw new Error(`invalid content type of AP response - content type is not application/activity+json or application/ld+json: ${response.url}`);
|
||||
}
|
||||
|
||||
const plusJsonSuffixRegex = /^\s*(application|text)\/[a-zA-Z0-9\.\-\+]+\+json\s*(;|$)/;
|
||||
|
|
@ -26,7 +26,7 @@ export function validateContentTypeSetAsJsonLD(response: Response): void {
|
|||
const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
|
||||
|
||||
if (contentType === '') {
|
||||
throw new Error('Validate content type of JSON LD: No content-type header');
|
||||
throw new Error(`invalid content type of JSON LD - no content-type header: ${response.url}`);
|
||||
}
|
||||
if (
|
||||
contentType.startsWith('application/ld+json') ||
|
||||
|
|
@ -35,5 +35,5 @@ export function validateContentTypeSetAsJsonLD(response: Response): void {
|
|||
) {
|
||||
return;
|
||||
}
|
||||
throw new Error('Validate content type of JSON LD: Content type is not application/ld+json or application/json');
|
||||
throw new Error(`invalid content type of JSON LD - content type is not application/ld+json or application/json: ${response.url}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,13 @@ import type { DriveFilesRepository, MiMeta } from '@/models/_.js';
|
|||
import type { MiRemoteUser } from '@/models/User.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import { truncate } from '@/misc/truncate.js';
|
||||
import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js';
|
||||
import { DriveService } from '@/core/DriveService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { checkHttps } from '@/misc/check-https.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { ApResolverService } from '../ApResolverService.js';
|
||||
import { ApLoggerService } from '../ApLoggerService.js';
|
||||
import { isDocument, type IObject } from '../type.js';
|
||||
|
|
@ -29,6 +30,8 @@ export class ApImageService {
|
|||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private apResolverService: ApResolverService,
|
||||
private driveService: DriveService,
|
||||
|
|
@ -45,7 +48,7 @@ export class ApImageService {
|
|||
public async createImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile | null> {
|
||||
// 投稿者が凍結されていたらスキップ
|
||||
if (actor.isSuspended) {
|
||||
throw new Error('actor has been suspended');
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor has been suspended: ${actor.uri}`);
|
||||
}
|
||||
|
||||
const image = await this.apResolverService.createResolver().resolve(value);
|
||||
|
|
@ -83,7 +86,7 @@ export class ApImageService {
|
|||
uri: image.url,
|
||||
sensitive: !!(image.sensitive),
|
||||
isLink: !shouldBeCached,
|
||||
comment: truncate(image.name ?? undefined, DB_MAX_IMAGE_COMMENT_LENGTH),
|
||||
comment: truncate(image.name ?? undefined, this.config.maxRemoteAltTextLength),
|
||||
});
|
||||
if (!file.isLink || file.url === image.url) return file;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@
|
|||
|
||||
import { forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { PollsRepository, EmojisRepository, NotesRepository, MiMeta } from '@/models/_.js';
|
||||
import type { UsersRepository, PollsRepository, EmojisRepository, NotesRepository, MiMeta } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { MiRemoteUser } from '@/models/User.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
|
|
@ -49,6 +50,9 @@ export class ApNoteService {
|
|||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.pollsRepository)
|
||||
private pollsRepository: PollsRepository,
|
||||
|
||||
|
|
@ -82,7 +86,13 @@ export class ApNoteService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public validateNote(object: IObject, uri: string): Error | null {
|
||||
public validateNote(
|
||||
object: IObject,
|
||||
uri: string,
|
||||
actor?: MiRemoteUser,
|
||||
user?: MiRemoteUser,
|
||||
note?: MiNote,
|
||||
): Error | null {
|
||||
const expectHost = this.utilityService.extractDbHost(uri);
|
||||
const apType = getApType(object);
|
||||
|
||||
|
|
@ -99,10 +109,27 @@ export class ApNoteService {
|
|||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`);
|
||||
}
|
||||
|
||||
if (actor) {
|
||||
const attribution = (object.attributedTo) ? getOneApId(object.attributedTo) : actor.uri;
|
||||
if (attribution !== actor.uri) {
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attribution does not match the actor that send it. attribution: ${attribution}, actor: ${actor.uri}`);
|
||||
}
|
||||
if (user && attribution !== user.uri) {
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: updated attribution does not match original attribution. updated attribution: ${user.uri}, original attribution: ${attribution}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (object.published && !this.idService.isSafeT(new Date(object.published).valueOf())) {
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed');
|
||||
}
|
||||
|
||||
if (note) {
|
||||
const url = (object.url) ? getOneApId(object.url) : note.url;
|
||||
if (url && url !== note.url) {
|
||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: updated url does not match original url. updated url: ${url}, original url: ${note.url}`);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -116,18 +143,27 @@ export class ApNoteService {
|
|||
return await this.apDbResolverService.getNoteFromApId(object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the provided object / ID exists in the local database.
|
||||
*/
|
||||
@bindThis
|
||||
public async hasNote(object: string | IObject | [string | IObject]): Promise<boolean> {
|
||||
const uri = getApId(object);
|
||||
return await this.notesRepository.existsBy({ uri });
|
||||
}
|
||||
|
||||
/**
|
||||
* Noteを作成します。
|
||||
*/
|
||||
@bindThis
|
||||
public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<MiNote | null> {
|
||||
public async createNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise<MiNote | null> {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(value);
|
||||
|
||||
const entryUri = getApId(value);
|
||||
const err = this.validateNote(object, entryUri);
|
||||
const err = this.validateNote(object, entryUri, actor);
|
||||
if (err) {
|
||||
this.logger.error(err.message, {
|
||||
resolver: { history: resolver.getHistory() },
|
||||
|
|
@ -141,29 +177,40 @@ export class ApNoteService {
|
|||
|
||||
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
||||
|
||||
if (note.id && !checkHttps(note.id)) {
|
||||
throw new Error('unexpected schema of note.id: ' + note.id);
|
||||
if (note.id == null) {
|
||||
throw new UnrecoverableError(`Refusing to create note without id: ${entryUri}`);
|
||||
}
|
||||
|
||||
if (!checkHttps(note.id)) {
|
||||
throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${entryUri}`);
|
||||
}
|
||||
|
||||
const url = getOneApHrefNullable(note.url);
|
||||
|
||||
if (url && !checkHttps(url)) {
|
||||
throw new Error('unexpected schema of note url: ' + url);
|
||||
if (url != null) {
|
||||
if (!checkHttps(url)) {
|
||||
throw new UnrecoverableError(`unexpected schema of note.url ${url} in ${entryUri}`);
|
||||
}
|
||||
|
||||
if (this.utilityService.punyHostPSLDomain(url) !== this.utilityService.punyHostPSLDomain(note.id)) {
|
||||
throw new UnrecoverableError(`note url <> uri host mismatch: ${url} <> ${note.id} in ${entryUri}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info(`Creating the Note: ${note.id}`);
|
||||
|
||||
// 投稿者をフェッチ
|
||||
if (note.attributedTo == null) {
|
||||
throw new Error('invalid note.attributedTo: ' + note.attributedTo);
|
||||
throw new UnrecoverableError(`invalid note.attributedTo ${note.attributedTo} in ${entryUri}`);
|
||||
}
|
||||
|
||||
const uri = getOneApId(note.attributedTo);
|
||||
|
||||
// ローカルで投稿者を検索し、もし凍結されていたらスキップ
|
||||
const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser;
|
||||
if (cachedActor && cachedActor.isSuspended) {
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
actor ??= await this.apPersonService.fetchPerson(uri) as MiRemoteUser | undefined;
|
||||
if (actor && actor.isSuspended) {
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor ${uri} has been suspended: ${entryUri}`);
|
||||
}
|
||||
|
||||
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
|
||||
|
|
@ -190,15 +237,16 @@ export class ApNoteService {
|
|||
*/
|
||||
const hasProhibitedWords = this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices });
|
||||
if (hasProhibitedWords) {
|
||||
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words');
|
||||
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', `Note contains prohibited words: ${entryUri}`);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const actor = cachedActor ?? await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
actor ??= await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
|
||||
|
||||
// 解決した投稿者が凍結されていたらスキップ
|
||||
if (actor.isSuspended) {
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor has been suspended: ${entryUri}`);
|
||||
}
|
||||
|
||||
const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
|
||||
|
|
@ -228,13 +276,13 @@ export class ApNoteService {
|
|||
.then(x => {
|
||||
if (x == null) {
|
||||
this.logger.warn('Specified inReplyTo, but not found');
|
||||
throw new Error('inReplyTo not found');
|
||||
throw new Error(`could not fetch inReplyTo ${note.inReplyTo} for note ${entryUri}`);
|
||||
}
|
||||
|
||||
return x;
|
||||
})
|
||||
.catch(async err => {
|
||||
this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`);
|
||||
this.logger.warn(`error ${err.statusCode ?? err} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`);
|
||||
throw err;
|
||||
})
|
||||
: null;
|
||||
|
|
@ -243,16 +291,25 @@ export class ApNoteService {
|
|||
let quote: MiNote | undefined | null = null;
|
||||
|
||||
if (note._misskey_quote ?? note.quoteUrl ?? note.quoteUri) {
|
||||
const tryResolveNote = async (uri: string): Promise<
|
||||
const tryResolveNote = async (uri: unknown): Promise<
|
||||
| { status: 'ok'; res: MiNote }
|
||||
| { status: 'permerror' | 'temperror' }
|
||||
> => {
|
||||
if (typeof uri !== 'string' || !/^https?:/.test(uri)) return { status: 'permerror' };
|
||||
if (typeof uri !== 'string' || !/^https?:/.test(uri)) {
|
||||
this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: URI is invalid`);
|
||||
return { status: 'permerror' };
|
||||
}
|
||||
try {
|
||||
const res = await this.resolveNote(uri, { resolver });
|
||||
if (res == null) return { status: 'permerror' };
|
||||
if (res == null) {
|
||||
this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: resolution error`);
|
||||
return { status: 'permerror' };
|
||||
}
|
||||
return { status: 'ok', res };
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
|
||||
this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: ${error}`);
|
||||
|
||||
return {
|
||||
status: (e instanceof StatusError && !e.isRetryable) ? 'permerror' : 'temperror',
|
||||
};
|
||||
|
|
@ -265,7 +322,7 @@ export class ApNoteService {
|
|||
quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0);
|
||||
if (!quote) {
|
||||
if (results.some(x => x.status === 'temperror')) {
|
||||
throw new Error('quote resolve failed');
|
||||
throw new Error(`temporary error resolving quote for ${entryUri}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -325,7 +382,7 @@ export class ApNoteService {
|
|||
this.logger.info('The note is already inserted while creating itself, reading again');
|
||||
const duplicate = await this.fetchNote(value);
|
||||
if (!duplicate) {
|
||||
throw new Error('The note creation failed with duplication error even when there is no duplication');
|
||||
throw new Error(`The note creation failed with duplication error even when there is no duplication: ${entryUri}`);
|
||||
}
|
||||
return duplicate;
|
||||
}
|
||||
|
|
@ -335,16 +392,18 @@ export class ApNoteService {
|
|||
* Noteを作成します。
|
||||
*/
|
||||
@bindThis
|
||||
public async updateNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<MiNote | null> {
|
||||
const noteUri = typeof value === 'string' ? value : value.id;
|
||||
if (noteUri == null) throw new Error('uri is null');
|
||||
public async updateNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise<MiNote | null> {
|
||||
const noteUri = getApId(value);
|
||||
|
||||
// URIがこのサーバーを指しているならスキップ
|
||||
if (noteUri.startsWith(this.config.url + '/')) throw new Error('uri points local');
|
||||
if (noteUri.startsWith(this.config.url + '/')) throw new UnrecoverableError(`uri points local: ${noteUri}`);
|
||||
|
||||
//#region このサーバーに既に登録されているか
|
||||
const UpdatedNote = await this.notesRepository.findOneBy({ uri: noteUri });
|
||||
if (UpdatedNote == null) throw new Error('Note is not registered');
|
||||
const updatedNote = await this.notesRepository.findOneBy({ uri: noteUri });
|
||||
if (updatedNote == null) throw new Error(`Note is not registered (no note): ${noteUri}`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: updatedNote.userId }) as MiRemoteUser | null;
|
||||
if (user == null) throw new Error(`Note is not registered (no user): ${noteUri}`);
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
|
|
@ -362,33 +421,38 @@ export class ApNoteService {
|
|||
throw err;
|
||||
}
|
||||
|
||||
// `validateNote` checks that the actor and user are one and the same
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
actor ??= user;
|
||||
|
||||
const note = object as IPost;
|
||||
|
||||
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
||||
|
||||
if (note.id && !checkHttps(note.id)) {
|
||||
throw new Error('unexpected schema of note.id: ' + note.id);
|
||||
if (note.id == null) {
|
||||
throw new UnrecoverableError(`Refusing to update note without id: ${noteUri}`);
|
||||
}
|
||||
|
||||
if (!checkHttps(note.id)) {
|
||||
throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${noteUri}`);
|
||||
}
|
||||
|
||||
const url = getOneApHrefNullable(note.url);
|
||||
|
||||
if (url && !checkHttps(url)) {
|
||||
throw new Error('unexpected schema of note url: ' + url);
|
||||
if (url != null) {
|
||||
if (!checkHttps(url)) {
|
||||
throw new UnrecoverableError(`unexpected schema of note.url ${url} in ${noteUri}`);
|
||||
}
|
||||
|
||||
if (this.utilityService.punyHostPSLDomain(url) !== this.utilityService.punyHostPSLDomain(note.id)) {
|
||||
throw new UnrecoverableError(`note url <> id host mismatch: ${url} <> ${note.id} in ${noteUri}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info(`Creating the Note: ${note.id}`);
|
||||
|
||||
// 投稿者をフェッチ
|
||||
if (note.attributedTo == null) {
|
||||
throw new Error('invalid note.attributedTo: ' + note.attributedTo);
|
||||
}
|
||||
|
||||
const uri = getOneApId(note.attributedTo);
|
||||
|
||||
// ローカルで投稿者を検索し、もし凍結されていたらスキップ
|
||||
const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser;
|
||||
if (cachedActor && cachedActor.isSuspended) {
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
|
||||
if (actor.isSuspended) {
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor ${actor.id} has been suspended: ${noteUri}`);
|
||||
}
|
||||
|
||||
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
|
||||
|
|
@ -415,17 +479,10 @@ export class ApNoteService {
|
|||
*/
|
||||
const hasProhibitedWords = await this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices });
|
||||
if (hasProhibitedWords) {
|
||||
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words');
|
||||
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', `Note contains prohibited words: ${noteUri}`);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const actor = cachedActor ?? await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
|
||||
|
||||
// 投稿者が凍結されていたらスキップ
|
||||
if (actor.isSuspended) {
|
||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
|
||||
}
|
||||
|
||||
const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
|
||||
let visibility = noteAudience.visibility;
|
||||
const visibleUsers = noteAudience.visibleUsers;
|
||||
|
|
@ -453,13 +510,13 @@ export class ApNoteService {
|
|||
.then(x => {
|
||||
if (x == null) {
|
||||
this.logger.warn('Specified inReplyTo, but not found');
|
||||
throw new Error('inReplyTo not found');
|
||||
throw new Error(`could not fetch inReplyTo ${note.inReplyTo}: ${entryUri}`);
|
||||
}
|
||||
|
||||
return x;
|
||||
})
|
||||
.catch(async err => {
|
||||
this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`);
|
||||
this.logger.warn(`error ${err.statusCode ?? err} fetching inReplyTo ${note.inReplyTo}: ${entryUri}`);
|
||||
throw err;
|
||||
})
|
||||
: null;
|
||||
|
|
@ -468,16 +525,25 @@ export class ApNoteService {
|
|||
let quote: MiNote | undefined | null = null;
|
||||
|
||||
if (note._misskey_quote ?? note.quoteUrl ?? note.quoteUri) {
|
||||
const tryResolveNote = async (uri: string): Promise<
|
||||
const tryResolveNote = async (uri: unknown): Promise<
|
||||
| { status: 'ok'; res: MiNote }
|
||||
| { status: 'permerror' | 'temperror' }
|
||||
> => {
|
||||
if (typeof uri !== 'string' || !/^https?:/.test(uri)) return { status: 'permerror' };
|
||||
if (typeof uri !== 'string' || !/^https?:/.test(uri)) {
|
||||
this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: URI is invalid`);
|
||||
return { status: 'permerror' };
|
||||
}
|
||||
try {
|
||||
const res = await this.resolveNote(uri, { resolver });
|
||||
if (res == null) return { status: 'permerror' };
|
||||
if (res == null) {
|
||||
this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: resolution error`);
|
||||
return { status: 'permerror' };
|
||||
}
|
||||
return { status: 'ok', res };
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
|
||||
this.logger.warn(`Failed to resolve quote ${uri} for note ${entryUri}: ${error}`);
|
||||
|
||||
return {
|
||||
status: (e instanceof StatusError && !e.isRetryable) ? 'permerror' : 'temperror',
|
||||
};
|
||||
|
|
@ -490,7 +556,7 @@ export class ApNoteService {
|
|||
quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0);
|
||||
if (!quote) {
|
||||
if (results.some(x => x.status === 'temperror')) {
|
||||
throw new Error('quote resolve failed');
|
||||
throw new Error(`temporary error resolving quote for ${entryUri}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -525,7 +591,7 @@ export class ApNoteService {
|
|||
const apEmojis = emojis.map(emoji => emoji.name);
|
||||
|
||||
try {
|
||||
return await this.noteEditService.edit(actor, UpdatedNote.id, {
|
||||
return await this.noteEditService.edit(actor, updatedNote.id, {
|
||||
createdAt: note.published ? new Date(note.published) : null,
|
||||
files,
|
||||
reply,
|
||||
|
|
@ -550,7 +616,7 @@ export class ApNoteService {
|
|||
this.logger.info('The note is already inserted while creating itself, reading again');
|
||||
const duplicate = await this.fetchNote(value);
|
||||
if (!duplicate) {
|
||||
throw new Error('The note creation failed with duplication error even when there is no duplication');
|
||||
throw new Error(`The note creation failed with duplication error even when there is no duplication: ${noteUri}`);
|
||||
}
|
||||
return duplicate;
|
||||
}
|
||||
|
|
@ -567,7 +633,7 @@ export class ApNoteService {
|
|||
const uri = getApId(value);
|
||||
|
||||
if (!this.utilityService.isFederationAllowedUri(uri)) {
|
||||
throw new StatusError('blocked host', 451);
|
||||
throw new StatusError(`blocked host: ${uri}`, 451, 'blocked host');
|
||||
}
|
||||
|
||||
const unlock = await this.appLockService.getApLock(uri);
|
||||
|
|
@ -578,15 +644,15 @@ export class ApNoteService {
|
|||
if (exist) return exist;
|
||||
//#endregion
|
||||
|
||||
if (uri.startsWith(this.config.url)) {
|
||||
throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note');
|
||||
if (this.utilityService.isUriLocal(uri)) {
|
||||
throw new StatusError(`cannot resolve local note: ${uri}`, 400, 'cannot resolve local note');
|
||||
}
|
||||
|
||||
// リモートサーバーからフェッチしてきて登録
|
||||
// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが
|
||||
// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
|
||||
const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri;
|
||||
return await this.createNote(createFrom, options.resolver, false);
|
||||
return await this.createNote(createFrom, undefined, options.resolver, true);
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
|
|
@ -627,7 +693,7 @@ export class ApNoteService {
|
|||
});
|
||||
|
||||
const emoji = await this.emojisRepository.findOneBy({ host, name });
|
||||
if (emoji == null) throw new Error('emoji update failed');
|
||||
if (emoji == null) throw new Error(`emoji update failed: ${name}:${host}`);
|
||||
return emoji;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import promiseLimit from 'promise-limit';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { AbortError } from 'node-fetch';
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { FollowingsRepository, InstancesRepository, MiMeta, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
|
@ -136,35 +138,49 @@ export class ApPersonService implements OnModuleInit {
|
|||
*/
|
||||
@bindThis
|
||||
private validateActor(x: IObject, uri: string): IActor {
|
||||
const expectHost = this.utilityService.punyHost(uri);
|
||||
const expectHost = this.utilityService.punyHostPSLDomain(uri);
|
||||
|
||||
if (!isActor(x)) {
|
||||
throw new Error(`invalid Actor type '${x.type}'`);
|
||||
throw new UnrecoverableError(`invalid Actor type '${x.type}' in ${uri}`);
|
||||
}
|
||||
|
||||
if (!(typeof x.id === 'string' && x.id.length > 0)) {
|
||||
throw new Error('invalid Actor: wrong id');
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong id type`);
|
||||
}
|
||||
|
||||
if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) {
|
||||
throw new Error('invalid Actor: wrong inbox');
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong inbox type`);
|
||||
}
|
||||
|
||||
if (this.utilityService.punyHost(x.inbox) !== expectHost) {
|
||||
throw new Error('invalid Actor: inbox has different host');
|
||||
const inboxHost = this.utilityService.punyHostPSLDomain(x.inbox);
|
||||
if (inboxHost !== expectHost) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong inbox ${inboxHost}`);
|
||||
}
|
||||
|
||||
const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined);
|
||||
if (sharedInboxObject != null) {
|
||||
const sharedInbox = getApId(sharedInboxObject);
|
||||
if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHostPSLDomain(sharedInbox) === expectHost)) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong shared inbox ${sharedInbox}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const collection of ['outbox', 'followers', 'following'] as (keyof IActor)[]) {
|
||||
const collectionUri = (x as IActor)[collection];
|
||||
if (typeof collectionUri === 'string' && collectionUri.length > 0) {
|
||||
if (this.utilityService.punyHost(collectionUri) !== expectHost) {
|
||||
throw new Error(`invalid Actor: ${collection} has different host`);
|
||||
const xCollection = (x as IActor)[collection];
|
||||
if (xCollection != null) {
|
||||
const collectionUri = getApId(xCollection);
|
||||
if (typeof collectionUri === 'string' && collectionUri.length > 0) {
|
||||
if (this.utilityService.punyHostPSLDomain(collectionUri) !== expectHost) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong ${collection} ${collectionUri}`);
|
||||
}
|
||||
} else if (collectionUri != null) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong ${collection} type`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) {
|
||||
throw new Error('invalid Actor: wrong username');
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong username`);
|
||||
}
|
||||
|
||||
// These fields are only informational, and some AP software allows these
|
||||
|
|
@ -172,7 +188,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
// we can at least see these users and their activities.
|
||||
if (x.name) {
|
||||
if (!(typeof x.name === 'string' && x.name.length > 0)) {
|
||||
throw new Error('invalid Actor: wrong name');
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong name`);
|
||||
}
|
||||
x.name = truncate(x.name, nameLength);
|
||||
} else if (x.name === '') {
|
||||
|
|
@ -181,24 +197,24 @@ export class ApPersonService implements OnModuleInit {
|
|||
}
|
||||
if (x.summary) {
|
||||
if (!(typeof x.summary === 'string' && x.summary.length > 0)) {
|
||||
throw new Error('invalid Actor: wrong summary');
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong summary`);
|
||||
}
|
||||
x.summary = truncate(x.summary, summaryLength);
|
||||
}
|
||||
|
||||
const idHost = this.utilityService.punyHost(x.id);
|
||||
const idHost = this.utilityService.punyHostPSLDomain(x.id);
|
||||
if (idHost !== expectHost) {
|
||||
throw new Error('invalid Actor: id has different host');
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong id ${x.id}`);
|
||||
}
|
||||
|
||||
if (x.publicKey) {
|
||||
if (typeof x.publicKey.id !== 'string') {
|
||||
throw new Error('invalid Actor: publicKey.id is not a string');
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong publicKey.id type`);
|
||||
}
|
||||
|
||||
const publicKeyIdHost = this.utilityService.punyHost(x.publicKey.id);
|
||||
const publicKeyIdHost = this.utilityService.punyHostPSLDomain(x.publicKey.id);
|
||||
if (publicKeyIdHost !== expectHost) {
|
||||
throw new Error('invalid Actor: publicKey.id has different host');
|
||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong publicKey.id ${x.publicKey.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -284,24 +300,23 @@ export class ApPersonService implements OnModuleInit {
|
|||
*/
|
||||
@bindThis
|
||||
public async createPerson(uri: string, resolver?: Resolver): Promise<MiRemoteUser> {
|
||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||
if (typeof uri !== 'string') throw new UnrecoverableError(`uri is not string: ${uri}`);
|
||||
|
||||
if (uri.startsWith(this.config.url)) {
|
||||
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
|
||||
const host = this.utilityService.punyHost(uri);
|
||||
if (host === this.utilityService.toPuny(this.config.host)) {
|
||||
throw new StatusError(`cannot resolve local user: ${uri}`, 400, 'cannot resolve local user');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
|
||||
const object = await resolver.resolve(uri);
|
||||
if (object.id == null) throw new Error('invalid object.id: ' + object.id);
|
||||
if (object.id == null) throw new UnrecoverableError(`null object.id in ${uri}`);
|
||||
|
||||
const person = this.validateActor(object, uri);
|
||||
|
||||
this.logger.info(`Creating the Person: ${person.id}`);
|
||||
|
||||
const host = this.utilityService.punyHost(object.id);
|
||||
|
||||
const fields = this.analyzeAttachments(person.attachment ?? []);
|
||||
|
||||
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
|
||||
|
|
@ -327,8 +342,18 @@ export class ApPersonService implements OnModuleInit {
|
|||
|
||||
const url = getOneApHrefNullable(person.url);
|
||||
|
||||
if (url && !checkHttps(url)) {
|
||||
throw new Error('unexpected schema of person url: ' + url);
|
||||
if (person.id == null) {
|
||||
throw new UnrecoverableError(`Refusing to create person without id: ${uri}`);
|
||||
}
|
||||
|
||||
if (url != null) {
|
||||
if (!checkHttps(url)) {
|
||||
throw new UnrecoverableError(`unexpected schema of person url ${url} in ${uri}`);
|
||||
}
|
||||
|
||||
if (this.utilityService.punyHostPSLDomain(url) !== this.utilityService.punyHostPSLDomain(person.id)) {
|
||||
throw new UnrecoverableError(`person url <> uri host mismatch: ${url} <> ${person.id} in ${uri}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create user
|
||||
|
|
@ -419,7 +444,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
if (isDuplicateKeyValueError(e)) {
|
||||
// /users/@a => /users/:id のように入力がaliasなときにエラーになることがあるのを対応
|
||||
const u = await this.usersRepository.findOneBy({ uri: person.id });
|
||||
if (u == null) throw new Error('already registered');
|
||||
if (u == null) throw new UnrecoverableError(`already registered a user with conflicting data: ${uri}`);
|
||||
|
||||
user = u as MiRemoteUser;
|
||||
} else {
|
||||
|
|
@ -428,7 +453,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
}
|
||||
}
|
||||
|
||||
if (user == null) throw new Error('failed to create user: user is null');
|
||||
if (user == null) throw new Error(`failed to create user - user is null: ${uri}`);
|
||||
|
||||
// Register to the cache
|
||||
this.cacheService.uriPersonCache.set(user.uri, user);
|
||||
|
|
@ -477,10 +502,10 @@ export class ApPersonService implements OnModuleInit {
|
|||
*/
|
||||
@bindThis
|
||||
public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise<string | void> {
|
||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||
if (typeof uri !== 'string') throw new UnrecoverableError('uri is not string');
|
||||
|
||||
// URIがこのサーバーを指しているならスキップ
|
||||
if (uri.startsWith(`${this.config.url}/`)) return;
|
||||
if (this.utilityService.isUriLocal(uri)) return;
|
||||
|
||||
//#region このサーバーに既に登録されているか
|
||||
const exist = await this.fetchPerson(uri) as MiRemoteUser | null;
|
||||
|
|
@ -529,8 +554,18 @@ export class ApPersonService implements OnModuleInit {
|
|||
|
||||
const url = getOneApHrefNullable(person.url);
|
||||
|
||||
if (url && !checkHttps(url)) {
|
||||
throw new Error('unexpected schema of person url: ' + url);
|
||||
if (person.id == null) {
|
||||
throw new UnrecoverableError(`Refusing to update person without id: ${uri}`);
|
||||
}
|
||||
|
||||
if (url != null) {
|
||||
if (!checkHttps(url)) {
|
||||
throw new UnrecoverableError(`unexpected schema of person url ${url} in ${uri}`);
|
||||
}
|
||||
|
||||
if (this.utilityService.punyHostPSLDomain(url) !== this.utilityService.punyHostPSLDomain(person.id)) {
|
||||
throw new UnrecoverableError(`person url <> uri host mismatch: ${url} <> ${person.id} in ${uri}`);
|
||||
}
|
||||
}
|
||||
|
||||
const updates = {
|
||||
|
|
@ -640,7 +675,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
});
|
||||
}
|
||||
|
||||
return 'skip';
|
||||
return 'skip: too soon to migrate accounts';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -690,8 +725,16 @@ export class ApPersonService implements OnModuleInit {
|
|||
const _resolver = resolver ?? this.apResolverService.createResolver();
|
||||
|
||||
// Resolve to (Ordered)Collection Object
|
||||
const collection = await _resolver.resolveCollection(user.featured);
|
||||
if (!isCollectionOrOrderedCollection(collection)) throw new Error('Object is not Collection or OrderedCollection');
|
||||
const collection = await _resolver.resolveCollection(user.featured).catch(err => {
|
||||
if (err instanceof AbortError || err instanceof StatusError) {
|
||||
this.logger.warn(`Failed to update featured notes: ${err.name}: ${err.message}`);
|
||||
} else {
|
||||
this.logger.error('Failed to update featured notes:', err);
|
||||
}
|
||||
});
|
||||
if (!collection) return;
|
||||
|
||||
if (!isCollectionOrOrderedCollection(collection)) throw new UnrecoverableError(`featured ${user.featured} is not Collection or OrderedCollection in ${user.uri}`);
|
||||
|
||||
// Resolve to Object(may be Note) arrays
|
||||
const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems;
|
||||
|
|
@ -699,9 +742,10 @@ export class ApPersonService implements OnModuleInit {
|
|||
|
||||
// Resolve and regist Notes
|
||||
const limit = promiseLimit<MiNote | null>(2);
|
||||
const maxPinned = (await this.roleService.getUserPolicies(user.id)).pinLimit;
|
||||
const featuredNotes = await Promise.all(items
|
||||
.filter(item => getApType(item) === 'Note') // TODO: Noteでなくてもいいかも
|
||||
.slice(0, 5)
|
||||
.slice(0, maxPinned)
|
||||
.map(item => limit(() => this.apNoteService.resolveNote(item, {
|
||||
resolver: _resolver,
|
||||
sentFrom: new URL(user.uri),
|
||||
|
|
@ -747,7 +791,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
await this.updatePerson(src.movedToUri, undefined, undefined, [...movePreventUris, src.uri]);
|
||||
dst = await this.fetchPerson(src.movedToUri) ?? dst;
|
||||
} else {
|
||||
if (src.movedToUri.startsWith(`${this.config.url}/`)) {
|
||||
if (this.utilityService.isUriLocal(src.movedToUri)) {
|
||||
// ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている
|
||||
return 'failed: movedTo is local but not found';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,17 +4,20 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { NotesRepository, PollsRepository } from '@/models/_.js';
|
||||
import type { UsersRepository, NotesRepository, PollsRepository } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { IPoll } from '@/models/Poll.js';
|
||||
import type { MiRemoteUser } from '@/models/User.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { isQuestion } from '../type.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { getApId, getApType, getNullableApId, getOneApId, isQuestion } from '../type.js';
|
||||
import { ApLoggerService } from '../ApLoggerService.js';
|
||||
import { ApResolverService } from '../ApResolverService.js';
|
||||
import type { Resolver } from '../ApResolverService.js';
|
||||
import type { IObject, IQuestion } from '../type.js';
|
||||
import type { IObject } from '../type.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApQuestionService {
|
||||
|
|
@ -24,6 +27,9 @@ export class ApQuestionService {
|
|||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
|
|
@ -32,6 +38,7 @@ export class ApQuestionService {
|
|||
|
||||
private apResolverService: ApResolverService,
|
||||
private apLoggerService: ApLoggerService,
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
this.logger = this.apLoggerService.logger;
|
||||
}
|
||||
|
|
@ -42,10 +49,10 @@ export class ApQuestionService {
|
|||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
|
||||
const question = await resolver.resolve(source);
|
||||
if (!isQuestion(question)) throw new Error('invalid type');
|
||||
if (!isQuestion(question)) throw new UnrecoverableError(`invalid type ${getApType(question)}: ${getNullableApId(question)}`);
|
||||
|
||||
const multiple = question.oneOf === undefined;
|
||||
if (multiple && question.anyOf === undefined) throw new Error('invalid question');
|
||||
if (multiple && question.anyOf === undefined) throw new UnrecoverableError(`invalid question - neither oneOf nor anyOf is defined: ${getNullableApId(question)}`);
|
||||
|
||||
const expiresAt = question.endTime ? new Date(question.endTime) : question.closed ? new Date(question.closed) : null;
|
||||
|
||||
|
|
@ -65,40 +72,50 @@ export class ApQuestionService {
|
|||
* @returns true if updated
|
||||
*/
|
||||
@bindThis
|
||||
public async updateQuestion(value: string | IObject, resolver?: Resolver): Promise<boolean> {
|
||||
const uri = typeof value === 'string' ? value : value.id;
|
||||
if (uri == null) throw new Error('uri is null');
|
||||
public async updateQuestion(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver): Promise<boolean> {
|
||||
const uri = getApId(value);
|
||||
|
||||
// URIがこのサーバーを指しているならスキップ
|
||||
if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local');
|
||||
if (this.utilityService.isUriLocal(uri)) throw new Error(`uri points local: ${uri}`);
|
||||
|
||||
//#region このサーバーに既に登録されているか
|
||||
const note = await this.notesRepository.findOneBy({ uri });
|
||||
if (note == null) throw new Error('Question is not registered');
|
||||
if (note == null) throw new Error(`Question is not registered (no note): ${uri}`);
|
||||
|
||||
const poll = await this.pollsRepository.findOneBy({ noteId: note.id });
|
||||
if (poll == null) throw new Error('Question is not registered');
|
||||
if (poll == null) throw new Error(`Question is not registered (no poll): ${uri}`);
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: poll.userId });
|
||||
if (user == null) throw new Error(`Question is not registered (no user): ${uri}`);
|
||||
//#endregion
|
||||
|
||||
// resolve new Question object
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
const question = await resolver.resolve(value) as IQuestion;
|
||||
const question = await resolver.resolve(value);
|
||||
this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);
|
||||
|
||||
if (question.type !== 'Question') throw new Error('object is not a Question');
|
||||
if (!isQuestion(question)) throw new UnrecoverableError(`object ${getApType(question)} is not a Question: ${uri}`);
|
||||
|
||||
const attribution = (question.attributedTo) ? getOneApId(question.attributedTo) : user.uri;
|
||||
const attributionMatchesExisting = attribution === user.uri;
|
||||
const actorMatchesAttribution = (actor) ? attribution === actor.uri : true;
|
||||
|
||||
if (!attributionMatchesExisting || !actorMatchesAttribution) {
|
||||
throw new UnrecoverableError(`Refusing to ingest update for poll by different user: ${uri}`);
|
||||
}
|
||||
|
||||
const apChoices = question.oneOf ?? question.anyOf;
|
||||
if (apChoices == null) throw new Error('invalid apChoices: ' + apChoices);
|
||||
if (apChoices == null) throw new UnrecoverableError(`poll has no choices: ${uri}`);
|
||||
|
||||
let changed = false;
|
||||
|
||||
for (const choice of poll.choices) {
|
||||
const oldCount = poll.votes[poll.choices.indexOf(choice)];
|
||||
const newCount = apChoices.filter(ap => ap.name === choice).at(0)?.replies?.totalItems;
|
||||
if (newCount == null) throw new Error('invalid newCount: ' + newCount);
|
||||
if (newCount == null || !(Number.isInteger(newCount) && newCount >= 0)) throw new UnrecoverableError(`invalid newCount: ${newCount} in ${uri}`);
|
||||
|
||||
if (oldCount !== newCount) {
|
||||
if (oldCount <= newCount) {
|
||||
changed = true;
|
||||
poll.votes[poll.choices.indexOf(choice)] = newCount;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
import { fromTuple } from '@/misc/from-tuple.js';
|
||||
|
||||
export type Obj = { [x: string]: any };
|
||||
export type ApObject = IObject | string | (IObject | string)[];
|
||||
|
||||
|
|
@ -53,10 +56,25 @@ export function getOneApId(value: ApObject): string {
|
|||
/**
|
||||
* Get ActivityStreams Object id
|
||||
*/
|
||||
export function getApId(value: string | IObject): string {
|
||||
export function getApId(value: string | IObject | [string | IObject]): string {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
value = fromTuple(value);
|
||||
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value.id === 'string') return value.id;
|
||||
throw new Error('cannot detemine id');
|
||||
throw new UnrecoverableError('cannot determine id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ActivityStreams Object id, or null if not present
|
||||
*/
|
||||
export function getNullableApId(value: string | IObject | [string | IObject]): string | null {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
value = fromTuple(value);
|
||||
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value.id === 'string') return value.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -85,7 +103,9 @@ export function getApHrefNullable(value: string | IObject | undefined): string |
|
|||
export interface IActivity extends IObject {
|
||||
//type: 'Activity';
|
||||
actor: IObject | string;
|
||||
object: IObject | string;
|
||||
// ActivityPub spec allows for arrays: https://www.w3.org/TR/activitystreams-vocabulary/#properties
|
||||
// Misskey can only handle one value, so we use a tuple for that case.
|
||||
object: IObject | string | [IObject | string] ;
|
||||
target?: IObject | string;
|
||||
/** LD-Signature */
|
||||
signature?: {
|
||||
|
|
@ -316,6 +336,10 @@ export interface ILike extends IActivity {
|
|||
_misskey_reaction?: string;
|
||||
}
|
||||
|
||||
export interface IDislike extends IActivity {
|
||||
type: 'Dislike';
|
||||
}
|
||||
|
||||
export interface IAnnounce extends IActivity {
|
||||
type: 'Announce';
|
||||
}
|
||||
|
|
@ -333,6 +357,7 @@ export interface IMove extends IActivity {
|
|||
target: IObject | string;
|
||||
}
|
||||
|
||||
export const isApObject = (object: string | IObject): object is IObject => typeof(object) === 'object';
|
||||
export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create';
|
||||
export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete';
|
||||
export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update';
|
||||
|
|
@ -347,6 +372,7 @@ export const isLike = (object: IObject): object is ILike => {
|
|||
const type = getApType(object);
|
||||
return type != null && ['Like', 'EmojiReaction', 'EmojiReact'].includes(type);
|
||||
};
|
||||
export const isDislike = (object: IObject): object is IDislike => getApType(object) === 'Dislike';
|
||||
export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce';
|
||||
export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block';
|
||||
export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag';
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { ChartLoggerService } from '@/core/chart/ChartLoggerService.js';
|
||||
import Logger from '@/logger.js';
|
||||
import FederationChart from './charts/federation.js';
|
||||
import NotesChart from './charts/notes.js';
|
||||
import UsersChart from './charts/users.js';
|
||||
|
|
@ -24,6 +26,7 @@ import type { OnApplicationShutdown } from '@nestjs/common';
|
|||
export class ChartManagementService implements OnApplicationShutdown {
|
||||
private charts;
|
||||
private saveIntervalId: NodeJS.Timeout;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
private federationChart: FederationChart,
|
||||
|
|
@ -38,6 +41,7 @@ export class ChartManagementService implements OnApplicationShutdown {
|
|||
private perUserFollowingChart: PerUserFollowingChart,
|
||||
private perUserDriveChart: PerUserDriveChart,
|
||||
private apRequestChart: ApRequestChart,
|
||||
private chartLoggerService: ChartLoggerService,
|
||||
) {
|
||||
this.charts = [
|
||||
this.federationChart,
|
||||
|
|
@ -53,6 +57,7 @@ export class ChartManagementService implements OnApplicationShutdown {
|
|||
this.perUserDriveChart,
|
||||
this.apRequestChart,
|
||||
];
|
||||
this.logger = chartLoggerService.logger;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -62,6 +67,7 @@ export class ChartManagementService implements OnApplicationShutdown {
|
|||
for (const chart of this.charts) {
|
||||
chart.save();
|
||||
}
|
||||
this.logger.info('All charts saved');
|
||||
}, 1000 * 60 * 20);
|
||||
}
|
||||
|
||||
|
|
@ -72,6 +78,7 @@ export class ChartManagementService implements OnApplicationShutdown {
|
|||
await Promise.all(
|
||||
this.charts.map(chart => chart.save()),
|
||||
);
|
||||
this.logger.info('All charts saved');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -368,7 +368,7 @@ export default abstract class Chart<T extends Schema> {
|
|||
// 初期ログデータを作成
|
||||
data = this.getNewLog(null);
|
||||
|
||||
this.logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): Initial commit created`);
|
||||
this.logger.debug(`${this.name + (group ? `:${group}` : '')}(${span}): Initial commit created`);
|
||||
}
|
||||
|
||||
const date = Chart.dateToTimestamp(current);
|
||||
|
|
@ -398,7 +398,7 @@ export default abstract class Chart<T extends Schema> {
|
|||
...columns,
|
||||
}) as RawRecord<T>;
|
||||
|
||||
this.logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): New commit created`);
|
||||
this.logger.debug(`${this.name + (group ? `:${group}` : '')}(${span}): New commit created`);
|
||||
|
||||
return log;
|
||||
} finally {
|
||||
|
|
@ -418,7 +418,7 @@ export default abstract class Chart<T extends Schema> {
|
|||
@bindThis
|
||||
public async save(): Promise<void> {
|
||||
if (this.buffer.length === 0) {
|
||||
this.logger.info(`${this.name}: Write skipped`);
|
||||
this.logger.debug(`${this.name}: Write skipped`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -519,7 +519,7 @@ export default abstract class Chart<T extends Schema> {
|
|||
.execute(),
|
||||
]);
|
||||
|
||||
this.logger.info(`${this.name + (logHour.group ? `:${logHour.group}` : '')}: Updated`);
|
||||
this.logger.debug(`${this.name + (logHour.group ? `:${logHour.group}` : '')}: Updated`);
|
||||
|
||||
// TODO: この一連の処理が始まった後に新たにbufferに入ったものは消さないようにする
|
||||
this.buffer = this.buffer.filter(q => q.group != null && (q.group !== logHour.group));
|
||||
|
|
|
|||
|
|
@ -8,11 +8,14 @@ import { DI } from '@/di-symbols.js';
|
|||
import type { FollowingsRepository } from '@/models/_.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { } from '@/models/Blocking.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiFollowing } from '@/models/Following.js';
|
||||
import { MiBlocking } from '@/models/Blocking.js';
|
||||
import { MiUserProfile } from '@/models/UserProfile.js';
|
||||
import type { MiLocalUser, MiUser } from '@/models/User.js';
|
||||
import { MiFollowing } from '@/models/Following.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { UserEntityService } from './UserEntityService.js';
|
||||
|
||||
type LocalFollowerFollowing = MiFollowing & {
|
||||
|
|
@ -47,6 +50,8 @@ export class FollowingEntityService {
|
|||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private queryService: QueryService,
|
||||
private roleService: RoleService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -70,6 +75,53 @@ export class FollowingEntityService {
|
|||
return following.followeeHost != null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getFollowing(me: MiLocalUser, params: FollowsQueryParams) {
|
||||
return await this.getFollows(me, params, 'following.followerHost = :host');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getFollowers(me: MiLocalUser, params: FollowsQueryParams) {
|
||||
return await this.getFollows(me, params, 'following.followeeHost = :host');
|
||||
}
|
||||
|
||||
private async getFollows(me: MiLocalUser, params: FollowsQueryParams, condition: string) {
|
||||
const builder = this.followingsRepository.createQueryBuilder('following');
|
||||
const query = this.queryService
|
||||
.makePaginationQuery(builder, params.sinceId, params.untilId)
|
||||
.andWhere(condition, { host: params.host })
|
||||
.limit(params.limit);
|
||||
|
||||
if (!await this.roleService.isModerator(me)) {
|
||||
query.setParameter('me', me.id);
|
||||
|
||||
// Make sure that the followee doesn't block us, if their profile will be included.
|
||||
if (params.includeFollowee) {
|
||||
query.leftJoin(MiBlocking, 'followee_blocking', 'followee_blocking."blockerId" = following."followeeId" AND followee_blocking."blockeeId" = :me');
|
||||
query.andWhere('followee_blocking.id IS NULL');
|
||||
}
|
||||
|
||||
// Make sure that the follower doesn't block us, if their profile will be included.
|
||||
if (params.includeFollower) {
|
||||
query.leftJoin(MiBlocking, 'follower_blocking', 'follower_blocking."blockerId" = following."followerId" AND follower_blocking."blockeeId" = :me');
|
||||
query.andWhere('follower_blocking.id IS NULL');
|
||||
}
|
||||
|
||||
// Make sure that the followee hasn't hidden this connection.
|
||||
query.leftJoin(MiUserProfile, 'followee', 'followee."userId" = following."followeeId"');
|
||||
query.leftJoin(MiFollowing, 'me_following_followee', 'me_following_followee."followerId" = :me AND me_following_followee."followeeId" = following."followerId"');
|
||||
query.andWhere('(followee."userId" = :me OR followee."followersVisibility" = \'public\' OR (followee."followersVisibility" = \'followers\' AND me_following_followee.id IS NOT NULL))');
|
||||
|
||||
// Make sure that the follower hasn't hidden this connection.
|
||||
query.leftJoin(MiUserProfile, 'follower', 'follower."userId" = following."followerId"');
|
||||
query.leftJoin(MiFollowing, 'me_following_follower', 'me_following_follower."followerId" = :me AND me_following_follower."followeeId" = following."followerId"');
|
||||
query.andWhere('(follower."userId" = :me OR follower."followingVisibility" = \'public\' OR (follower."followingVisibility" = \'followers\' AND me_following_follower.id IS NOT NULL))');
|
||||
}
|
||||
|
||||
const followings = await query.getMany();
|
||||
return await this.packMany(followings, me, { populateFollowee: params.includeFollowee, populateFollower: params.includeFollower });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async pack(
|
||||
src: MiFollowing['id'] | MiFollowing,
|
||||
|
|
@ -124,3 +176,12 @@ export class FollowingEntityService {
|
|||
}
|
||||
}
|
||||
|
||||
interface FollowsQueryParams {
|
||||
readonly host: string;
|
||||
readonly limit: number;
|
||||
readonly includeFollower: boolean;
|
||||
readonly includeFollowee: boolean;
|
||||
|
||||
readonly sinceId?: string;
|
||||
readonly untilId?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ export class InstanceEntityService {
|
|||
infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null,
|
||||
latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null,
|
||||
isNSFW: instance.isNSFW,
|
||||
rejectReports: instance.rejectReports,
|
||||
moderationNote: iAmModerator ? instance.moderationNote : null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,8 @@ export class MetaEntityService {
|
|||
recaptchaSiteKey: instance.recaptchaSiteKey,
|
||||
enableTurnstile: instance.enableTurnstile,
|
||||
turnstileSiteKey: instance.turnstileSiteKey,
|
||||
enableFC: instance.enableFC,
|
||||
fcSiteKey: instance.fcSiteKey,
|
||||
swPublickey: instance.swPublicKey,
|
||||
themeColor: instance.themeColor,
|
||||
mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png',
|
||||
|
|
@ -110,6 +112,11 @@ export class MetaEntityService {
|
|||
backgroundImageUrl: instance.backgroundImageUrl,
|
||||
logoImageUrl: instance.logoImageUrl,
|
||||
maxNoteTextLength: this.config.maxNoteLength,
|
||||
maxRemoteNoteTextLength: this.config.maxRemoteNoteLength,
|
||||
maxCwLength: this.config.maxCwLength,
|
||||
maxRemoteCwLength: this.config.maxRemoteCwLength,
|
||||
maxAltTextLength: this.config.maxAltTextLength,
|
||||
maxRemoteAltTextLength: this.config.maxRemoteAltTextLength,
|
||||
defaultLightTheme,
|
||||
defaultDarkTheme,
|
||||
defaultLike: instance.defaultLike,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ import { bindThis } from '@/decorators.js';
|
|||
import { DebounceLoader } from '@/misc/loader.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
|
||||
import { isPackedPureRenote } from '@/misc/is-renote.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
import type { CacheService } from '../CacheService.js';
|
||||
import type { CustomEmojiService } from '../CustomEmojiService.js';
|
||||
import type { ReactionService } from '../ReactionService.js';
|
||||
import type { UserEntityService } from './UserEntityService.js';
|
||||
|
|
@ -27,6 +29,7 @@ import type { Config } from '@/config.js';
|
|||
export class NoteEntityService implements OnModuleInit {
|
||||
private userEntityService: UserEntityService;
|
||||
private driveFileEntityService: DriveFileEntityService;
|
||||
private cacheService: CacheService;
|
||||
private customEmojiService: CustomEmojiService;
|
||||
private reactionService: ReactionService;
|
||||
private reactionsBufferingService: ReactionsBufferingService;
|
||||
|
|
@ -75,6 +78,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
onModuleInit() {
|
||||
this.userEntityService = this.moduleRef.get('UserEntityService');
|
||||
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
|
||||
this.cacheService = this.moduleRef.get('CacheService');
|
||||
this.customEmojiService = this.moduleRef.get('CustomEmojiService');
|
||||
this.reactionService = this.moduleRef.get('ReactionService');
|
||||
this.reactionsBufferingService = this.moduleRef.get('ReactionsBufferingService');
|
||||
|
|
@ -119,29 +123,32 @@ export class NoteEntityService implements OnModuleInit {
|
|||
} else if (packedNote.renote && (meId === packedNote.renote.userId)) {
|
||||
hide = false;
|
||||
} else {
|
||||
if (packedNote.renote) {
|
||||
const isFollowing = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followeeId: packedNote.renote.userId,
|
||||
followerId: meId,
|
||||
},
|
||||
});
|
||||
// フォロワーかどうか
|
||||
const isFollowing = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followeeId: packedNote.userId,
|
||||
followerId: meId,
|
||||
},
|
||||
});
|
||||
|
||||
hide = !isFollowing;
|
||||
} else {
|
||||
// フォロワーかどうか
|
||||
const isFollowing = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followeeId: packedNote.userId,
|
||||
followerId: meId,
|
||||
},
|
||||
});
|
||||
|
||||
hide = !isFollowing;
|
||||
}
|
||||
hide = !isFollowing;
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a pure renote (boost), then we should *also* check the boosted note's visibility.
|
||||
// Otherwise we can have empty notes on the timeline, which is not good.
|
||||
// Notes are packed in depth-first order, so we can safely grab the "isHidden" property to avoid duplicated checks.
|
||||
// This is pulled out to ensure that we check both the renote *and* the boosted note.
|
||||
if (packedNote.renote?.isHidden && isPackedPureRenote(packedNote)) {
|
||||
hide = true;
|
||||
}
|
||||
|
||||
if (!hide && meId && packedNote.userId !== meId) {
|
||||
const isBlocked = (await this.cacheService.userBlockedCache.fetch(meId)).has(packedNote.userId);
|
||||
|
||||
if (isBlocked) hide = true;
|
||||
}
|
||||
|
||||
if (hide) {
|
||||
packedNote.visibleUserIds = undefined;
|
||||
packedNote.fileIds = [];
|
||||
|
|
@ -149,6 +156,12 @@ export class NoteEntityService implements OnModuleInit {
|
|||
packedNote.text = null;
|
||||
packedNote.poll = undefined;
|
||||
packedNote.cw = null;
|
||||
packedNote.repliesCount = 0;
|
||||
packedNote.reactionAcceptance = null;
|
||||
packedNote.reactionAndUserPairCache = undefined;
|
||||
packedNote.reactionCount = 0;
|
||||
packedNote.reactionEmojis = {};
|
||||
packedNote.reactions = {};
|
||||
packedNote.isHidden = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -262,7 +275,8 @@ export class NoteEntityService implements OnModuleInit {
|
|||
return true;
|
||||
} else {
|
||||
// フォロワーかどうか
|
||||
const [following, user] = await Promise.all([
|
||||
const [blocked, following, user] = await Promise.all([
|
||||
this.cacheService.userBlockingCache.fetch(meId).then((ids) => ids.has(note.userId)),
|
||||
this.followingsRepository.count({
|
||||
where: {
|
||||
followeeId: note.userId,
|
||||
|
|
@ -273,6 +287,8 @@ export class NoteEntityService implements OnModuleInit {
|
|||
this.usersRepository.findOneByOrFail({ id: meId }),
|
||||
]);
|
||||
|
||||
if (blocked) return false;
|
||||
|
||||
/* If we know the following, everyhting is fine.
|
||||
|
||||
But if we do not know the following, it might be that both the
|
||||
|
|
@ -284,6 +300,12 @@ export class NoteEntityService implements OnModuleInit {
|
|||
}
|
||||
}
|
||||
|
||||
if (meId != null) {
|
||||
const isBlocked = (await this.cacheService.userBlockedCache.fetch(meId)).has(note.userId);
|
||||
|
||||
if (isBlocked) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -374,6 +374,13 @@ export class UserEntityService implements OnModuleInit {
|
|||
return count > 0;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getHasPendingSentFollowRequest(userId: MiUser['id']): Promise<boolean> {
|
||||
return this.followRequestsRepository.existsBy({
|
||||
followerId: userId,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getOnlineStatus(user: MiUser): 'unknown' | 'online' | 'active' | 'offline' {
|
||||
if (user.hideOnlineStatus) return 'unknown';
|
||||
|
|
@ -620,6 +627,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
injectFeaturedNote: profile!.injectFeaturedNote,
|
||||
receiveAnnouncementEmail: profile!.receiveAnnouncementEmail,
|
||||
alwaysMarkNsfw: profile!.alwaysMarkNsfw,
|
||||
defaultSensitive: profile!.defaultSensitive,
|
||||
autoSensitive: profile!.autoSensitive,
|
||||
carefulBot: profile!.carefulBot,
|
||||
autoAcceptFollowed: profile!.autoAcceptFollowed,
|
||||
|
|
@ -643,6 +651,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
hasUnreadChannel: false, // 後方互換性のため
|
||||
hasUnreadNotification: notificationsInfo?.hasUnread, // 後方互換性のため
|
||||
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
|
||||
hasPendingSentFollowRequest: this.getHasPendingSentFollowRequest(user.id),
|
||||
unreadNotificationsCount: notificationsInfo?.unreadCount,
|
||||
mutedWords: profile!.mutedWords,
|
||||
hardMutedWords: profile!.hardMutedWords,
|
||||
|
|
|
|||
7
packages/backend/src/misc/from-tuple.ts
Normal file
7
packages/backend/src/misc/from-tuple.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function fromTuple<T>(value: T | [T]): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0];
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
|
@ -23,6 +23,17 @@ type Quote =
|
|||
hasPoll: true
|
||||
});
|
||||
|
||||
type PureRenote =
|
||||
Renote & {
|
||||
text: null,
|
||||
cw: null,
|
||||
replyId: null,
|
||||
hasPoll: false,
|
||||
fileIds: {
|
||||
length: 0,
|
||||
},
|
||||
};
|
||||
|
||||
export function isRenote(note: MiNote): note is Renote {
|
||||
return note.renoteId != null;
|
||||
}
|
||||
|
|
@ -36,6 +47,10 @@ export function isQuote(note: Renote): note is Quote {
|
|||
note.fileIds.length > 0;
|
||||
}
|
||||
|
||||
export function isPureRenote(note: MiNote): note is PureRenote {
|
||||
return isRenote(note) && !isQuote(note);
|
||||
}
|
||||
|
||||
type PackedRenote =
|
||||
Packed<'Note'> & {
|
||||
renoteId: NonNullable<Packed<'Note'>['renoteId']>
|
||||
|
|
@ -54,6 +69,14 @@ type PackedQuote =
|
|||
fileIds: NonNullable<Packed<'Note'>['fileIds']>
|
||||
});
|
||||
|
||||
type PackedPureRenote = PackedRenote & {
|
||||
text: NonNullable<Packed<'Note'>['text']>;
|
||||
cw: NonNullable<Packed<'Note'>['cw']>;
|
||||
replyId: NonNullable<Packed<'Note'>['replyId']>;
|
||||
poll: NonNullable<Packed<'Note'>['poll']>;
|
||||
fileIds: NonNullable<Packed<'Note'>['fileIds']>;
|
||||
}
|
||||
|
||||
export function isRenotePacked(note: Packed<'Note'>): note is PackedRenote {
|
||||
return note.renoteId != null;
|
||||
}
|
||||
|
|
@ -65,3 +88,7 @@ export function isQuotePacked(note: PackedRenote): note is PackedQuote {
|
|||
note.poll != null ||
|
||||
(note.fileIds != null && note.fileIds.length > 0);
|
||||
}
|
||||
|
||||
export function isPackedPureRenote(note: Packed<'Note'>): note is PackedPureRenote {
|
||||
return isRenotePacked(note) && !isQuotePacked(note);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,7 @@ export function secureRndstr(length = 32, { chars = LU_CHARS } = {}): string {
|
|||
let str = '';
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
let rand = Math.floor((crypto.randomBytes(1).readUInt8(0) / 0xFF) * chars_len);
|
||||
if (rand === chars_len) {
|
||||
rand = chars_len - 1;
|
||||
}
|
||||
const rand = crypto.randomInt(0, chars_len);
|
||||
str += chars.charAt(rand);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
*/
|
||||
|
||||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js';
|
||||
import { id } from './util/id.js';
|
||||
import { MiUser } from './User.js';
|
||||
import { MiDriveFolder } from './DriveFolder.js';
|
||||
|
|
@ -61,8 +60,7 @@ export class MiDriveFile {
|
|||
})
|
||||
public size: number;
|
||||
|
||||
@Column('varchar', {
|
||||
length: DB_MAX_IMAGE_COMMENT_LENGTH,
|
||||
@Column('text', {
|
||||
nullable: true,
|
||||
comment: 'The comment of the DriveFile.',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { MiUser } from '@/models/User.js';
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
import { isQuote, isRenote } from '@/misc/is-renote.js';
|
||||
|
||||
/**
|
||||
* Maps a user to the most recent post by that user.
|
||||
|
|
@ -13,7 +14,7 @@ import { MiNote } from '@/models/Note.js';
|
|||
* DMs are not counted.
|
||||
*/
|
||||
@Entity('latest_note')
|
||||
export class LatestNote {
|
||||
export class SkLatestNote {
|
||||
@PrimaryColumn({
|
||||
name: 'user_id',
|
||||
type: 'varchar' as const,
|
||||
|
|
@ -21,6 +22,24 @@ export class LatestNote {
|
|||
})
|
||||
public userId: string;
|
||||
|
||||
@PrimaryColumn('boolean', {
|
||||
name: 'is_public',
|
||||
default: false,
|
||||
})
|
||||
public isPublic: boolean;
|
||||
|
||||
@PrimaryColumn('boolean', {
|
||||
name: 'is_reply',
|
||||
default: false,
|
||||
})
|
||||
public isReply: boolean;
|
||||
|
||||
@PrimaryColumn('boolean', {
|
||||
name: 'is_quote',
|
||||
default: false,
|
||||
})
|
||||
public isQuote: boolean;
|
||||
|
||||
@ManyToOne(() => MiUser, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
|
|
@ -44,11 +63,38 @@ export class LatestNote {
|
|||
})
|
||||
public note: MiNote | null;
|
||||
|
||||
constructor(data?: Partial<LatestNote>) {
|
||||
constructor(data?: Partial<SkLatestNote>) {
|
||||
if (!data) return;
|
||||
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
(this as Record<string, unknown>)[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a compound key matching a provided note.
|
||||
*/
|
||||
static keyFor(note: MiNote) {
|
||||
return {
|
||||
userId: note.userId,
|
||||
isPublic: note.visibility === 'public',
|
||||
isReply: note.replyId != null,
|
||||
isQuote: isRenote(note) && isQuote(note),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if two notes would produce equivalent compound keys.
|
||||
*/
|
||||
static areEquivalent(first: MiNote, second: MiNote): boolean {
|
||||
const firstKey = SkLatestNote.keyFor(first);
|
||||
const secondKey = SkLatestNote.keyFor(second);
|
||||
|
||||
return (
|
||||
firstKey.userId === secondKey.userId &&
|
||||
firstKey.isPublic === secondKey.isPublic &&
|
||||
firstKey.isReply === secondKey.isReply &&
|
||||
firstKey.isQuote === secondKey.isQuote
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -269,6 +269,23 @@ export class MiMeta {
|
|||
})
|
||||
public turnstileSecretKey: string | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public enableFC: boolean;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
})
|
||||
public fcSiteKey: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024,
|
||||
nullable: true,
|
||||
})
|
||||
public fcSecretKey: string | null;
|
||||
|
||||
// chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること
|
||||
|
||||
@Column('enum', {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type { Provider } from '@nestjs/common';
|
|||
import { Module } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import {
|
||||
LatestNote,
|
||||
SkLatestNote,
|
||||
MiAbuseReportNotificationRecipient,
|
||||
MiAbuseUserReport,
|
||||
MiAccessToken,
|
||||
|
|
@ -121,7 +121,7 @@ const $avatarDecorationsRepository: Provider = {
|
|||
|
||||
const $latestNotesRepository: Provider = {
|
||||
provide: DI.latestNotesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(LatestNote).extend(miRepository as MiRepository<LatestNote>),
|
||||
useFactory: (db: DataSource) => db.getRepository(SkLatestNote).extend(miRepository as MiRepository<SkLatestNote>),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -196,6 +196,11 @@ export class MiUserProfile {
|
|||
})
|
||||
public alwaysMarkNsfw: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public defaultSensitive: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLo
|
|||
import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js';
|
||||
import { ObjectUtils } from 'typeorm/util/ObjectUtils.js';
|
||||
import { OrmUtils } from 'typeorm/util/OrmUtils.js';
|
||||
import { LatestNote } from '@/models/LatestNote.js';
|
||||
import { SkLatestNote } from '@/models/LatestNote.js';
|
||||
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
|
||||
import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
|
||||
import { MiAccessToken } from '@/models/AccessToken.js';
|
||||
|
|
@ -127,7 +127,7 @@ export const miRepository = {
|
|||
} satisfies MiRepository<ObjectLiteral>;
|
||||
|
||||
export {
|
||||
LatestNote,
|
||||
SkLatestNote,
|
||||
MiAbuseUserReport,
|
||||
MiAbuseReportNotificationRecipient,
|
||||
MiAccessToken,
|
||||
|
|
@ -226,7 +226,7 @@ export type GalleryPostsRepository = Repository<MiGalleryPost> & MiRepository<Mi
|
|||
export type HashtagsRepository = Repository<MiHashtag> & MiRepository<MiHashtag>;
|
||||
export type InstancesRepository = Repository<MiInstance> & MiRepository<MiInstance>;
|
||||
export type MetasRepository = Repository<MiMeta> & MiRepository<MiMeta>;
|
||||
export type LatestNotesRepository = Repository<LatestNote> & MiRepository<LatestNote>;
|
||||
export type LatestNotesRepository = Repository<SkLatestNote> & MiRepository<SkLatestNote>;
|
||||
export type ModerationLogsRepository = Repository<MiModerationLog> & MiRepository<MiModerationLog>;
|
||||
export type MutingsRepository = Repository<MiMuting> & MiRepository<MiMuting>;
|
||||
export type RenoteMutingsRepository = Repository<MiRenoteMuting> & MiRepository<MiRenoteMuting>;
|
||||
|
|
|
|||
|
|
@ -121,6 +121,11 @@ export const packedFederationInstanceSchema = {
|
|||
optional: false,
|
||||
nullable: false,
|
||||
},
|
||||
rejectReports: {
|
||||
type: 'boolean',
|
||||
optional: false,
|
||||
nullable: false,
|
||||
},
|
||||
moderationNote: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
|
|
|
|||
|
|
@ -127,6 +127,14 @@ export const packedMetaLiteSchema = {
|
|||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
enableFC: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
fcSiteKey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
enableAchievements: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: true,
|
||||
|
|
@ -168,6 +176,26 @@ export const packedMetaLiteSchema = {
|
|||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
maxRemoteNoteTextLength: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
maxCwLength: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
maxRemoteCwLength: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
maxAltTextLength: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
maxRemoteAltTextLength: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
ads: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue