Compare commits
212 commits
| 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 | ||
|
|
482538c7f8 | ||
|
|
d579687156 | ||
|
|
de970ff54e | ||
|
|
1bfb0dc395 | ||
|
|
da2dfee0a8 | ||
|
|
eaad96aae3 | ||
|
|
0a05841f33 | ||
|
|
68e5b5a84a | ||
|
|
6d6b03dfe2 | ||
|
|
19be113cb4 | ||
|
|
101ca9e0f7 | ||
|
|
906c2863db | ||
|
|
917e67d356 | ||
|
|
cd2e597223 | ||
|
|
03559156b9 | ||
|
|
aebdbf07b4 | ||
|
|
00ab7f5bd1 | ||
|
|
83f780978c | ||
|
|
7f9a151055 | ||
|
|
e0a2e7aedc | ||
|
|
9fe5dc679a | ||
|
|
6ed38f53f5 | ||
|
|
0f07f27642 | ||
|
|
c2c2120b76 | ||
|
|
8477909af2 | ||
|
|
a15e5c52f4 | ||
|
|
1c181df086 | ||
|
|
f5652605ec | ||
|
|
9d3aa6bb41 | ||
|
|
5b64b9001d | ||
|
|
3b89b73d27 |
327 changed files with 5372 additions and 773 deletions
|
|
@ -229,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'
|
||||
|
|
|
|||
|
|
@ -222,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'
|
||||
|
|
|
|||
|
|
@ -312,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
|
||||
|
|
@ -334,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> -->
|
||||
|
|
|
|||
|
|
@ -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 {} \;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,38 @@
|
|||
# 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
|
||||
|
|
|
|||
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;
|
||||
};
|
||||
})
|
||||
];
|
||||
};
|
||||
|
||||
};
|
||||
}
|
||||
8
locales/index.d.ts
vendored
8
locales/index.d.ts
vendored
|
|
@ -9661,6 +9661,10 @@ export interface Locale extends ILocale {
|
|||
* ロールタイムライン
|
||||
*/
|
||||
"roleTimeline": string;
|
||||
/**
|
||||
* Following
|
||||
*/
|
||||
"following": string;
|
||||
};
|
||||
};
|
||||
"_dialog": {
|
||||
|
|
@ -11374,6 +11378,10 @@ export interface Locale extends ILocale {
|
|||
* Remote followers may have incomplete or outdated activity
|
||||
*/
|
||||
"remoteFollowersWarning": string;
|
||||
/**
|
||||
* Select a follow relationship...
|
||||
*/
|
||||
"selectFollowRelationship": string;
|
||||
}
|
||||
declare const locales: {
|
||||
[lang: string]: Locale;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "sharkey",
|
||||
"version": "2024.9.2",
|
||||
"version": "2024.10.0-dev",
|
||||
"codename": "shonk",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -66,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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -57,4 +57,4 @@ const promises = Array
|
|||
connectToPostgres()
|
||||
]);
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
await Promise.all(promises);
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ type Source = {
|
|||
};
|
||||
|
||||
pidFile: string;
|
||||
filePermissionBits?: string;
|
||||
};
|
||||
|
||||
export type Config = {
|
||||
|
|
@ -212,6 +213,7 @@ export type Config = {
|
|||
} | undefined;
|
||||
|
||||
pidFile: string;
|
||||
filePermissionBits?: string;
|
||||
};
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
|
|
@ -347,6 +349,7 @@ export function loadConfig(): Config {
|
|||
deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7),
|
||||
import: config.import,
|
||||
pidFile: config.pidFile,
|
||||
filePermissionBits: config.filePermissionBits,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -452,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;
|
||||
|
|
@ -490,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', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'pidFile']]);
|
||||
_apply_top([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'pidFile', 'filePermissionBits']]);
|
||||
_apply_top(['import', ['downloadTimeout', 'maxFileSize']]);
|
||||
_apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature']]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -312,6 +312,7 @@ export class EmailService {
|
|||
Accept: 'application/json',
|
||||
Authorization: truemailAuthKey,
|
||||
},
|
||||
isLocalAddressAllowed: true,
|
||||
});
|
||||
|
||||
const json = (await res.json()) as {
|
||||
|
|
|
|||
|
|
@ -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,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { copyFile, mkdir, unlink, writeFile } from 'node:fs/promises';
|
||||
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';
|
||||
|
|
@ -41,12 +41,20 @@ export class InternalStorageService {
|
|||
@bindThis
|
||||
public async saveFromPath(key: string, srcPath: string): Promise<string> {
|
||||
await copyFile(srcPath, this.resolvePath(key));
|
||||
return `${this.config.url}/files/${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}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -146,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();
|
||||
|
|
@ -412,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';
|
||||
}
|
||||
});
|
||||
|
|
@ -627,6 +629,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
this.queueService.endedPollNotificationQueue.add(note.id, {
|
||||
noteId: note.id,
|
||||
}, {
|
||||
jobId: `pollEnd:${note.id}`,
|
||||
delay,
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
|
@ -821,6 +824,11 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -442,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';
|
||||
}
|
||||
});
|
||||
|
|
@ -471,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({
|
||||
|
|
@ -544,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);
|
||||
|
|
@ -605,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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ 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';
|
||||
|
|
@ -55,6 +56,7 @@ 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
|
||||
|
|
@ -64,8 +66,11 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -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,8 +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 { fromTuple } from '@/misc/from-tuple.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 {
|
||||
|
|
@ -93,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);
|
||||
|
|
@ -116,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);
|
||||
}
|
||||
|
||||
// ついでにリモートユーザーの情報が古かったら更新しておく
|
||||
|
|
@ -131,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}`;
|
||||
}
|
||||
|
|
@ -185,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}`);
|
||||
|
|
@ -245,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';
|
||||
}
|
||||
|
|
@ -255,8 +281,12 @@ export class ApInboxService {
|
|||
}
|
||||
|
||||
if (activity.target === actor.featured) {
|
||||
const object = fromTuple(activity.object);
|
||||
const note = await this.apNoteService.resolveNote(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;
|
||||
|
|
@ -266,12 +296,13 @@ 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();
|
||||
|
||||
const activityObject = fromTuple(activity.object);
|
||||
if (!activityObject) return 'skip: activity has no object property';
|
||||
|
|
@ -280,7 +311,7 @@ export class ApInboxService {
|
|||
|
||||
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);
|
||||
|
|
@ -289,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) {
|
||||
|
|
@ -311,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}`;
|
||||
}
|
||||
|
|
@ -330,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) {
|
||||
|
|
@ -368,7 +399,7 @@ 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}`);
|
||||
|
|
@ -394,7 +425,8 @@ export class ApInboxService {
|
|||
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(activityObject).catch(e => {
|
||||
this.logger.error(`Resolution failed: ${e}`);
|
||||
|
|
@ -402,14 +434,14 @@ export class ApInboxService {
|
|||
});
|
||||
|
||||
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') {
|
||||
|
|
@ -421,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';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -430,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;
|
||||
}
|
||||
|
|
@ -521,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) {
|
||||
|
|
@ -568,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}`);
|
||||
|
|
@ -610,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';
|
||||
}
|
||||
|
|
@ -621,7 +656,11 @@ export class ApInboxService {
|
|||
|
||||
if (activity.target === actor.featured) {
|
||||
const activityObject = fromTuple(activity.object);
|
||||
const note = await this.apNoteService.resolveNote(activityObject);
|
||||
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;
|
||||
|
|
@ -631,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';
|
||||
}
|
||||
|
|
@ -640,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
|
||||
|
|
@ -654,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
|
||||
|
|
@ -749,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);
|
||||
|
|
@ -764,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}`);
|
||||
|
|
@ -782,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 {
|
||||
|
|
@ -469,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);
|
||||
|
|
|
|||
|
|
@ -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,12 +16,12 @@ 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';
|
||||
import { ApRequestService } from './ApRequestService.js';
|
||||
import type { IObject, ICollection, IOrderedCollection } from './type.js';
|
||||
import { fromTuple } from '@/misc/from-tuple.js';
|
||||
|
||||
export class Resolver {
|
||||
private history: Set<string>;
|
||||
|
|
@ -42,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');
|
||||
|
|
@ -53,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'
|
||||
|
|
@ -62,7 +68,7 @@ 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -79,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);
|
||||
|
|
@ -98,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) {
|
||||
|
|
@ -114,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;
|
||||
|
|
@ -131,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':
|
||||
|
|
@ -160,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,
|
||||
|
|
@ -172,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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ 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';
|
||||
|
|
@ -47,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);
|
||||
|
|
|
|||
|
|
@ -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, true);
|
||||
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,38 +72,48 @@ 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) {
|
||||
changed = true;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
import { fromTuple } from '@/misc/from-tuple.js';
|
||||
|
||||
export type Obj = { [x: string]: any };
|
||||
|
|
@ -61,7 +62,19 @@ export function getApId(value: string | IObject | [string | IObject]): string {
|
|||
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value.id === 'string') return value.id;
|
||||
throw new Error('cannot determine 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -323,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';
|
||||
}
|
||||
|
|
@ -340,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';
|
||||
|
|
@ -354,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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,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;
|
||||
}
|
||||
|
|
@ -80,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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type { Config } from '@/config.js';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
|
||||
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
|
||||
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
|
||||
|
|
@ -132,7 +133,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
|||
// 何故かeがundefinedで来ることがある
|
||||
if (!e) return '?';
|
||||
|
||||
if (e instanceof Bull.UnrecoverableError || e.name === 'AbortError') {
|
||||
if (e instanceof Bull.UnrecoverableError || e.name === 'AbortError' || e instanceof StatusError) {
|
||||
return `${e.name}: ${e.message}`;
|
||||
}
|
||||
|
||||
|
|
@ -146,12 +147,15 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
|||
function renderJob(job?: Bull.Job) {
|
||||
if (!job) return '?';
|
||||
|
||||
return {
|
||||
name: job.name || undefined,
|
||||
const info: Record<string, string> = {
|
||||
info: getJobInfo(job),
|
||||
failedReason: job.failedReason || undefined,
|
||||
data: job.data,
|
||||
};
|
||||
|
||||
if (job.name) info.name = job.name;
|
||||
if (job.failedReason) info.failedReason = job.failedReason;
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
//#region system
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { URL } from 'node:url';
|
|||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import httpSignature from '@peertube/http-signature';
|
||||
import * as Bull from 'bullmq';
|
||||
import { AbortError } from 'node-fetch';
|
||||
import type Logger from '@/logger.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
|
||||
|
|
@ -192,6 +193,9 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
|||
if (signerHost !== activityIdHost) {
|
||||
throw new Bull.UnrecoverableError(`skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`);
|
||||
}
|
||||
} else {
|
||||
// Activity ID should only be string or undefined.
|
||||
delete activity.id;
|
||||
}
|
||||
|
||||
// Update stats
|
||||
|
|
@ -215,7 +219,11 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
|||
try {
|
||||
const result = await this.apInboxService.performActivity(authUser.user, activity);
|
||||
if (result && !result.startsWith('ok')) {
|
||||
this.logger.warn(`inbox activity ignored (maybe): id=${activity.id} reason=${result}`);
|
||||
if (result.startsWith('skip:')) {
|
||||
this.logger.info(`inbox activity ignored: id=${activity.id} reason=${result}`);
|
||||
} else {
|
||||
this.logger.warn(`inbox activity failed: id=${activity.id} reason=${result}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -230,6 +238,19 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
|||
return e.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (e instanceof StatusError) {
|
||||
if (e.isRetryable) {
|
||||
return `temporary error ${e.statusCode}`;
|
||||
} else {
|
||||
return `skip: permanent error ${e.statusCode}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (e instanceof AbortError) {
|
||||
return 'request aborted';
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
return 'ok';
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ export class ActivityPubServerService {
|
|||
let signature;
|
||||
|
||||
try {
|
||||
signature = httpSignature.parseRequest(request.raw, { 'headers': [] });
|
||||
signature = httpSignature.parseRequest(request.raw, { 'headers': ['(request-target)', 'host', 'date'], authorizationHeaderName: 'signature' });
|
||||
} catch (e) {
|
||||
// not signed, or malformed signature: refuse
|
||||
this.authlogger.warn(`${request.id} ${request.url} not signed, or malformed signature: refuse`);
|
||||
|
|
@ -229,7 +229,7 @@ export class ActivityPubServerService {
|
|||
let signature;
|
||||
|
||||
try {
|
||||
signature = httpSignature.parseRequest(request.raw, { 'headers': [] });
|
||||
signature = httpSignature.parseRequest(request.raw, { 'headers': ['(request-target)', 'digest', 'host', 'date'], authorizationHeaderName: 'signature' });
|
||||
} catch (e) {
|
||||
reply.code(401);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.
|
|||
import { RateLimiterService } from '@/server/api/RateLimiterService.js';
|
||||
import { getIpHash } from '@/misc/get-ip-hash.js';
|
||||
import { AuthenticateService } from '@/server/api/AuthenticateService.js';
|
||||
import type { IEndpointMeta } from '@/server/api/endpoints.js';
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
|
||||
import type Limiter from 'ratelimiter';
|
||||
|
||||
|
|
@ -82,7 +83,7 @@ export class FileServerService {
|
|||
});
|
||||
|
||||
fastify.get<{ Params: { key: string; } }>('/files/:key', async (request, reply) => {
|
||||
if (!await this.checkRateLimit(request, reply, `/files/${request.params.key}`)) return;
|
||||
if (!await this.checkRateLimit(request, reply, '/files/', request.params.key)) return;
|
||||
|
||||
return await this.sendDriveFile(request, reply)
|
||||
.catch(err => this.errorHandler(request, reply, err));
|
||||
|
|
@ -109,7 +110,7 @@ export class FileServerService {
|
|||
keyUrl.username = '';
|
||||
keyUrl.password = '';
|
||||
|
||||
if (!await this.checkRateLimit(request, reply, `/proxy/${keyUrl}`)) return;
|
||||
if (!await this.checkRateLimit(request, reply, '/proxy/', keyUrl.href)) return;
|
||||
|
||||
return await this.proxyHandler(request, reply)
|
||||
.catch(err => this.errorHandler(request, reply, err));
|
||||
|
|
@ -603,7 +604,8 @@ export class FileServerService {
|
|||
Params?: Record<string, unknown> | unknown,
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
rateLimitKey: string,
|
||||
group: string,
|
||||
resource: string,
|
||||
): Promise<boolean> {
|
||||
const body = request.method === 'GET'
|
||||
? request.query
|
||||
|
|
@ -622,32 +624,48 @@ export class FileServerService {
|
|||
const [user] = await this.authenticateService.authenticate(token);
|
||||
const actor = user?.id ?? getIpHash(request.ip);
|
||||
|
||||
// Call both limits: the per-resource limit and the shared cross-resource limit
|
||||
return await this.checkResourceLimit(reply, actor, group, resource) && await this.checkSharedLimit(reply, actor, group);
|
||||
}
|
||||
|
||||
private async checkResourceLimit(reply: FastifyReply, actor: string, group: string, resource: string): Promise<boolean> {
|
||||
const limit = {
|
||||
// Group by resource
|
||||
key: rateLimitKey,
|
||||
key: `${group}${resource}`,
|
||||
|
||||
// Maximum of 10 requests / 10 minutes
|
||||
max: 10,
|
||||
duration: 1000 * 60 * 10,
|
||||
|
||||
// Minimum of 250 ms between each request
|
||||
minInterval: 250,
|
||||
};
|
||||
|
||||
// Rate limit proxy requests
|
||||
return await this.checkLimit(reply, actor, limit);
|
||||
}
|
||||
|
||||
private async checkSharedLimit(reply: FastifyReply, actor: string, group: string): Promise<boolean> {
|
||||
const limit = {
|
||||
key: group,
|
||||
|
||||
// Maximum of 3600 requests per hour, which is an average of 1 per second.
|
||||
max: 3600,
|
||||
duration: 1000 * 60 * 60,
|
||||
};
|
||||
|
||||
return await this.checkLimit(reply, actor, limit);
|
||||
}
|
||||
|
||||
private async checkLimit(reply: FastifyReply, actor: string, limit: IEndpointMeta['limit'] & { key: NonNullable<string> }): Promise<boolean> {
|
||||
try {
|
||||
await this.rateLimiterService.limit(limit, actor);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// errはLimiter.LimiterInfoであることが期待される
|
||||
reply.code(429);
|
||||
|
||||
if (hasRateLimitInfo(err)) {
|
||||
const cooldownInSeconds = Math.ceil((err.info.resetMs - Date.now()) / 1000);
|
||||
// もしかするとマイナスになる可能性がなくはないのでマイナスだったら0にしておく
|
||||
reply.header('Retry-After', Math.max(cooldownInSeconds, 0).toString(10));
|
||||
}
|
||||
|
||||
reply.code(429);
|
||||
reply.send({
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => {
|
|||
}
|
||||
|
||||
const subject = `acct:${user.username}@${this.config.host}`;
|
||||
const profileLink = `${this.config.url}/@${user.username}`;
|
||||
const self = {
|
||||
rel: 'self',
|
||||
type: 'application/activity+json',
|
||||
|
|
@ -148,7 +149,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => {
|
|||
const profilePage = {
|
||||
rel: 'http://webfinger.net/rel/profile-page',
|
||||
type: 'text/html',
|
||||
href: `${this.config.url}/@${user.username}`,
|
||||
href: profileLink,
|
||||
};
|
||||
const subscribe = {
|
||||
rel: 'http://ostatus.org/schema/1.0/subscribe',
|
||||
|
|
@ -164,12 +165,14 @@ fastify.get('/.well-known/change-password', async (request, reply) => {
|
|||
{ element: 'Subject', value: subject },
|
||||
{ element: 'Link', attributes: self },
|
||||
{ element: 'Link', attributes: profilePage },
|
||||
{ element: 'Link', attributes: subscribe });
|
||||
{ element: 'Link', attributes: subscribe },
|
||||
{ element: 'Alias', value: profileLink });
|
||||
} else {
|
||||
reply.type(jrd);
|
||||
return {
|
||||
subject,
|
||||
links: [self, profilePage, subscribe],
|
||||
aliases: [profileLink],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -311,7 +311,15 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||
throw new ApiError(accessDenied);
|
||||
}
|
||||
|
||||
if (ep.meta.limit) {
|
||||
// For endpoints without a limit, the default is 10 calls per second
|
||||
const endpointLimit: IEndpointMeta['limit'] = ep.meta.limit ?? {
|
||||
duration: 1000,
|
||||
max: 10,
|
||||
};
|
||||
|
||||
// We don't need this check, but removing it would cause a big merge conflict.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (endpointLimit) {
|
||||
// koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
|
||||
let limitActor: string;
|
||||
if (user) {
|
||||
|
|
@ -320,7 +328,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||
limitActor = getIpHash(request.ip);
|
||||
}
|
||||
|
||||
const limit = Object.assign({}, ep.meta.limit);
|
||||
const limit = Object.assign({}, endpointLimit);
|
||||
|
||||
if (limit.key == null) {
|
||||
(limit as any).key = ep.name;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ export const meta = {
|
|||
ref: 'Announcement',
|
||||
},
|
||||
},
|
||||
|
||||
// 2 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 2,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ export const meta = {
|
|||
id: 'b57b5e1d-4f49-404a-9edb-46b00268f121',
|
||||
},
|
||||
},
|
||||
|
||||
// 5 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 5,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,12 @@ export const meta = {
|
|||
optional: false, nullable: false,
|
||||
ref: 'Antenna',
|
||||
},
|
||||
|
||||
// 2 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 2,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ export const meta = {
|
|||
id: 'b34dcf9d-348f-44bb-99d0-6c9314cfe2df',
|
||||
},
|
||||
},
|
||||
|
||||
// 2 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 2,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ export const meta = {
|
|||
ref: 'Antenna',
|
||||
},
|
||||
},
|
||||
|
||||
// 3 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 3,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,12 @@ export const meta = {
|
|||
ref: 'Note',
|
||||
},
|
||||
},
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ export const meta = {
|
|||
optional: false, nullable: false,
|
||||
ref: 'Antenna',
|
||||
},
|
||||
|
||||
// 3 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 3,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@ export const meta = {
|
|||
optional: false, nullable: false,
|
||||
ref: 'Antenna',
|
||||
},
|
||||
|
||||
// 2 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 2,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { ApResolverService } from '@/core/activitypub/ApResolverService.js';
|
|||
export const meta = {
|
||||
tags: ['federation'],
|
||||
|
||||
requireAdmin: true,
|
||||
requireCredential: true,
|
||||
kind: 'read:federation',
|
||||
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
return await this.mergePack(
|
||||
me,
|
||||
isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null,
|
||||
isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, true) : null,
|
||||
isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, undefined, true) : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ export const meta = {
|
|||
optional: false, nullable: false,
|
||||
ref: 'App',
|
||||
},
|
||||
|
||||
// 3 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 3,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ export const meta = {
|
|||
optional: false, nullable: false,
|
||||
ref: 'App',
|
||||
},
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ export const meta = {
|
|||
id: '9c72d8de-391a-43c1-9d06-08d29efde8df',
|
||||
},
|
||||
},
|
||||
|
||||
// 2 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 2,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,12 @@ export const meta = {
|
|||
id: '92f93e63-428e-4f2f-a5a4-39e1407fe998',
|
||||
},
|
||||
},
|
||||
|
||||
// 2 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 2,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 2 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 2,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -51,6 +51,12 @@ export const meta = {
|
|||
id: '8c8a4145-02cc-4cca-8e66-29ba60445a8e',
|
||||
},
|
||||
},
|
||||
|
||||
// 2 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 2,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ export const meta = {
|
|||
ref: 'Blocking',
|
||||
},
|
||||
},
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,12 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 2 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 2,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ export const meta = {
|
|||
id: '4938f5f3-6167-4c04-9149-6607b7542861',
|
||||
},
|
||||
},
|
||||
|
||||
// 3 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 3,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ export const meta = {
|
|||
ref: 'Channel',
|
||||
},
|
||||
},
|
||||
|
||||
// 3 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 3,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ export const meta = {
|
|||
id: 'c0031718-d573-4e85-928e-10039f1fbb68',
|
||||
},
|
||||
},
|
||||
|
||||
// 3 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 3,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ export const meta = {
|
|||
ref: 'Channel',
|
||||
},
|
||||
},
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ export const meta = {
|
|||
ref: 'Channel',
|
||||
},
|
||||
},
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ export const meta = {
|
|||
ref: 'Channel',
|
||||
},
|
||||
},
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ export const meta = {
|
|||
ref: 'Channel',
|
||||
},
|
||||
},
|
||||
|
||||
// 3 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 3,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ export const meta = {
|
|||
id: '6f6c314b-7486-4897-8966-c04a66a02923',
|
||||
},
|
||||
},
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,12 @@ export const meta = {
|
|||
id: '4d0eeeba-a02c-4c3c-9966-ef60d38d2e7f',
|
||||
},
|
||||
},
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ export const meta = {
|
|||
id: '353c68dd-131a-476c-aa99-88a345e83668',
|
||||
},
|
||||
},
|
||||
|
||||
// 3 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 3,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,12 @@ export const meta = {
|
|||
id: '19959ee9-0153-4c51-bbd9-a98c49dc59d6',
|
||||
},
|
||||
},
|
||||
|
||||
// 3 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 3,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import ms from 'ms';
|
||||
|
||||
export const meta = {
|
||||
tags: ['channels'],
|
||||
|
|
@ -43,6 +44,11 @@ export const meta = {
|
|||
id: 'e86c14a4-0da2-4032-8df3-e737a04c7f3b',
|
||||
},
|
||||
},
|
||||
|
||||
limit: {
|
||||
duration: ms('1hour'),
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ export const meta = {
|
|||
|
||||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ export const meta = {
|
|||
|
||||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ export const meta = {
|
|||
|
||||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ export const meta = {
|
|||
|
||||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ export const meta = {
|
|||
|
||||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ export const meta = {
|
|||
|
||||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ export const meta = {
|
|||
|
||||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ export const meta = {
|
|||
|
||||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ export const meta = {
|
|||
|
||||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ export const meta = {
|
|||
|
||||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ export const meta = {
|
|||
|
||||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ export const meta = {
|
|||
|
||||
allowGet: true,
|
||||
cacheSec: 60 * 60,
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -18,9 +18,10 @@ export const meta = {
|
|||
|
||||
kind: 'write:account',
|
||||
|
||||
// 60 calls per hour
|
||||
limit: {
|
||||
duration: ms('1hour'),
|
||||
max: 20,
|
||||
max: 60,
|
||||
},
|
||||
|
||||
errors: {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ export const meta = {
|
|||
id: '920f7c2d-6208-4b76-8082-e632020f5883',
|
||||
},
|
||||
},
|
||||
|
||||
// 2 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 2,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ export const meta = {
|
|||
id: '70ca08ba-6865-4630-b6fb-8494759aa754',
|
||||
},
|
||||
},
|
||||
|
||||
// 2 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 2,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ export const meta = {
|
|||
id: '92658936-c625-4273-8326-2d790129256e',
|
||||
},
|
||||
},
|
||||
|
||||
// 3 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 3,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ export const meta = {
|
|||
ref: 'Clip',
|
||||
},
|
||||
},
|
||||
|
||||
// 3 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 3,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,12 @@ export const meta = {
|
|||
ref: 'Clip',
|
||||
},
|
||||
},
|
||||
|
||||
// 3 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 3,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,12 @@ export const meta = {
|
|||
ref: 'Note',
|
||||
},
|
||||
},
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
limit: {
|
||||
duration: 1000 * 5,
|
||||
max: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ export const meta = {
|
|||
id: 'aff017de-190e-434b-893e-33a9ff5049d8',
|
||||
},
|
||||
},
|
||||
|
||||
// 2 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 2,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ export const meta = {
|
|||
optional: false, nullable: false,
|
||||
ref: 'Clip',
|
||||
},
|
||||
|
||||
// 3 calls per second
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 3,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
|
|
|
|||
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