mirror of
https://codeberg.org/yeentown/barkey.git
synced 2025-10-25 18:54:52 +00:00
merge: prepare release 2025.4.3 (!1125)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1125 Approved-by: Marie <github@yuugi.dev> Approved-by: Julia <julia@insertdomain.name>
This commit is contained in:
commit
a77c32b17d
396 changed files with 10508 additions and 5098 deletions
|
|
@ -115,8 +115,14 @@ db:
|
||||||
user: postgres
|
user: postgres
|
||||||
pass: ci
|
pass: ci
|
||||||
|
|
||||||
# Whether disable Caching queries
|
## Log a warning to the server console if any query takes longer than this to complete.
|
||||||
#disableCache: true
|
## Measured in milliseconds; set to 0 to disable. (default: 300)
|
||||||
|
#slowQueryThreshold: 300
|
||||||
|
|
||||||
|
# If false, then query results will be cached in redis.
|
||||||
|
# If true (default), then queries will not be cached.
|
||||||
|
# This will reduce database load at the cost of increased Redis traffic and risk of bugs and unpredictable behavior.
|
||||||
|
#disableCache: false
|
||||||
|
|
||||||
# Extra Connection options
|
# Extra Connection options
|
||||||
#extra:
|
#extra:
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,14 @@ db:
|
||||||
user: postgres
|
user: postgres
|
||||||
pass: postgres
|
pass: postgres
|
||||||
|
|
||||||
# Whether disable Caching queries
|
## Log a warning to the server console if any query takes longer than this to complete.
|
||||||
#disableCache: true
|
## Measured in milliseconds; set to 0 to disable. (default: 300)
|
||||||
|
#slowQueryThreshold: 300
|
||||||
|
|
||||||
|
# If false, then query results will be cached in redis.
|
||||||
|
# If true (default), then queries will not be cached.
|
||||||
|
# This will reduce database load at the cost of increased Redis traffic and risk of bugs and unpredictable behavior.
|
||||||
|
#disableCache: false
|
||||||
|
|
||||||
# Extra Connection options
|
# Extra Connection options
|
||||||
#extra:
|
#extra:
|
||||||
|
|
|
||||||
|
|
@ -118,8 +118,14 @@ db:
|
||||||
user: example-misskey-user
|
user: example-misskey-user
|
||||||
pass: example-misskey-pass
|
pass: example-misskey-pass
|
||||||
|
|
||||||
# Whether disable Caching queries
|
## Log a warning to the server console if any query takes longer than this to complete.
|
||||||
#disableCache: true
|
## Measured in milliseconds; set to 0 to disable. (default: 300)
|
||||||
|
#slowQueryThreshold: 300
|
||||||
|
|
||||||
|
# If false, then query results will be cached in redis.
|
||||||
|
# If true (default), then queries will not be cached.
|
||||||
|
# This will reduce database load at the cost of increased Redis traffic and risk of bugs and unpredictable behavior.
|
||||||
|
#disableCache: false
|
||||||
|
|
||||||
# Extra Connection options
|
# Extra Connection options
|
||||||
#extra:
|
#extra:
|
||||||
|
|
|
||||||
|
|
@ -121,8 +121,14 @@ db:
|
||||||
user: sharkey
|
user: sharkey
|
||||||
pass: example-misskey-pass
|
pass: example-misskey-pass
|
||||||
|
|
||||||
# Whether disable Caching queries
|
## Log a warning to the server console if any query takes longer than this to complete.
|
||||||
#disableCache: true
|
## Measured in milliseconds; set to 0 to disable. (default: 300)
|
||||||
|
#slowQueryThreshold: 300
|
||||||
|
|
||||||
|
# If false, then query results will be cached in redis.
|
||||||
|
# If true (default), then queries will not be cached.
|
||||||
|
# This will reduce database load at the cost of increased Redis traffic and risk of bugs and unpredictable behavior.
|
||||||
|
#disableCache: false
|
||||||
|
|
||||||
# Extra Connection options
|
# Extra Connection options
|
||||||
#extra:
|
#extra:
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -87,3 +87,7 @@ vite.config.local-dev.ts.timestamp-*
|
||||||
|
|
||||||
# Sharkey
|
# Sharkey
|
||||||
/packages/megalodon/lib
|
/packages/megalodon/lib
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
.tsbuildinfo
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
|
||||||
|
|
@ -622,6 +622,35 @@ marginはそのコンポーネントを使う側が設定する
|
||||||
### indexというファイル名を使うな
|
### indexというファイル名を使うな
|
||||||
ESMではディレクトリインポートは廃止されているのと、ディレクトリインポートせずともファイル名が index だと何故か一部のライブラリ?でディレクトリインポートだと見做されてエラーになる
|
ESMではディレクトリインポートは廃止されているのと、ディレクトリインポートせずともファイル名が index だと何故か一部のライブラリ?でディレクトリインポートだと見做されてエラーになる
|
||||||
|
|
||||||
|
### Memory Caches
|
||||||
|
|
||||||
|
Sharkey offers multiple memory cache implementations, each meant for a different use case.
|
||||||
|
The following table compares the available options:
|
||||||
|
|
||||||
|
| Cache | Type | Consistency | Persistence | Data Source | Cardinality | Eviction | Description |
|
||||||
|
|---------------------|-----------|-------------|-------------|-------------|-------------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `MemoryKVCache` | Key-Value | None | None | Caller | Single | Lifetime | Implements a basic in-memory Key-Value store. The implementation is entirely synchronous, except for user-provided data sources. |
|
||||||
|
| `MemorySingleCache` | Single | None | None | Caller | Single | Lifetime | Implements a basic in-memory Single Value store. The implementation is entirely synchronous, except for user-provided data sources. |
|
||||||
|
| `RedisKVCache` | Key-Value | Eventual | Redis | Callback | Single | Lifetime | Extends `MemoryKVCache` with Redis-backed persistence and a pre-defined callback data source. This provides eventual consistency guarantees based on the memory cache lifetime. |
|
||||||
|
| `RedisSingleCache` | Single | Eventual | Redis | Callback | Single | Lifetime | Extends `MemorySingleCache` with Redis-backed persistence and a pre-defined callback data source. This provides eventual consistency guarantees based on the memory cache lifetime. |
|
||||||
|
| `QuantumKVCache` | Key-Value | Immediate | None | Callback | Multiple | Lifetime | Combines `MemoryKVCache` with a pre-defined callback data source and immediate consistency via Redis sync events. The implementation offers multi-item batch overloads for efficient bulk operations. **This is the recommended cache implementation for most use cases.** |
|
||||||
|
|
||||||
|
Key-Value caches store multiple entries per cache, while Single caches store a single value that can be accessed directly.
|
||||||
|
Consistency refers to the consistency of cached data between different processes in the instance cluster: "None" means no consistency guarantees, "Eventual" caches will gradually become consistent after some unknown time, and "Immediate" consistency ensures accurate data ASAP after the update.
|
||||||
|
Caches with persistence can retain their data after a reboot through an external service such as Redis.
|
||||||
|
If a data source is supported, then this allows the cache to directly load missing data in response to a fetch.
|
||||||
|
"Caller" data sources are passed into the fetch method(s) directly, while "Callback" sources are passed in as a function when the cache is first initialized.
|
||||||
|
The cardinality of a cache refers to the number of items that can be updated in a single operation, and eviction, finally, is the method that the cache uses to evict stale data.
|
||||||
|
|
||||||
|
#### Selecting a cache implementation
|
||||||
|
|
||||||
|
For most cache uses, `QuantumKVCache` should be considered first.
|
||||||
|
It offers strong consistency guarantees, multiple cardinality, and a cleaner API surface than the older caches.
|
||||||
|
An alternate cache implementation should be considered if any of the following apply:
|
||||||
|
* The data is particularly slow to calculate or difficult to access. In these cases, either `RedisKVCache` or `RedisSingleCache` should be considered.
|
||||||
|
* If stale data is acceptable, then consider `MemoryKVCache` or `MemorySingleCache`. These synchronous implementations have much less overhead than the other options.
|
||||||
|
* There is only one data item, or all data items must be fetched together. Using `MemorySingleCache` or `RedisSingleCache` could provide a cleaner implementation without resorting to hacks like a fixed key.
|
||||||
|
|
||||||
## CSS Recipe
|
## CSS Recipe
|
||||||
|
|
||||||
### Lighten CSS vars
|
### Lighten CSS vars
|
||||||
|
|
@ -690,7 +719,7 @@ seems to do a decent job)
|
||||||
* re-generate locales (`pnpm run build-assets`) and commit
|
* re-generate locales (`pnpm run build-assets`) and commit
|
||||||
* build the frontend: `rm -rf built/; NODE_ENV=development pnpm --filter=frontend --filter=frontend-embed --filter=frontend-shared build` (the `development` tells it to keep some of the original filenames in the built files)
|
* build the frontend: `rm -rf built/; NODE_ENV=development pnpm --filter=frontend --filter=frontend-embed --filter=frontend-shared build` (the `development` tells it to keep some of the original filenames in the built files)
|
||||||
* make sure there aren't any new `ti-*` classes (Tabler Icons), and replace them with appropriate `ph-*` ones (Phosphor Icons) in [`vite.replaceicons.ts`](packages/frontend/vite.replaceIcons.ts).
|
* make sure there aren't any new `ti-*` classes (Tabler Icons), and replace them with appropriate `ph-*` ones (Phosphor Icons) in [`vite.replaceicons.ts`](packages/frontend/vite.replaceIcons.ts).
|
||||||
* This command should show you want to change: `grep -ohrP '(?<=["'\'']ti )(ti-(?!fw)[\w\-]+)' --exclude \*.map -- built/ | sort -u`.
|
* This command should show you want to change: `grep -ohrP '(?<=["'\''](ti )?)(ti-(?!fw)[\w\-]+)' --exclude \*.map -- built/ | sort -u`.
|
||||||
* NOTE: `ti-fw` is a special class that's defined by Misskey, leave it alone.
|
* NOTE: `ti-fw` is a special class that's defined by Misskey, leave it alone.
|
||||||
* After every change, re-build the frontend and check again, until there are no more `ti-*` classes in the built files.
|
* After every change, re-build the frontend and check again, until there are no more `ti-*` classes in the built files.
|
||||||
* Commit!
|
* Commit!
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ ARG UID="991"
|
||||||
ARG GID="991"
|
ARG GID="991"
|
||||||
ENV COREPACK_DEFAULT_TO_LATEST=0
|
ENV COREPACK_DEFAULT_TO_LATEST=0
|
||||||
|
|
||||||
RUN apk add ffmpeg tini jemalloc pixman pango cairo libpng \
|
RUN apk add ffmpeg tini jemalloc pixman pango cairo libpng librsvg font-noto font-noto-cjk font-noto-thai \
|
||||||
&& corepack enable \
|
&& corepack enable \
|
||||||
&& addgroup -g "${GID}" sharkey \
|
&& addgroup -g "${GID}" sharkey \
|
||||||
&& adduser -D -u "${UID}" -G sharkey -h /sharkey sharkey \
|
&& adduser -D -u "${UID}" -G sharkey -h /sharkey sharkey \
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "es5"],
|
"lib": ["dom", "es5"],
|
||||||
"target": "es5",
|
"target": "es5",
|
||||||
"types": ["cypress", "node"]
|
"types": ["cypress", "node"],
|
||||||
|
"incremental": true
|
||||||
},
|
},
|
||||||
"include": ["./**/*.ts"]
|
"include": ["./**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
132
locales/index.d.ts
vendored
132
locales/index.d.ts
vendored
|
|
@ -12492,6 +12492,14 @@ export interface Locale extends ILocale {
|
||||||
* Displays content centered.
|
* Displays content centered.
|
||||||
*/
|
*/
|
||||||
"centerDescription": string;
|
"centerDescription": string;
|
||||||
|
/**
|
||||||
|
* Unix Time
|
||||||
|
*/
|
||||||
|
"unixtime": string;
|
||||||
|
/**
|
||||||
|
* Displays a timestamp in the viewer's current timezone.
|
||||||
|
*/
|
||||||
|
"unixtimeDescription": string;
|
||||||
/**
|
/**
|
||||||
* Code (Inline)
|
* Code (Inline)
|
||||||
*/
|
*/
|
||||||
|
|
@ -13069,6 +13077,26 @@ export interface Locale extends ILocale {
|
||||||
* Users popular on {name}
|
* Users popular on {name}
|
||||||
*/
|
*/
|
||||||
"popularUsersLocal": ParameterizedString<"name">;
|
"popularUsersLocal": ParameterizedString<"name">;
|
||||||
|
/**
|
||||||
|
* Polls trending on {name}
|
||||||
|
*/
|
||||||
|
"pollsOnLocal": ParameterizedString<"name">;
|
||||||
|
/**
|
||||||
|
* Polls trending on the global network
|
||||||
|
*/
|
||||||
|
"pollsOnRemote": string;
|
||||||
|
/**
|
||||||
|
* Polls that have ended recently
|
||||||
|
*/
|
||||||
|
"pollsExpired": string;
|
||||||
|
/**
|
||||||
|
* Trending polls are disabled on this instance.
|
||||||
|
*/
|
||||||
|
"trendingPollsDisabled": string;
|
||||||
|
/**
|
||||||
|
* Please log in to view trending polls.
|
||||||
|
*/
|
||||||
|
"trendingPollsDisabledLogIn": string;
|
||||||
/**
|
/**
|
||||||
* Silenced
|
* Silenced
|
||||||
*/
|
*/
|
||||||
|
|
@ -13129,6 +13157,110 @@ export interface Locale extends ILocale {
|
||||||
* Timeout in milliseconds for translation API requests.
|
* Timeout in milliseconds for translation API requests.
|
||||||
*/
|
*/
|
||||||
"translationTimeoutCaption": string;
|
"translationTimeoutCaption": string;
|
||||||
|
/**
|
||||||
|
* Staff notes
|
||||||
|
*/
|
||||||
|
"staffNotes": string;
|
||||||
|
/**
|
||||||
|
* Icon of {name}
|
||||||
|
*/
|
||||||
|
"instanceIconAlt": ParameterizedString<"name">;
|
||||||
|
/**
|
||||||
|
* Attribution Domains
|
||||||
|
*/
|
||||||
|
"attributionDomains": string;
|
||||||
|
/**
|
||||||
|
* A list of domains whose content can be attributed to you on link previews, separated by new-line. Any subdomain will also be valid. The following needs to be on the webpage:
|
||||||
|
*/
|
||||||
|
"attributionDomainsDescription": string;
|
||||||
|
/**
|
||||||
|
* Written by {user}
|
||||||
|
*/
|
||||||
|
"writtenBy": ParameterizedString<"user">;
|
||||||
|
/**
|
||||||
|
* Following (Pub)
|
||||||
|
*/
|
||||||
|
"followingPub": string;
|
||||||
|
/**
|
||||||
|
* Followers (Sub)
|
||||||
|
*/
|
||||||
|
"followersSub": string;
|
||||||
|
/**
|
||||||
|
* Well-known resources
|
||||||
|
*/
|
||||||
|
"wellKnownResources": string;
|
||||||
|
/**
|
||||||
|
* Last posted: {at}
|
||||||
|
*/
|
||||||
|
"lastPosted": ParameterizedString<"at">;
|
||||||
|
/**
|
||||||
|
* NSFW
|
||||||
|
*/
|
||||||
|
"nsfw": string;
|
||||||
|
/**
|
||||||
|
* Raw
|
||||||
|
*/
|
||||||
|
"raw": string;
|
||||||
|
/**
|
||||||
|
* CW
|
||||||
|
*/
|
||||||
|
"cw": string;
|
||||||
|
/**
|
||||||
|
* Media Silenced
|
||||||
|
*/
|
||||||
|
"mediaSilenced": string;
|
||||||
|
/**
|
||||||
|
* Bubble
|
||||||
|
*/
|
||||||
|
"bubble": string;
|
||||||
|
/**
|
||||||
|
* Verified
|
||||||
|
*/
|
||||||
|
"verified": string;
|
||||||
|
/**
|
||||||
|
* Not Verified
|
||||||
|
*/
|
||||||
|
"notVerified": string;
|
||||||
|
/**
|
||||||
|
* Hibernated
|
||||||
|
*/
|
||||||
|
"hibernated": string;
|
||||||
|
/**
|
||||||
|
* When replying to a post with a Content Warning, automatically use the same CW for the reply.
|
||||||
|
*/
|
||||||
|
"keepCwDescription": string;
|
||||||
|
/**
|
||||||
|
* Disabled (do not copy CWs)
|
||||||
|
*/
|
||||||
|
"keepCwDisabled": string;
|
||||||
|
/**
|
||||||
|
* Enabled (copy CWs verbatim)
|
||||||
|
*/
|
||||||
|
"keepCwEnabled": string;
|
||||||
|
/**
|
||||||
|
* Enabled (copy CW and prepend "RE:")
|
||||||
|
*/
|
||||||
|
"keepCwPrependRe": string;
|
||||||
|
/**
|
||||||
|
* Note controls
|
||||||
|
*/
|
||||||
|
"noteFooterLabel": string;
|
||||||
|
/**
|
||||||
|
* Packed user data in its raw form. Most of these fields are public and visible to all users.
|
||||||
|
*/
|
||||||
|
"rawUserDescription": string;
|
||||||
|
/**
|
||||||
|
* Extended user data in its raw form. These fields are private and can only be accessed by moderators.
|
||||||
|
*/
|
||||||
|
"rawInfoDescription": string;
|
||||||
|
/**
|
||||||
|
* ActivityPub user data in its raw form. These fields are public and accessible to other instances.
|
||||||
|
*/
|
||||||
|
"rawApDescription": string;
|
||||||
|
/**
|
||||||
|
* Signup Reason
|
||||||
|
*/
|
||||||
|
"signupReason": string;
|
||||||
}
|
}
|
||||||
declare const locales: {
|
declare const locales: {
|
||||||
[lang: string]: Locale;
|
[lang: string]: Locale;
|
||||||
|
|
|
||||||
30
package.json
30
package.json
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "sharkey",
|
"name": "sharkey",
|
||||||
"version": "2025.4.2",
|
"version": "2025.4.3",
|
||||||
"codename": "shonk",
|
"codename": "shonk",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
@ -54,17 +54,7 @@
|
||||||
"lodash": "4.17.21"
|
"lodash": "4.17.21"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssnano": "7.0.6",
|
"js-yaml": "4.1.0"
|
||||||
"esbuild": "0.25.3",
|
|
||||||
"execa": "9.5.2",
|
|
||||||
"fast-glob": "3.3.3",
|
|
||||||
"glob": "11.0.2",
|
|
||||||
"ignore-walk": "7.0.0",
|
|
||||||
"js-yaml": "4.1.0",
|
|
||||||
"postcss": "8.5.3",
|
|
||||||
"tar": "7.4.3",
|
|
||||||
"terser": "5.39.0",
|
|
||||||
"typescript": "5.8.3"
|
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"cypress": "14.3.2"
|
"cypress": "14.3.2"
|
||||||
|
|
@ -75,10 +65,20 @@
|
||||||
"@typescript-eslint/eslint-plugin": "8.31.0",
|
"@typescript-eslint/eslint-plugin": "8.31.0",
|
||||||
"@typescript-eslint/parser": "8.31.0",
|
"@typescript-eslint/parser": "8.31.0",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
|
"cssnano": "7.0.6",
|
||||||
|
"esbuild": "0.25.3",
|
||||||
"eslint": "9.25.1",
|
"eslint": "9.25.1",
|
||||||
"globals": "16.0.0",
|
"execa": "9.5.2",
|
||||||
|
"fast-glob": "3.3.3",
|
||||||
|
"glob": "11.0.2",
|
||||||
|
"globals": "16.1.0",
|
||||||
"ncp": "2.0.0",
|
"ncp": "2.0.0",
|
||||||
"pnpm": "10.10.0",
|
"pnpm": "9.6.0",
|
||||||
"start-server-and-test": "2.0.11"
|
"ignore-walk": "7.0.0",
|
||||||
|
"postcss": "8.5.3",
|
||||||
|
"start-server-and-test": "2.0.11",
|
||||||
|
"tar": "7.4.3",
|
||||||
|
"terser": "5.39.0",
|
||||||
|
"typescript": "5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,35 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class AddMissingIndexes1747938628395 {
|
export class AddMissingIndexes1747938628395 {
|
||||||
name = 'AddMissingIndexes1747938628395'
|
name = 'AddMissingIndexes1747938628395'
|
||||||
|
|
||||||
async up(queryRunner) {
|
async up(queryRunner) {
|
||||||
await queryRunner.query(`CREATE INDEX "IDX_58699f75b9cf904f5f007909cb" ON "user_profile" ("birthday") `);
|
// Some instances have duplicate list entries
|
||||||
await queryRunner.query(`CREATE INDEX "IDX_021015e6683570ae9f6b0c62be" ON "user_list_membership" ("userId") `);
|
await queryRunner.query(`
|
||||||
await queryRunner.query(`CREATE INDEX "IDX_cddcaf418dc4d392ecfcca842a" ON "user_list_membership" ("userListId") `);
|
DELETE FROM "user_list_membership"
|
||||||
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_e4f3094c43f2d665e6030b0337" ON "user_list_membership" ("userId", "userListId") `);
|
WHERE "id" NOT IN (
|
||||||
}
|
SELECT MIN("id")
|
||||||
|
FROM "user_list_membership"
|
||||||
|
GROUP BY "userId", "userListId"
|
||||||
|
)`);
|
||||||
|
|
||||||
async down(queryRunner) {
|
// Some instances already have these indexes, for an unknown reason
|
||||||
await queryRunner.query(`DROP INDEX "public"."IDX_e4f3094c43f2d665e6030b0337"`);
|
await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_e4f3094c43f2d665e6030b0337"`);
|
||||||
await queryRunner.query(`DROP INDEX "public"."IDX_cddcaf418dc4d392ecfcca842a"`);
|
await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_cddcaf418dc4d392ecfcca842a"`);
|
||||||
await queryRunner.query(`DROP INDEX "public"."IDX_021015e6683570ae9f6b0c62be"`);
|
await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_021015e6683570ae9f6b0c62be"`);
|
||||||
await queryRunner.query(`DROP INDEX "public"."IDX_58699f75b9cf904f5f007909cb"`);
|
await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_58699f75b9cf904f5f007909cb"`);
|
||||||
}
|
|
||||||
|
// Now the actual migration
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_58699f75b9cf904f5f007909cb" ON "user_profile" ("birthday") `);
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_021015e6683570ae9f6b0c62be" ON "user_list_membership" ("userId") `);
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_cddcaf418dc4d392ecfcca842a" ON "user_list_membership" ("userListId") `);
|
||||||
|
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_e4f3094c43f2d665e6030b0337" ON "user_list_membership" ("userId", "userListId") `);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_e4f3094c43f2d665e6030b0337"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_cddcaf418dc4d392ecfcca842a"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_021015e6683570ae9f6b0c62be"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_58699f75b9cf904f5f007909cb"`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: piuvas and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AddAttributionDomains1748096357260 {
|
||||||
|
name = 'AddAttributionDomains1748096357260'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" ADD "attributionDomains" text array NOT NULL DEFAULT '{}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "attributionDomains"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class IndexIDXInstanceHostKey1748104955717 {
|
||||||
|
name = 'IndexIDXInstanceHostKey1748104955717'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_instance_host_key" ON "instance" (((lower(reverse("host")) || '.')::text) text_pattern_ops)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_instance_host_key"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
|
||||||
|
* @typedef {{ blockedHosts: string[], silencedHosts: string[], mediaSilencedHosts: string[], federationHosts: string[], bubbleInstances: string[] }} Meta
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class
|
||||||
|
* @implements {MigrationInterface}
|
||||||
|
*/
|
||||||
|
export class AddInstanceBlockColumns1748105111513 {
|
||||||
|
name = 'AddInstanceBlockColumns1748105111513'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
// Schema migration
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" ADD "isBlocked" boolean NOT NULL DEFAULT false`);
|
||||||
|
await queryRunner.query(`COMMENT ON COLUMN "instance"."isBlocked" IS 'True if this instance is blocked from federation.'`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" ADD "isAllowListed" boolean NOT NULL DEFAULT false`);
|
||||||
|
await queryRunner.query(`COMMENT ON COLUMN "instance"."isAllowListed" IS 'True if this instance is allow-listed.'`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" ADD "isBubbled" boolean NOT NULL DEFAULT false`);
|
||||||
|
await queryRunner.query(`COMMENT ON COLUMN "instance"."isBubbled" IS 'True if this instance is part of the local bubble.'`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" ADD "isSilenced" boolean NOT NULL DEFAULT false`);
|
||||||
|
await queryRunner.query(`COMMENT ON COLUMN "instance"."isSilenced" IS 'True if this instance is silenced.'`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" ADD "isMediaSilenced" boolean NOT NULL DEFAULT false`);
|
||||||
|
await queryRunner.query(`COMMENT ON COLUMN "instance"."isMediaSilenced" IS 'True if this instance is media-silenced.'`);
|
||||||
|
|
||||||
|
// Data migration
|
||||||
|
/** @type {Meta[]} */
|
||||||
|
const metas = await queryRunner.query(`SELECT "blockedHosts", "silencedHosts", "mediaSilencedHosts", "federationHosts", "bubbleInstances" FROM "meta"`);
|
||||||
|
if (metas.length > 0) {
|
||||||
|
/** @type {Meta} */
|
||||||
|
const meta = metas[0];
|
||||||
|
|
||||||
|
// Blocked hosts
|
||||||
|
if (meta.blockedHosts.length > 0) {
|
||||||
|
const patterns = buildPatterns(meta.blockedHosts);
|
||||||
|
await queryRunner.query(`UPDATE "instance" SET "isBlocked" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silenced hosts
|
||||||
|
if (meta.silencedHosts.length > 0) {
|
||||||
|
const patterns = buildPatterns(meta.silencedHosts);
|
||||||
|
await queryRunner.query(`UPDATE "instance" SET "isSilenced" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media silenced hosts
|
||||||
|
if (meta.mediaSilencedHosts.length > 0) {
|
||||||
|
const patterns = buildPatterns(meta.mediaSilencedHosts);
|
||||||
|
await queryRunner.query(`UPDATE "instance" SET "isMediaSilenced" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow-listed hosts
|
||||||
|
if (meta.federationHosts.length > 0) {
|
||||||
|
const patterns = buildPatterns(meta.federationHosts);
|
||||||
|
await queryRunner.query(`UPDATE "instance" SET "isAllowListed" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bubbled hosts
|
||||||
|
if (meta.bubbleInstances.length > 0) {
|
||||||
|
const patterns = buildPatterns(meta.bubbleInstances);
|
||||||
|
await queryRunner.query(`UPDATE "instance" SET "isBubbled" = true WHERE ((lower(reverse("host")) || '.')::text) LIKE ANY ($1)`, [ patterns ]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isMediaSilenced"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isSilenced"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isBubbled"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isAllowListed"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "isBlocked"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} input
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function buildPatterns(input) {
|
||||||
|
return input.map(i => i.toLowerCase().split('').reverse().join('') + '.%');
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class
|
||||||
|
* @implements {MigrationInterface}
|
||||||
|
*/
|
||||||
|
export class AddInstanceForeignKeys1748128176881 {
|
||||||
|
name = 'AddInstanceForeignKeys1748128176881'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
// Fix-up: Some older instances have users without a matching instance entry
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO "instance" ("id", "host", "firstRetrievedAt")
|
||||||
|
SELECT
|
||||||
|
MIN("id"),
|
||||||
|
"host",
|
||||||
|
COALESCE(MIN("lastFetchedAt"), CURRENT_TIMESTAMP)
|
||||||
|
FROM "user"
|
||||||
|
WHERE
|
||||||
|
"host" IS NOT NULL AND
|
||||||
|
NOT EXISTS (select 1 from "instance" where "instance"."host" = "user"."host")
|
||||||
|
GROUP BY "host"
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" ADD CONSTRAINT "FK_user_host" FOREIGN KEY ("host") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_note_userHost" FOREIGN KEY ("userHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_note_replyUserHost" FOREIGN KEY ("replyUserHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_note_renoteUserHost" FOREIGN KEY ("renoteUserHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_note_renoteUserHost"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_note_replyUserHost"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_note_userHost"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" DROP CONSTRAINT "FK_user_host"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class
|
||||||
|
* @implements {MigrationInterface}
|
||||||
|
*/
|
||||||
|
export class AddInstanceForeignKeysToFollowing1748137683887 {
|
||||||
|
name = 'AddInstanceForeignKeysToFollowing1748137683887'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "following" ADD CONSTRAINT "FK_following_followerHost" FOREIGN KEY ("followerHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "following" ADD CONSTRAINT "FK_following_followeeHost" FOREIGN KEY ("followeeHost") REFERENCES "instance"("host") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "following" DROP CONSTRAINT "FK_following_followeeHost"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "following" DROP CONSTRAINT "FK_following_followerHost"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('typeorm').MigrationInterface} MigrationInterface
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @class
|
||||||
|
* @implements {MigrationInterface}
|
||||||
|
*/
|
||||||
|
export class AnalyzeInstanceUserNoteFollowing1748191631151 {
|
||||||
|
name = 'AnalyzeInstanceUserNoteFollowing1748191631151'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
// Refresh statistics for tables impacted by new indexes.
|
||||||
|
// This helps the query planner to efficiently use them without waiting for the next full vacuum.
|
||||||
|
await queryRunner.query(`ANALYZE "instance", "user", "following", "note"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class ReplaceNoteUserHostIndex1748990452958 {
|
||||||
|
name = 'ReplaceNoteUserHostIndex1748990452958'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_7125a826ab192eb27e11d358a5"`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
create index "IDX_note_userHost_id"
|
||||||
|
on "note" ("userHost", "id" desc)
|
||||||
|
nulls not distinct`);
|
||||||
|
await queryRunner.query(`comment on index "IDX_note_userHost_id" is 'User host with ID included'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`drop index if exists "IDX_note_userHost_id"`);
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_7125a826ab192eb27e11d358a5" ON "note" ("userHost") `);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class FixIDXInstanceHostKey1748990662839 {
|
||||||
|
async up(queryRunner) {
|
||||||
|
// must include host for index-only scans: https://www.postgresql.org/docs/current/indexes-index-only-scans.html
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_instance_host_key"`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
create index "IDX_instance_host_key"
|
||||||
|
on "instance" ((lower(reverse("host"::text)) || '.'::text) text_pattern_ops)
|
||||||
|
include ("host")
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`comment on index "IDX_instance_host_key" is 'Expression index for finding instances by base domain'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_instance_host_key"`);
|
||||||
|
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_instance_host_key" ON "instance" (((lower(reverse("host")) || '.')::text) text_pattern_ops)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class CreateIDXNoteForTimelines1748991828473 {
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
create index "IDX_note_for_timelines"
|
||||||
|
on "note" ("id" desc, "channelId", "visibility", "userHost")
|
||||||
|
include ("userId", "userHost", "replyId", "replyUserId", "replyUserHost", "renoteId", "renoteUserId", "renoteUserHost")
|
||||||
|
NULLS NOT DISTINCT`);
|
||||||
|
await queryRunner.query(`comment on index "IDX_note_for_timelines" is 'Covering index for timeline queries'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_note_for_timelines"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class CreateIDXInstanceHostFilters1748992017688 {
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
create index "IDX_instance_host_filters"
|
||||||
|
on "instance" ("host", "isBlocked", "isSilenced", "isMediaSilenced", "isAllowListed", "isBubbled", "suspensionState")`);
|
||||||
|
await queryRunner.query(`comment on index "IDX_instance_host_filters" is 'Covering index for host filter queries'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_instance_host_filters"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class CreateStatistics1748992128683 {
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`CREATE STATISTICS "STTS_instance_isBlocked_isBubbled" (mcv) ON "isBlocked", "isBubbled" FROM "instance"`);
|
||||||
|
await queryRunner.query(`CREATE STATISTICS "STTS_instance_isBlocked_isSilenced" (mcv) ON "isBlocked", "isSilenced" FROM "instance"`);
|
||||||
|
await queryRunner.query(`CREATE STATISTICS "STTS_note_replyId_replyUserId_replyUserHost" (dependencies) ON "replyId", "replyUserId", "replyUserHost" FROM "note"`)
|
||||||
|
await queryRunner.query(`CREATE STATISTICS "STTS_note_renoteId_renoteUserId_renoteUserHost" (dependencies) ON "renoteId", "renoteUserId", "renoteUserHost" FROM "note"`);
|
||||||
|
await queryRunner.query(`CREATE STATISTICS "STTS_note_userId_userHost" (mcv) ON "userId", "userHost" FROM "note"`);
|
||||||
|
await queryRunner.query(`CREATE STATISTICS "STTS_note_replyUserId_replyUserHost" (mcv) ON "replyUserId", "replyUserHost" FROM "note"`);
|
||||||
|
await queryRunner.query(`CREATE STATISTICS "STTS_note_renoteUserId_renoteUserHost" (mcv) ON "renoteUserId", "renoteUserHost" FROM "note"`);
|
||||||
|
await queryRunner.query(`ANALYZE "note", "instance"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`DROP STATISTICS "STTS_instance_isBlocked_isBubbled"`);
|
||||||
|
await queryRunner.query(`DROP STATISTICS "STTS_instance_isBlocked_isSilenced"`);
|
||||||
|
await queryRunner.query(`DROP STATISTICS "STTS_note_replyId_replyUserId_replyUserHost"`);
|
||||||
|
await queryRunner.query(`DROP STATISTICS "STTS_note_renoteId_renoteUserId_renoteUserHost"`);
|
||||||
|
await queryRunner.query(`DROP STATISTICS "STTS_note_userId_userHost"`);
|
||||||
|
await queryRunner.query(`DROP STATISTICS "STTS_note_replyUserId_replyUserHost"`);
|
||||||
|
await queryRunner.query(`DROP STATISTICS "STTS_note_renoteUserId_renoteUserHost"`);
|
||||||
|
await queryRunner.query(`ANALYZE "note", "instance"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class FixIDXNoteForTimeline1749097536193 {
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query('drop index "IDX_note_for_timelines"');
|
||||||
|
await queryRunner.query(`
|
||||||
|
create index "IDX_note_for_timelines"
|
||||||
|
on "note" ("id" desc, "channelId", "visibility", "userHost")
|
||||||
|
include ("userId", "replyId", "replyUserId", "replyUserHost", "renoteId", "renoteUserId", "renoteUserHost", "threadId")
|
||||||
|
NULLS NOT DISTINCT
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`comment on index "IDX_note_for_timelines" is 'Covering index for timeline queries'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query('drop index "IDX_note_for_timelines"');
|
||||||
|
await queryRunner.query(`
|
||||||
|
create index "IDX_note_for_timelines"
|
||||||
|
on "note" ("id" desc, "channelId", "visibility", "userHost")
|
||||||
|
include ("userId", "userHost", "replyId", "replyUserId", "replyUserHost", "renoteId", "renoteUserId", "renoteUserHost")
|
||||||
|
NULLS NOT DISTINCT
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`comment on index "IDX_note_for_timelines" is 'Covering index for timeline queries'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class CreateIDXNoteUrl1749229288946 {
|
||||||
|
name = 'CreateIDXNoteUrl1749229288946'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_note_url" ON "note" ("url") `);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_note_url"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class RemoveIDXInstanceHostFilters1749267016885 {
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_instance_host_filters"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`
|
||||||
|
create index "IDX_instance_host_filters"
|
||||||
|
on "instance" ("host", "isBlocked", "isSilenced", "isMediaSilenced", "isAllowListed", "isBubbled", "suspensionState")`);
|
||||||
|
await queryRunner.query(`comment on index "IDX_instance_host_filters" is 'Covering index for host filter queries'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,9 @@
|
||||||
"start": "node ./built/boot/entry.js",
|
"start": "node ./built/boot/entry.js",
|
||||||
"start:test": "cross-env NODE_ENV=test node ./built/boot/entry.js",
|
"start:test": "cross-env NODE_ENV=test node ./built/boot/entry.js",
|
||||||
"migrate": "pnpm typeorm migration:run -d ormconfig.js",
|
"migrate": "pnpm typeorm migration:run -d ormconfig.js",
|
||||||
|
"migrate:revert": "pnpm typeorm migration:revert -d ormconfig.js",
|
||||||
|
"migrate:generate": "pnpm typeorm migration:generate -d ormconfig.js",
|
||||||
|
"migrate:create": "pnpm typeorm migration:create",
|
||||||
"revert": "pnpm typeorm migration:revert -d ormconfig.js",
|
"revert": "pnpm typeorm migration:revert -d ormconfig.js",
|
||||||
"check:connect": "node ./scripts/check_connect.js",
|
"check:connect": "node ./scripts/check_connect.js",
|
||||||
"build": "swc src -d built -D --strip-leading-paths",
|
"build": "swc src -d built -D --strip-leading-paths",
|
||||||
|
|
@ -77,7 +80,7 @@
|
||||||
"@fastify/static": "8.1.1",
|
"@fastify/static": "8.1.1",
|
||||||
"@fastify/view": "10.0.2",
|
"@fastify/view": "10.0.2",
|
||||||
"@misskey-dev/sharp-read-bmp": "1.3.0",
|
"@misskey-dev/sharp-read-bmp": "1.3.0",
|
||||||
"@misskey-dev/summaly": "5.2.1",
|
"@misskey-dev/summaly": "npm:@transfem-org/summaly@5.2.2",
|
||||||
"@nestjs/common": "11.1.0",
|
"@nestjs/common": "11.1.0",
|
||||||
"@nestjs/core": "11.1.0",
|
"@nestjs/core": "11.1.0",
|
||||||
"@nestjs/testing": "11.1.0",
|
"@nestjs/testing": "11.1.0",
|
||||||
|
|
@ -87,33 +90,30 @@
|
||||||
"@simplewebauthn/server": "12.0.0",
|
"@simplewebauthn/server": "12.0.0",
|
||||||
"@sinonjs/fake-timers": "11.3.1",
|
"@sinonjs/fake-timers": "11.3.1",
|
||||||
"@smithy/node-http-handler": "2.5.0",
|
"@smithy/node-http-handler": "2.5.0",
|
||||||
"@swc/cli": "0.7.3",
|
"mfm-js": "npm:@transfem-org/sfm-js@0.24.6",
|
||||||
"@swc/core": "1.11.24",
|
|
||||||
"@transfem-org/sfm-js": "0.24.6",
|
|
||||||
"@twemoji/parser": "15.1.1",
|
"@twemoji/parser": "15.1.1",
|
||||||
"accepts": "1.3.8",
|
"accepts": "1.3.8",
|
||||||
"ajv": "8.17.1",
|
"ajv": "8.17.1",
|
||||||
"archiver": "7.0.1",
|
"archiver": "7.0.1",
|
||||||
"argon2": "^0.40.1",
|
"argon2": "0.43.0",
|
||||||
"axios": "1.7.4",
|
"axios": "1.7.4",
|
||||||
"async-mutex": "0.5.0",
|
|
||||||
"bcryptjs": "2.4.3",
|
"bcryptjs": "2.4.3",
|
||||||
"blurhash": "2.0.5",
|
"blurhash": "2.0.5",
|
||||||
"body-parser": "1.20.3",
|
|
||||||
"bullmq": "5.51.1",
|
"bullmq": "5.51.1",
|
||||||
"cacheable-lookup": "7.0.0",
|
"cacheable-lookup": "7.0.0",
|
||||||
"canvas": "^3.1.0",
|
"canvas": "3.1.0",
|
||||||
"cbor": "9.0.2",
|
"cbor": "9.0.2",
|
||||||
"chalk": "5.4.1",
|
"chalk": "5.4.1",
|
||||||
"chalk-template": "1.1.0",
|
"chalk-template": "1.1.0",
|
||||||
"cheerio": "1.0.0",
|
"cheerio": "1.0.0",
|
||||||
"chokidar": "3.6.0",
|
"cli-highlight": "npm:@transfem-org/cli-highlight@2.1.12",
|
||||||
"cli-highlight": "2.1.11",
|
|
||||||
"color-convert": "2.0.1",
|
"color-convert": "2.0.1",
|
||||||
"content-disposition": "0.5.4",
|
"content-disposition": "0.5.4",
|
||||||
"date-fns": "2.30.0",
|
"date-fns": "2.30.0",
|
||||||
"deep-email-validator": "0.1.21",
|
"deep-email-validator": "0.1.21",
|
||||||
"fast-xml-parser": "4.4.1",
|
"dom-serializer": "2.0.0",
|
||||||
|
"domhandler": "5.0.3",
|
||||||
|
"domutils": "3.2.2",
|
||||||
"fastify": "5.3.2",
|
"fastify": "5.3.2",
|
||||||
"fastify-raw-body": "5.0.0",
|
"fastify-raw-body": "5.0.0",
|
||||||
"feed": "4.2.2",
|
"feed": "4.2.2",
|
||||||
|
|
@ -122,10 +122,9 @@
|
||||||
"form-data": "4.0.2",
|
"form-data": "4.0.2",
|
||||||
"glob": "11.0.0",
|
"glob": "11.0.0",
|
||||||
"got": "14.4.7",
|
"got": "14.4.7",
|
||||||
"happy-dom": "16.8.1",
|
|
||||||
"hpagent": "1.2.0",
|
"hpagent": "1.2.0",
|
||||||
"htmlescape": "1.1.1",
|
"htmlescape": "1.1.1",
|
||||||
"http-link-header": "1.1.3",
|
"htmlparser2": "9.1.0",
|
||||||
"ioredis": "5.6.1",
|
"ioredis": "5.6.1",
|
||||||
"ip-cidr": "4.0.2",
|
"ip-cidr": "4.0.2",
|
||||||
"ipaddr.js": "2.2.0",
|
"ipaddr.js": "2.2.0",
|
||||||
|
|
@ -133,49 +132,39 @@
|
||||||
"js-yaml": "4.1.0",
|
"js-yaml": "4.1.0",
|
||||||
"json5": "2.2.3",
|
"json5": "2.2.3",
|
||||||
"jsonld": "8.3.3",
|
"jsonld": "8.3.3",
|
||||||
"jsrsasign": "11.1.0",
|
|
||||||
"juice": "11.0.1",
|
"juice": "11.0.1",
|
||||||
"megalodon": "workspace:*",
|
"megalodon": "workspace:*",
|
||||||
"meilisearch": "0.50.0",
|
"meilisearch": "0.50.0",
|
||||||
"microformats-parser": "2.0.2",
|
|
||||||
"mime-types": "2.1.35",
|
"mime-types": "2.1.35",
|
||||||
"misskey-js": "workspace:*",
|
"misskey-js": "workspace:*",
|
||||||
"misskey-reversi": "workspace:*",
|
"misskey-reversi": "workspace:*",
|
||||||
"moment": "^2.30.1",
|
"moment": "2.30.1",
|
||||||
"ms": "3.0.0-canary.1",
|
"ms": "3.0.0-canary.1",
|
||||||
"nanoid": "5.1.5",
|
"nanoid": "5.1.5",
|
||||||
"nested-property": "4.0.0",
|
"nested-property": "4.0.0",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"nodemailer": "6.10.1",
|
"nodemailer": "6.10.1",
|
||||||
"oauth": "0.10.2",
|
|
||||||
"oauth2orize": "1.12.0",
|
|
||||||
"oauth2orize-pkce": "0.1.2",
|
|
||||||
"os-utils": "0.0.14",
|
"os-utils": "0.0.14",
|
||||||
"otpauth": "9.4.0",
|
"otpauth": "9.4.0",
|
||||||
"parse5": "7.3.0",
|
|
||||||
"pg": "8.15.6",
|
"pg": "8.15.6",
|
||||||
"pkce-challenge": "4.1.0",
|
"pkce-challenge": "4.1.0",
|
||||||
"probe-image-size": "7.2.3",
|
"probe-image-size": "7.2.3",
|
||||||
"promise-limit": "2.7.0",
|
"promise-limit": "2.7.0",
|
||||||
"proxy-addr": "^2.0.7",
|
"proxy-addr": "2.0.7",
|
||||||
"psl": "^1.13.0",
|
"psl": "1.15.0",
|
||||||
"pug": "3.0.3",
|
"pug": "3.0.3",
|
||||||
"qrcode": "1.5.4",
|
"qrcode": "1.5.4",
|
||||||
"random-seed": "0.3.0",
|
"random-seed": "0.3.0",
|
||||||
"ratelimiter": "3.4.1",
|
|
||||||
"re2": "1.21.4",
|
"re2": "1.21.4",
|
||||||
"redis-info": "3.1.0",
|
"redis-info": "3.1.0",
|
||||||
"redis-lock": "0.1.4",
|
"redis-lock": "0.1.4",
|
||||||
"reflect-metadata": "0.2.2",
|
"reflect-metadata": "0.2.2",
|
||||||
"rename": "1.0.4",
|
"rename": "1.0.4",
|
||||||
"rss-parser": "3.13.0",
|
|
||||||
"rxjs": "7.8.2",
|
|
||||||
"sanitize-html": "2.16.0",
|
"sanitize-html": "2.16.0",
|
||||||
"secure-json-parse": "3.0.2",
|
"secure-json-parse": "3.0.2",
|
||||||
"sharp": "0.34.1",
|
"sharp": "0.34.1",
|
||||||
"slacc": "0.0.10",
|
"slacc": "0.0.10",
|
||||||
"strict-event-emitter-types": "2.0.0",
|
"strict-event-emitter-types": "2.0.0",
|
||||||
"stringz": "2.1.0",
|
|
||||||
"systeminformation": "5.25.11",
|
"systeminformation": "5.25.11",
|
||||||
"tinycolor2": "1.6.0",
|
"tinycolor2": "1.6.0",
|
||||||
"tmp": "0.2.3",
|
"tmp": "0.2.3",
|
||||||
|
|
@ -184,7 +173,7 @@
|
||||||
"typeorm": "0.3.22",
|
"typeorm": "0.3.22",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"ulid": "2.4.0",
|
"ulid": "2.4.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "11.1.0",
|
||||||
"vary": "1.1.2",
|
"vary": "1.1.2",
|
||||||
"web-push": "3.6.7",
|
"web-push": "3.6.7",
|
||||||
"ws": "8.18.1",
|
"ws": "8.18.1",
|
||||||
|
|
@ -195,16 +184,16 @@
|
||||||
"@nestjs/platform-express": "11.1.0",
|
"@nestjs/platform-express": "11.1.0",
|
||||||
"@sentry/vue": "9.14.0",
|
"@sentry/vue": "9.14.0",
|
||||||
"@simplewebauthn/types": "12.0.0",
|
"@simplewebauthn/types": "12.0.0",
|
||||||
|
"@swc/cli": "0.7.3",
|
||||||
|
"@swc/core": "1.11.24",
|
||||||
"@swc/jest": "0.2.38",
|
"@swc/jest": "0.2.38",
|
||||||
"@types/accepts": "1.3.7",
|
"@types/accepts": "1.3.7",
|
||||||
"@types/archiver": "6.0.3",
|
"@types/archiver": "6.0.3",
|
||||||
"@types/bcryptjs": "2.4.6",
|
"@types/bcryptjs": "2.4.6",
|
||||||
"@types/body-parser": "1.19.5",
|
|
||||||
"@types/color-convert": "2.0.4",
|
"@types/color-convert": "2.0.4",
|
||||||
"@types/content-disposition": "0.5.8",
|
"@types/content-disposition": "0.5.8",
|
||||||
"@types/fluent-ffmpeg": "2.1.27",
|
"@types/fluent-ffmpeg": "2.1.27",
|
||||||
"@types/htmlescape": "1.1.3",
|
"@types/htmlescape": "1.1.3",
|
||||||
"@types/http-link-header": "1.0.7",
|
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/jsonld": "1.5.15",
|
"@types/jsonld": "1.5.15",
|
||||||
|
|
@ -217,12 +206,11 @@
|
||||||
"@types/oauth2orize": "1.11.5",
|
"@types/oauth2orize": "1.11.5",
|
||||||
"@types/oauth2orize-pkce": "0.1.2",
|
"@types/oauth2orize-pkce": "0.1.2",
|
||||||
"@types/pg": "8.11.14",
|
"@types/pg": "8.11.14",
|
||||||
"@types/proxy-addr": "^2.0.3",
|
"@types/proxy-addr": "2.0.3",
|
||||||
"@types/psl": "^1.1.3",
|
"@types/psl": "1.1.3",
|
||||||
"@types/pug": "2.0.10",
|
"@types/pug": "2.0.10",
|
||||||
"@types/qrcode": "1.5.5",
|
"@types/qrcode": "1.5.5",
|
||||||
"@types/random-seed": "0.3.5",
|
"@types/random-seed": "0.3.5",
|
||||||
"@types/ratelimiter": "3.4.6",
|
|
||||||
"@types/redis-info": "3.0.3",
|
"@types/redis-info": "3.0.3",
|
||||||
"@types/rename": "1.0.7",
|
"@types/rename": "1.0.7",
|
||||||
"@types/sanitize-html": "2.15.0",
|
"@types/sanitize-html": "2.15.0",
|
||||||
|
|
@ -232,7 +220,6 @@
|
||||||
"@types/supertest": "6.0.3",
|
"@types/supertest": "6.0.3",
|
||||||
"@types/tinycolor2": "1.4.6",
|
"@types/tinycolor2": "1.4.6",
|
||||||
"@types/tmp": "0.2.6",
|
"@types/tmp": "0.2.6",
|
||||||
"@types/uuid": "^9.0.4",
|
|
||||||
"@types/vary": "1.1.3",
|
"@types/vary": "1.1.3",
|
||||||
"@types/web-push": "3.6.4",
|
"@types/web-push": "3.6.4",
|
||||||
"@types/ws": "8.18.1",
|
"@types/ws": "8.18.1",
|
||||||
|
|
@ -241,7 +228,7 @@
|
||||||
"aws-sdk-client-mock": "4.1.0",
|
"aws-sdk-client-mock": "4.1.0",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"eslint-plugin-import": "2.31.0",
|
"eslint-plugin-import": "2.31.0",
|
||||||
"execa": "8.0.1",
|
"execa": "9.5.2",
|
||||||
"fkill": "9.0.0",
|
"fkill": "9.0.0",
|
||||||
"jest": "29.7.0",
|
"jest": "29.7.0",
|
||||||
"jest-mock": "29.7.0",
|
"jest-mock": "29.7.0",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
import cluster from 'node:cluster';
|
import cluster from 'node:cluster';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { inspect } from 'node:util';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import Xev from 'xev';
|
import Xev from 'xev';
|
||||||
import Logger from '@/logger.js';
|
import Logger from '@/logger.js';
|
||||||
|
|
@ -53,20 +54,45 @@ async function main() {
|
||||||
|
|
||||||
// Display detail of unhandled promise rejection
|
// Display detail of unhandled promise rejection
|
||||||
if (!envOption.quiet) {
|
if (!envOption.quiet) {
|
||||||
process.on('unhandledRejection', console.dir);
|
process.on('unhandledRejection', e => {
|
||||||
|
try {
|
||||||
|
logger.error('Unhandled rejection:', inspect(e));
|
||||||
|
} catch {
|
||||||
|
console.error('Unhandled rejection:', inspect(e));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display detail of uncaught exception
|
// Display detail of uncaught exception
|
||||||
process.on('uncaughtException', err => {
|
process.on('uncaughtExceptionMonitor', ((err, origin) => {
|
||||||
try {
|
try {
|
||||||
logger.error(err);
|
logger.error(`Uncaught exception (${origin}):`, err);
|
||||||
console.trace(err);
|
} catch {
|
||||||
} catch { }
|
console.error(`Uncaught exception (${origin}):`, err);
|
||||||
});
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
// Dying away...
|
// Dying away...
|
||||||
|
process.on('disconnect', () => {
|
||||||
|
try {
|
||||||
|
logger.warn('IPC channel disconnected! The process may soon die.');
|
||||||
|
} catch {
|
||||||
|
console.warn('IPC channel disconnected! The process may soon die.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
process.on('beforeExit', code => {
|
||||||
|
try {
|
||||||
|
logger.warn(`Event loop died! Process will exit with code ${code}.`);
|
||||||
|
} catch {
|
||||||
|
console.warn(`Event loop died! Process will exit with code ${code}.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
process.on('exit', code => {
|
process.on('exit', code => {
|
||||||
logger.info(`The process is going to exit with code ${code}`);
|
try {
|
||||||
|
logger.info(`The process is going to exit with code ${code}`);
|
||||||
|
} catch {
|
||||||
|
console.info(`The process is going to exit with code ${code}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ export async function masterMain() {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
bootLogger.succ('Sharkey initialized');
|
bootLogger.info('Sharkey initialized');
|
||||||
|
|
||||||
if (config.sentryForBackend) {
|
if (config.sentryForBackend) {
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
|
|
@ -140,10 +140,10 @@ export async function masterMain() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (envOption.onlyQueue) {
|
if (envOption.onlyQueue) {
|
||||||
bootLogger.succ('Queue started', null, true);
|
bootLogger.info('Queue started', null, true);
|
||||||
} else {
|
} else {
|
||||||
const addressString = net.isIPv6(config.address) ? `[${config.address}]` : config.address;
|
const addressString = net.isIPv6(config.address) ? `[${config.address}]` : config.address;
|
||||||
bootLogger.succ(config.socket ? `Now listening on socket ${config.socket} on ${config.url}` : `Now listening on ${addressString}:${config.port} on ${config.url}`, null, true);
|
bootLogger.info(config.socket ? `Now listening on socket ${config.socket} on ${config.url}` : `Now listening on ${addressString}:${config.port} on ${config.url}`, null, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -172,7 +172,7 @@ function loadConfigBoot(): Config {
|
||||||
config = loadConfig();
|
config = loadConfig();
|
||||||
} catch (exception) {
|
} catch (exception) {
|
||||||
if (typeof exception === 'string') {
|
if (typeof exception === 'string') {
|
||||||
configLogger.error(exception);
|
configLogger.error('Exception loading config:', exception);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} else if ((exception as any).code === 'ENOENT') {
|
} else if ((exception as any).code === 'ENOENT') {
|
||||||
configLogger.error('Configuration file not found', null, true);
|
configLogger.error('Configuration file not found', null, true);
|
||||||
|
|
@ -181,7 +181,7 @@ function loadConfigBoot(): Config {
|
||||||
throw exception;
|
throw exception;
|
||||||
}
|
}
|
||||||
|
|
||||||
configLogger.succ('Loaded');
|
configLogger.info('Loaded');
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
@ -195,7 +195,7 @@ async function connectDb(): Promise<void> {
|
||||||
dbLogger.info('Connecting...');
|
dbLogger.info('Connecting...');
|
||||||
await initDb();
|
await initDb();
|
||||||
const v = await db.query('SHOW server_version').then(x => x[0].server_version);
|
const v = await db.query('SHOW server_version').then(x => x[0].server_version);
|
||||||
dbLogger.succ(`Connected: v${v}`);
|
dbLogger.info(`Connected: v${v}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
dbLogger.error('Cannot connect', null, true);
|
dbLogger.error('Cannot connect', null, true);
|
||||||
dbLogger.error(err);
|
dbLogger.error(err);
|
||||||
|
|
@ -211,7 +211,7 @@ async function spawnWorkers(limit = 1) {
|
||||||
|
|
||||||
bootLogger.info(`Starting ${workers} worker${workers === 1 ? '' : 's'}...`);
|
bootLogger.info(`Starting ${workers} worker${workers === 1 ? '' : 's'}...`);
|
||||||
await Promise.all([...Array(workers)].map(spawnWorker));
|
await Promise.all([...Array(workers)].map(spawnWorker));
|
||||||
bootLogger.succ('All workers started');
|
bootLogger.info('All workers started');
|
||||||
}
|
}
|
||||||
|
|
||||||
function spawnWorker(): Promise<void> {
|
function spawnWorker(): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { dirname, resolve } from 'node:path';
|
||||||
import * as yaml from 'js-yaml';
|
import * as yaml from 'js-yaml';
|
||||||
import { globSync } from 'glob';
|
import { globSync } from 'glob';
|
||||||
import ipaddr from 'ipaddr.js';
|
import ipaddr from 'ipaddr.js';
|
||||||
|
import Logger from './logger.js';
|
||||||
import type * as Sentry from '@sentry/node';
|
import type * as Sentry from '@sentry/node';
|
||||||
import type * as SentryVue from '@sentry/vue';
|
import type * as SentryVue from '@sentry/vue';
|
||||||
import type { RedisOptions } from 'ioredis';
|
import type { RedisOptions } from 'ioredis';
|
||||||
|
|
@ -40,6 +41,7 @@ type Source = {
|
||||||
db?: string;
|
db?: string;
|
||||||
user?: string;
|
user?: string;
|
||||||
pass?: string;
|
pass?: string;
|
||||||
|
slowQueryThreshold?: number;
|
||||||
disableCache?: boolean;
|
disableCache?: boolean;
|
||||||
extra?: { [x: string]: string };
|
extra?: { [x: string]: string };
|
||||||
};
|
};
|
||||||
|
|
@ -155,6 +157,8 @@ type Source = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const configLogger = new Logger('config');
|
||||||
|
|
||||||
export type PrivateNetworkSource = string | { network?: string, ports?: number[] };
|
export type PrivateNetworkSource = string | { network?: string, ports?: number[] };
|
||||||
|
|
||||||
export type PrivateNetwork = {
|
export type PrivateNetwork = {
|
||||||
|
|
@ -192,7 +196,7 @@ export function parsePrivateNetworks(patterns: PrivateNetworkSource[] | undefine
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn('[config] Skipping invalid entry in allowedPrivateNetworks: ', e);
|
configLogger.warn('Skipping invalid entry in allowedPrivateNetworks: ', e);
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.filter(p => p != null);
|
.filter(p => p != null);
|
||||||
|
|
@ -222,6 +226,7 @@ export type Config = {
|
||||||
db: string;
|
db: string;
|
||||||
user: string;
|
user: string;
|
||||||
pass: string;
|
pass: string;
|
||||||
|
slowQueryThreshold?: number;
|
||||||
disableCache?: boolean;
|
disableCache?: boolean;
|
||||||
extra?: { [x: string]: string };
|
extra?: { [x: string]: string };
|
||||||
};
|
};
|
||||||
|
|
@ -375,11 +380,14 @@ export function loadConfig(): Config {
|
||||||
|
|
||||||
if (configFiles.length === 0
|
if (configFiles.length === 0
|
||||||
&& !process.env['MK_WARNED_ABOUT_CONFIG']) {
|
&& !process.env['MK_WARNED_ABOUT_CONFIG']) {
|
||||||
console.log('No config files loaded, check if this is intentional');
|
configLogger.warn('No config files loaded, check if this is intentional');
|
||||||
process.env['MK_WARNED_ABOUT_CONFIG'] = '1';
|
process.env['MK_WARNED_ABOUT_CONFIG'] = '1';
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = configFiles.map(path => fs.readFileSync(path, 'utf-8'))
|
const config = configFiles.map(path => {
|
||||||
|
configLogger.info(`Reading configuration from ${path}`);
|
||||||
|
return fs.readFileSync(path, 'utf-8');
|
||||||
|
})
|
||||||
.map(contents => yaml.load(contents) as Source)
|
.map(contents => yaml.load(contents) as Source)
|
||||||
.reduce(
|
.reduce(
|
||||||
(acc: Source, cur: Source) => Object.assign(acc, cur),
|
(acc: Source, cur: Source) => Object.assign(acc, cur),
|
||||||
|
|
@ -405,6 +413,10 @@ export function loadConfig(): Config {
|
||||||
const internalMediaProxy = `${scheme}://${host}/proxy`;
|
const internalMediaProxy = `${scheme}://${host}/proxy`;
|
||||||
const redis = convertRedisOptions(config.redis, host);
|
const redis = convertRedisOptions(config.redis, host);
|
||||||
|
|
||||||
|
// nullish => 300 (default)
|
||||||
|
// 0 => undefined (disabled)
|
||||||
|
const slowQueryThreshold = (config.db.slowQueryThreshold ?? 300) || undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version,
|
version,
|
||||||
publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl,
|
publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl,
|
||||||
|
|
@ -423,7 +435,7 @@ export function loadConfig(): Config {
|
||||||
apiUrl: `${scheme}://${host}/api`,
|
apiUrl: `${scheme}://${host}/api`,
|
||||||
authUrl: `${scheme}://${host}/auth`,
|
authUrl: `${scheme}://${host}/auth`,
|
||||||
driveUrl: `${scheme}://${host}/files`,
|
driveUrl: `${scheme}://${host}/files`,
|
||||||
db: { ...config.db, db: dbDb, user: dbUser, pass: dbPass },
|
db: { ...config.db, db: dbDb, user: dbUser, pass: dbPass, slowQueryThreshold },
|
||||||
dbReplications: config.dbReplications,
|
dbReplications: config.dbReplications,
|
||||||
dbSlaves: config.dbSlaves,
|
dbSlaves: config.dbSlaves,
|
||||||
fulltextSearch: config.fulltextSearch,
|
fulltextSearch: config.fulltextSearch,
|
||||||
|
|
@ -496,6 +508,10 @@ export function loadConfig(): Config {
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryCreateUrl(url: string) {
|
function tryCreateUrl(url: string) {
|
||||||
|
if (!url) {
|
||||||
|
throw new Error('Failed to load: no "url" property found in config. Please check the value of "MISSKEY_CONFIG_DIR" and "MISSKEY_CONFIG_YML", and verify that all configuration files are correct.');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return new URL(url);
|
return new URL(url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -627,7 +643,7 @@ function applyEnvOverrides(config: Source) {
|
||||||
// these are all the settings that can be overridden
|
// these are all the settings that can be overridden
|
||||||
|
|
||||||
_apply_top([['url', 'port', 'address', 'socket', 'chmodSocket', 'disableHsts', 'id', 'dbReplications', 'websocketCompression']]);
|
_apply_top([['url', 'port', 'address', 'socket', 'chmodSocket', 'disableHsts', 'id', 'dbReplications', 'websocketCompression']]);
|
||||||
_apply_top(['db', ['host', 'port', 'db', 'user', 'pass', 'disableCache']]);
|
_apply_top(['db', ['host', 'port', 'db', 'user', 'pass', 'slowQueryThreshold', 'disableCache']]);
|
||||||
_apply_top(['dbSlaves', Array.from((config.dbSlaves ?? []).keys()), ['host', 'port', 'db', 'user', 'pass']]);
|
_apply_top(['dbSlaves', Array.from((config.dbSlaves ?? []).keys()), ['host', 'port', 'db', 'user', 'pass']]);
|
||||||
_apply_top([
|
_apply_top([
|
||||||
['redis', 'redisForPubsub', 'redisForJobQueue', 'redisForTimelines', 'redisForReactions', 'redisForRateLimit'],
|
['redis', 'redisForPubsub', 'redisForJobQueue', 'redisForTimelines', 'redisForReactions', 'redisForRateLimit'],
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,28 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects all email addresses that a abuse report should be sent to.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async getRecipientEMailAddresses(): Promise<string[]> {
|
||||||
|
const recipientEMailAddresses = await this.fetchEMailRecipients().then(it => it
|
||||||
|
.filter(it => it.isActive && it.userProfile?.emailVerified)
|
||||||
|
.map(it => it.userProfile?.email)
|
||||||
|
.filter(x => x != null),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.meta.email) {
|
||||||
|
recipientEMailAddresses.push(this.meta.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.meta.maintainerEmail) {
|
||||||
|
recipientEMailAddresses.push(this.meta.maintainerEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return recipientEMailAddresses;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mailを用いて{@link abuseReports}の内容を管理者各位に通知する.
|
* Mailを用いて{@link abuseReports}の内容を管理者各位に通知する.
|
||||||
* メールアドレスの送信先は以下の通り.
|
* メールアドレスの送信先は以下の通り.
|
||||||
|
|
@ -96,15 +118,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipientEMailAddresses = await this.fetchEMailRecipients().then(it => it
|
const recipientEMailAddresses = await this.getRecipientEMailAddresses();
|
||||||
.filter(it => it.isActive && it.userProfile?.emailVerified)
|
|
||||||
.map(it => it.userProfile?.email)
|
|
||||||
.filter(x => x != null),
|
|
||||||
);
|
|
||||||
|
|
||||||
recipientEMailAddresses.push(
|
|
||||||
...(this.meta.email ? [this.meta.email] : []),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (recipientEMailAddresses.length <= 0) {
|
if (recipientEMailAddresses.length <= 0) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { QueueService } from '@/core/QueueService.js';
|
||||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||||
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import { IdService } from './IdService.js';
|
import { IdService } from './IdService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -125,11 +126,11 @@ export class AbuseReportService {
|
||||||
const report = await this.abuseUserReportsRepository.findOneByOrFail({ id: reportId });
|
const report = await this.abuseUserReportsRepository.findOneByOrFail({ id: reportId });
|
||||||
|
|
||||||
if (report.targetUserHost == null) {
|
if (report.targetUserHost == null) {
|
||||||
throw new Error('The target user host is null.');
|
throw new IdentifiableError('0b1ce202-b2c1-4ee4-8af4-2742a51b383d', 'The target user host is null.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (report.forwarded) {
|
if (report.forwarded) {
|
||||||
throw new Error('The report has already been forwarded.');
|
throw new IdentifiableError('5c008bdf-f0e8-4154-9f34-804e114516d7', 'The report has already been forwarded.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.abuseUserReportsRepository.update(report.id, {
|
await this.abuseUserReportsRepository.update(report.id, {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
|
||||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
import { AntennaService } from '@/core/AntennaService.js';
|
import { AntennaService } from '@/core/AntennaService.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccountMoveService {
|
export class AccountMoveService {
|
||||||
|
|
@ -68,6 +69,7 @@ export class AccountMoveService {
|
||||||
private systemAccountService: SystemAccountService,
|
private systemAccountService: SystemAccountService,
|
||||||
private roleService: RoleService,
|
private roleService: RoleService,
|
||||||
private antennaService: AntennaService,
|
private antennaService: AntennaService,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,12 +109,10 @@ export class AccountMoveService {
|
||||||
this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj);
|
this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj);
|
||||||
|
|
||||||
// Unfollow after 24 hours
|
// Unfollow after 24 hours
|
||||||
const followings = await this.followingsRepository.findBy({
|
const followings = await this.cacheService.userFollowingsCache.fetch(src.id);
|
||||||
followerId: src.id,
|
this.queueService.createDelayedUnfollowJob(Array.from(followings.keys()).map(followeeId => ({
|
||||||
});
|
|
||||||
this.queueService.createDelayedUnfollowJob(followings.map(following => ({
|
|
||||||
from: { id: src.id },
|
from: { id: src.id },
|
||||||
to: { id: following.followeeId },
|
to: { id: followeeId },
|
||||||
})), process.env.NODE_ENV === 'test' ? 10000 : 1000 * 60 * 60 * 24);
|
})), process.env.NODE_ENV === 'test' ? 10000 : 1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
await this.postMoveProcess(src, dst);
|
await this.postMoveProcess(src, dst);
|
||||||
|
|
@ -138,11 +138,9 @@ export class AccountMoveService {
|
||||||
|
|
||||||
// follow the new account
|
// follow the new account
|
||||||
const proxy = await this.systemAccountService.fetch('proxy');
|
const proxy = await this.systemAccountService.fetch('proxy');
|
||||||
const followings = await this.followingsRepository.findBy({
|
const followings = await this.cacheService.userFollowersCache.fetch(src.id)
|
||||||
followeeId: src.id,
|
.then(fs => Array.from(fs.values())
|
||||||
followerHost: IsNull(), // follower is local
|
.filter(f => f.followerHost == null && f.followerId !== proxy.id));
|
||||||
followerId: Not(proxy.id),
|
|
||||||
});
|
|
||||||
const followJobs = followings.map(following => ({
|
const followJobs = followings.map(following => ({
|
||||||
from: { id: following.followerId },
|
from: { id: following.followerId },
|
||||||
to: { id: dst.id },
|
to: { id: dst.id },
|
||||||
|
|
@ -318,9 +316,9 @@ export class AccountMoveService {
|
||||||
await this.usersRepository.decrement({ id: In(localFollowerIds) }, 'followingCount', 1);
|
await this.usersRepository.decrement({ id: In(localFollowerIds) }, 'followingCount', 1);
|
||||||
|
|
||||||
// Decrease follower counts of local followees by 1.
|
// Decrease follower counts of local followees by 1.
|
||||||
const oldFollowings = await this.followingsRepository.findBy({ followerId: oldAccount.id });
|
const oldFollowings = await this.cacheService.userFollowingsCache.fetch(oldAccount.id);
|
||||||
if (oldFollowings.length > 0) {
|
if (oldFollowings.size > 0) {
|
||||||
await this.usersRepository.decrement({ id: In(oldFollowings.map(following => following.followeeId)) }, 'followersCount', 1);
|
await this.usersRepository.decrement({ id: In(Array.from(oldFollowings.keys())) }, 'followersCount', 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update instance stats by decreasing remote followers count by the number of local followers who were following the old account.
|
// Update instance stats by decreasing remote followers count by the number of local followers who were following the old account.
|
||||||
|
|
|
||||||
|
|
@ -130,7 +130,8 @@ export class AntennaService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (note.visibility === 'followers') {
|
if (note.visibility === 'followers') {
|
||||||
const isFollowing = Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(antenna.userId), note.userId);
|
const followings = await this.cacheService.userFollowingsCache.fetch(antenna.userId);
|
||||||
|
const isFollowing = followings.has(note.userId);
|
||||||
if (!isFollowing && antenna.userId !== note.userId) return false;
|
if (!isFollowing && antenna.userId !== note.userId) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,9 +80,9 @@ export class BunnyService {
|
||||||
});
|
});
|
||||||
|
|
||||||
req.on('error', (error) => {
|
req.on('error', (error) => {
|
||||||
this.bunnyCdnLogger.error(error);
|
this.bunnyCdnLogger.error('Unhandled error', error);
|
||||||
data.destroy();
|
data.destroy();
|
||||||
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140bf91', 'An error has occured during the connectiong to BunnyCDN');
|
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140bf91', 'An error has occurred while connecting to BunnyCDN', true, error);
|
||||||
});
|
});
|
||||||
|
|
||||||
data.pipe(req).on('finish', () => {
|
data.pipe(req).on('finish', () => {
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,16 @@
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
import { IsNull } from 'typeorm';
|
import { In, IsNull } from 'typeorm';
|
||||||
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing, MiNote } from '@/models/_.js';
|
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiNote, MiFollowing } from '@/models/_.js';
|
||||||
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
||||||
|
import { QuantumKVCache } from '@/misc/QuantumKVCache.js';
|
||||||
import type { MiLocalUser, MiUser } from '@/models/User.js';
|
import type { MiLocalUser, MiUser } from '@/models/User.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
import type { InternalEventTypes } from '@/core/GlobalEventService.js';
|
||||||
|
import { InternalEventService } from '@/core/InternalEventService.js';
|
||||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||||
|
|
||||||
export interface FollowStats {
|
export interface FollowStats {
|
||||||
|
|
@ -27,7 +29,7 @@ export interface CachedTranslation {
|
||||||
text: string | undefined;
|
text: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CachedTranslationEntity {
|
export interface CachedTranslationEntity {
|
||||||
l?: string;
|
l?: string;
|
||||||
t?: string;
|
t?: string;
|
||||||
u?: number;
|
u?: number;
|
||||||
|
|
@ -39,14 +41,16 @@ export class CacheService implements OnApplicationShutdown {
|
||||||
public localUserByNativeTokenCache: MemoryKVCache<MiLocalUser | null>;
|
public localUserByNativeTokenCache: MemoryKVCache<MiLocalUser | null>;
|
||||||
public localUserByIdCache: MemoryKVCache<MiLocalUser>;
|
public localUserByIdCache: MemoryKVCache<MiLocalUser>;
|
||||||
public uriPersonCache: MemoryKVCache<MiUser | null>;
|
public uriPersonCache: MemoryKVCache<MiUser | null>;
|
||||||
public userProfileCache: RedisKVCache<MiUserProfile>;
|
public userProfileCache: QuantumKVCache<MiUserProfile>;
|
||||||
public userMutingsCache: RedisKVCache<Set<string>>;
|
public userMutingsCache: QuantumKVCache<Set<string>>;
|
||||||
public userBlockingCache: RedisKVCache<Set<string>>;
|
public userBlockingCache: QuantumKVCache<Set<string>>;
|
||||||
public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
|
public userBlockedCache: QuantumKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
|
||||||
public renoteMutingsCache: RedisKVCache<Set<string>>;
|
public renoteMutingsCache: QuantumKVCache<Set<string>>;
|
||||||
public userFollowingsCache: RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>;
|
public userFollowingsCache: QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>;
|
||||||
private readonly userFollowStatsCache = new MemoryKVCache<FollowStats>(1000 * 60 * 10); // 10 minutes
|
public userFollowersCache: QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>;
|
||||||
private readonly translationsCache: RedisKVCache<CachedTranslationEntity>;
|
public hibernatedUserCache: QuantumKVCache<boolean>;
|
||||||
|
protected userFollowStatsCache = new MemoryKVCache<FollowStats>(1000 * 60 * 10); // 10 minutes
|
||||||
|
protected translationsCache: RedisKVCache<CachedTranslationEntity>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.redis)
|
@Inject(DI.redis)
|
||||||
|
|
@ -74,6 +78,7 @@ export class CacheService implements OnApplicationShutdown {
|
||||||
private followingsRepository: FollowingsRepository,
|
private followingsRepository: FollowingsRepository,
|
||||||
|
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
|
private readonly internalEventService: InternalEventService,
|
||||||
) {
|
) {
|
||||||
//this.onMessage = this.onMessage.bind(this);
|
//this.onMessage = this.onMessage.bind(this);
|
||||||
|
|
||||||
|
|
@ -82,58 +87,148 @@ export class CacheService implements OnApplicationShutdown {
|
||||||
this.localUserByIdCache = new MemoryKVCache<MiLocalUser>(1000 * 60 * 5); // 5m
|
this.localUserByIdCache = new MemoryKVCache<MiLocalUser>(1000 * 60 * 5); // 5m
|
||||||
this.uriPersonCache = new MemoryKVCache<MiUser | null>(1000 * 60 * 5); // 5m
|
this.uriPersonCache = new MemoryKVCache<MiUser | null>(1000 * 60 * 5); // 5m
|
||||||
|
|
||||||
this.userProfileCache = new RedisKVCache<MiUserProfile>(this.redisClient, 'userProfile', {
|
this.userProfileCache = new QuantumKVCache(this.internalEventService, 'userProfile', {
|
||||||
lifetime: 1000 * 60 * 30, // 30m
|
lifetime: 1000 * 60 * 30, // 30m
|
||||||
memoryCacheLifetime: 1000 * 60, // 1m
|
|
||||||
fetcher: (key) => this.userProfilesRepository.findOneByOrFail({ userId: key }),
|
fetcher: (key) => this.userProfilesRepository.findOneByOrFail({ userId: key }),
|
||||||
toRedisConverter: (value) => JSON.stringify(value),
|
bulkFetcher: userIds => this.userProfilesRepository.findBy({ userId: In(userIds) }).then(ps => ps.map(p => [p.userId, p])),
|
||||||
fromRedisConverter: (value) => JSON.parse(value), // TODO: date型の考慮
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.userMutingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userMutings', {
|
this.userMutingsCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'userMutings', {
|
||||||
lifetime: 1000 * 60 * 30, // 30m
|
lifetime: 1000 * 60 * 30, // 30m
|
||||||
memoryCacheLifetime: 1000 * 60, // 1m
|
|
||||||
fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))),
|
fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))),
|
||||||
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
|
bulkFetcher: muterIds => this.mutingsRepository
|
||||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
.createQueryBuilder('muting')
|
||||||
|
.select('"muting"."muterId"', 'muterId')
|
||||||
|
.addSelect('array_agg("muting"."muteeId")', 'muteeIds')
|
||||||
|
.where({ muterId: In(muterIds) })
|
||||||
|
.groupBy('muting.muterId')
|
||||||
|
.getRawMany<{ muterId: string, muteeIds: string[] }>()
|
||||||
|
.then(ms => ms.map(m => [m.muterId, new Set(m.muteeIds)])),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.userBlockingCache = new RedisKVCache<Set<string>>(this.redisClient, 'userBlocking', {
|
this.userBlockingCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'userBlocking', {
|
||||||
lifetime: 1000 * 60 * 30, // 30m
|
lifetime: 1000 * 60 * 30, // 30m
|
||||||
memoryCacheLifetime: 1000 * 60, // 1m
|
|
||||||
fetcher: (key) => this.blockingsRepository.find({ where: { blockerId: key }, select: ['blockeeId'] }).then(xs => new Set(xs.map(x => x.blockeeId))),
|
fetcher: (key) => this.blockingsRepository.find({ where: { blockerId: key }, select: ['blockeeId'] }).then(xs => new Set(xs.map(x => x.blockeeId))),
|
||||||
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
|
bulkFetcher: blockerIds => this.blockingsRepository
|
||||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
.createQueryBuilder('blocking')
|
||||||
|
.select('"blocking"."blockerId"', 'blockerId')
|
||||||
|
.addSelect('array_agg("blocking"."blockeeId")', 'blockeeIds')
|
||||||
|
.where({ blockerId: In(blockerIds) })
|
||||||
|
.groupBy('blocking.blockerId')
|
||||||
|
.getRawMany<{ blockerId: string, blockeeIds: string[] }>()
|
||||||
|
.then(ms => ms.map(m => [m.blockerId, new Set(m.blockeeIds)])),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.userBlockedCache = new RedisKVCache<Set<string>>(this.redisClient, 'userBlocked', {
|
this.userBlockedCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'userBlocked', {
|
||||||
lifetime: 1000 * 60 * 30, // 30m
|
lifetime: 1000 * 60 * 30, // 30m
|
||||||
memoryCacheLifetime: 1000 * 60, // 1m
|
|
||||||
fetcher: (key) => this.blockingsRepository.find({ where: { blockeeId: key }, select: ['blockerId'] }).then(xs => new Set(xs.map(x => x.blockerId))),
|
fetcher: (key) => this.blockingsRepository.find({ where: { blockeeId: key }, select: ['blockerId'] }).then(xs => new Set(xs.map(x => x.blockerId))),
|
||||||
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
|
bulkFetcher: blockeeIds => this.blockingsRepository
|
||||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
.createQueryBuilder('blocking')
|
||||||
|
.select('"blocking"."blockeeId"', 'blockeeId')
|
||||||
|
.addSelect('array_agg("blocking"."blockeeId")', 'blockeeIds')
|
||||||
|
.where({ blockeeId: In(blockeeIds) })
|
||||||
|
.groupBy('blocking.blockeeId')
|
||||||
|
.getRawMany<{ blockeeId: string, blockerIds: string[] }>()
|
||||||
|
.then(ms => ms.map(m => [m.blockeeId, new Set(m.blockerIds)])),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.renoteMutingsCache = new RedisKVCache<Set<string>>(this.redisClient, 'renoteMutings', {
|
this.renoteMutingsCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'renoteMutings', {
|
||||||
lifetime: 1000 * 60 * 30, // 30m
|
lifetime: 1000 * 60 * 30, // 30m
|
||||||
memoryCacheLifetime: 1000 * 60, // 1m
|
|
||||||
fetcher: (key) => this.renoteMutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))),
|
fetcher: (key) => this.renoteMutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))),
|
||||||
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
|
bulkFetcher: muterIds => this.renoteMutingsRepository
|
||||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
.createQueryBuilder('muting')
|
||||||
|
.select('"muting"."muterId"', 'muterId')
|
||||||
|
.addSelect('array_agg("muting"."muteeId")', 'muteeIds')
|
||||||
|
.where({ muterId: In(muterIds) })
|
||||||
|
.groupBy('muting.muterId')
|
||||||
|
.getRawMany<{ muterId: string, muteeIds: string[] }>()
|
||||||
|
.then(ms => ms.map(m => [m.muterId, new Set(m.muteeIds)])),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.userFollowingsCache = new RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>(this.redisClient, 'userFollowings', {
|
this.userFollowingsCache = new QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>(this.internalEventService, 'userFollowings', {
|
||||||
lifetime: 1000 * 60 * 30, // 30m
|
lifetime: 1000 * 60 * 30, // 30m
|
||||||
memoryCacheLifetime: 1000 * 60, // 1m
|
fetcher: (key) => this.followingsRepository.findBy({ followerId: key }).then(xs => new Map(xs.map(f => [f.followeeId, f]))),
|
||||||
fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId', 'withReplies'] }).then(xs => {
|
bulkFetcher: followerIds => this.followingsRepository
|
||||||
const obj: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
|
.findBy({ followerId: In(followerIds) })
|
||||||
for (const x of xs) {
|
.then(fs => fs
|
||||||
obj[x.followeeId] = { withReplies: x.withReplies };
|
.reduce((groups, f) => {
|
||||||
|
let group = groups.get(f.followerId);
|
||||||
|
if (!group) {
|
||||||
|
group = new Map();
|
||||||
|
groups.set(f.followerId, group);
|
||||||
|
}
|
||||||
|
group.set(f.followeeId, f);
|
||||||
|
return groups;
|
||||||
|
}, new Map<string, Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>)),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.userFollowersCache = new QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>(this.internalEventService, 'userFollowers', {
|
||||||
|
lifetime: 1000 * 60 * 30, // 30m
|
||||||
|
fetcher: followeeId => this.followingsRepository.findBy({ followeeId: followeeId }).then(xs => new Map(xs.map(x => [x.followerId, x]))),
|
||||||
|
bulkFetcher: followeeIds => this.followingsRepository
|
||||||
|
.findBy({ followeeId: In(followeeIds) })
|
||||||
|
.then(fs => fs
|
||||||
|
.reduce((groups, f) => {
|
||||||
|
let group = groups.get(f.followeeId);
|
||||||
|
if (!group) {
|
||||||
|
group = new Map();
|
||||||
|
groups.set(f.followeeId, group);
|
||||||
|
}
|
||||||
|
group.set(f.followerId, f);
|
||||||
|
return groups;
|
||||||
|
}, new Map<string, Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>)),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.hibernatedUserCache = new QuantumKVCache<boolean>(this.internalEventService, 'hibernatedUsers', {
|
||||||
|
lifetime: 1000 * 60 * 30, // 30m
|
||||||
|
fetcher: async userId => {
|
||||||
|
const { isHibernated } = await this.usersRepository.findOneOrFail({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { isHibernated: true },
|
||||||
|
});
|
||||||
|
return isHibernated;
|
||||||
|
},
|
||||||
|
bulkFetcher: async userIds => {
|
||||||
|
const results = await this.usersRepository.find({
|
||||||
|
where: { id: In(userIds) },
|
||||||
|
select: { id: true, isHibernated: true },
|
||||||
|
});
|
||||||
|
return results.map(({ id, isHibernated }) => [id, isHibernated]);
|
||||||
|
},
|
||||||
|
onChanged: async userIds => {
|
||||||
|
// We only update local copies since each process will get this event, but we can have user objects in multiple different caches.
|
||||||
|
// Before doing anything else we must "find" all the objects to update.
|
||||||
|
const userObjects = new Map<string, MiUser[]>();
|
||||||
|
const toUpdate: string[] = [];
|
||||||
|
for (const uid of userIds) {
|
||||||
|
const toAdd: MiUser[] = [];
|
||||||
|
|
||||||
|
const localUserById = this.localUserByIdCache.get(uid);
|
||||||
|
if (localUserById) toAdd.push(localUserById);
|
||||||
|
|
||||||
|
const userById = this.userByIdCache.get(uid);
|
||||||
|
if (userById) toAdd.push(userById);
|
||||||
|
|
||||||
|
if (toAdd.length > 0) {
|
||||||
|
toUpdate.push(uid);
|
||||||
|
userObjects.set(uid, toAdd);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return obj;
|
|
||||||
}),
|
// In many cases, we won't have to do anything.
|
||||||
toRedisConverter: (value) => JSON.stringify(value),
|
// Skipping the DB fetch ensures that this remains a single-step synchronous process.
|
||||||
fromRedisConverter: (value) => JSON.parse(value),
|
if (toUpdate.length > 0) {
|
||||||
|
const hibernations = await this.usersRepository.find({ where: { id: In(toUpdate) }, select: { id: true, isHibernated: true } });
|
||||||
|
for (const { id, isHibernated } of hibernations) {
|
||||||
|
const users = userObjects.get(id);
|
||||||
|
if (users) {
|
||||||
|
for (const u of users) {
|
||||||
|
u.isHibernated = isHibernated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.translationsCache = new RedisKVCache<CachedTranslationEntity>(this.redisClient, 'translations', {
|
this.translationsCache = new RedisKVCache<CachedTranslationEntity>(this.redisClient, 'translations', {
|
||||||
|
|
@ -143,20 +238,21 @@ export class CacheService implements OnApplicationShutdown {
|
||||||
|
|
||||||
// NOTE: チャンネルのフォロー状況キャッシュはChannelFollowingServiceで行っている
|
// NOTE: チャンネルのフォロー状況キャッシュはChannelFollowingServiceで行っている
|
||||||
|
|
||||||
this.redisForSub.on('message', this.onMessage);
|
this.internalEventService.on('userChangeSuspendedState', this.onUserEvent);
|
||||||
|
this.internalEventService.on('userChangeDeletedState', this.onUserEvent);
|
||||||
|
this.internalEventService.on('remoteUserUpdated', this.onUserEvent);
|
||||||
|
this.internalEventService.on('localUserUpdated', this.onUserEvent);
|
||||||
|
this.internalEventService.on('userChangeSuspendedState', this.onUserEvent);
|
||||||
|
this.internalEventService.on('userTokenRegenerated', this.onTokenEvent);
|
||||||
|
this.internalEventService.on('follow', this.onFollowEvent);
|
||||||
|
this.internalEventService.on('unfollow', this.onFollowEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async onMessage(_: string, data: string): Promise<void> {
|
private async onUserEvent<E extends 'userChangeSuspendedState' | 'userChangeDeletedState' | 'remoteUserUpdated' | 'localUserUpdated'>(body: InternalEventTypes[E], _: E, isLocal: boolean): Promise<void> {
|
||||||
const obj = JSON.parse(data);
|
{
|
||||||
|
{
|
||||||
if (obj.channel === 'internal') {
|
{
|
||||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
|
||||||
switch (type) {
|
|
||||||
case 'userChangeSuspendedState':
|
|
||||||
case 'userChangeDeletedState':
|
|
||||||
case 'remoteUserUpdated':
|
|
||||||
case 'localUserUpdated': {
|
|
||||||
const user = await this.usersRepository.findOneBy({ id: body.id });
|
const user = await this.usersRepository.findOneBy({ id: body.id });
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
this.userByIdCache.delete(body.id);
|
this.userByIdCache.delete(body.id);
|
||||||
|
|
@ -166,6 +262,18 @@ export class CacheService implements OnApplicationShutdown {
|
||||||
this.uriPersonCache.delete(k);
|
this.uriPersonCache.delete(k);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isLocal) {
|
||||||
|
await Promise.all([
|
||||||
|
this.userProfileCache.delete(body.id),
|
||||||
|
this.userMutingsCache.delete(body.id),
|
||||||
|
this.userBlockingCache.delete(body.id),
|
||||||
|
this.userBlockedCache.delete(body.id),
|
||||||
|
this.renoteMutingsCache.delete(body.id),
|
||||||
|
this.userFollowingsCache.delete(body.id),
|
||||||
|
this.userFollowersCache.delete(body.id),
|
||||||
|
this.hibernatedUserCache.delete(body.id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.userByIdCache.set(user.id, user);
|
this.userByIdCache.set(user.id, user);
|
||||||
for (const [k, v] of this.uriPersonCache.entries) {
|
for (const [k, v] of this.uriPersonCache.entries) {
|
||||||
|
|
@ -178,20 +286,37 @@ export class CacheService implements OnApplicationShutdown {
|
||||||
this.localUserByIdCache.set(user.id, user);
|
this.localUserByIdCache.set(user.id, user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
case 'userTokenRegenerated': {
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async onTokenEvent<E extends 'userTokenRegenerated'>(body: InternalEventTypes[E]): Promise<void> {
|
||||||
|
{
|
||||||
|
{
|
||||||
|
{
|
||||||
const user = await this.usersRepository.findOneByOrFail({ id: body.id }) as MiLocalUser;
|
const user = await this.usersRepository.findOneByOrFail({ id: body.id }) as MiLocalUser;
|
||||||
this.localUserByNativeTokenCache.delete(body.oldToken);
|
this.localUserByNativeTokenCache.delete(body.oldToken);
|
||||||
this.localUserByNativeTokenCache.set(body.newToken, user);
|
this.localUserByNativeTokenCache.set(body.newToken, user);
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async onFollowEvent<E extends 'follow' | 'unfollow'>(body: InternalEventTypes[E], type: E): Promise<void> {
|
||||||
|
{
|
||||||
|
switch (type) {
|
||||||
case 'follow': {
|
case 'follow': {
|
||||||
const follower = this.userByIdCache.get(body.followerId);
|
const follower = this.userByIdCache.get(body.followerId);
|
||||||
if (follower) follower.followingCount++;
|
if (follower) follower.followingCount++;
|
||||||
const followee = this.userByIdCache.get(body.followeeId);
|
const followee = this.userByIdCache.get(body.followeeId);
|
||||||
if (followee) followee.followersCount++;
|
if (followee) followee.followersCount++;
|
||||||
this.userFollowingsCache.delete(body.followerId);
|
await Promise.all([
|
||||||
|
this.userFollowingsCache.delete(body.followerId),
|
||||||
|
this.userFollowersCache.delete(body.followeeId),
|
||||||
|
]);
|
||||||
this.userFollowStatsCache.delete(body.followerId);
|
this.userFollowStatsCache.delete(body.followerId);
|
||||||
this.userFollowStatsCache.delete(body.followeeId);
|
this.userFollowStatsCache.delete(body.followeeId);
|
||||||
break;
|
break;
|
||||||
|
|
@ -201,13 +326,14 @@ export class CacheService implements OnApplicationShutdown {
|
||||||
if (follower) follower.followingCount--;
|
if (follower) follower.followingCount--;
|
||||||
const followee = this.userByIdCache.get(body.followeeId);
|
const followee = this.userByIdCache.get(body.followeeId);
|
||||||
if (followee) followee.followersCount--;
|
if (followee) followee.followersCount--;
|
||||||
this.userFollowingsCache.delete(body.followerId);
|
await Promise.all([
|
||||||
|
this.userFollowingsCache.delete(body.followerId),
|
||||||
|
this.userFollowersCache.delete(body.followeeId),
|
||||||
|
]);
|
||||||
this.userFollowStatsCache.delete(body.followerId);
|
this.userFollowStatsCache.delete(body.followerId);
|
||||||
this.userFollowStatsCache.delete(body.followeeId);
|
this.userFollowStatsCache.delete(body.followeeId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -298,9 +424,115 @@ export class CacheService implements OnApplicationShutdown {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async getUsers(userIds: Iterable<string>): Promise<Map<string, MiUser>> {
|
||||||
|
const users = new Map<string, MiUser>;
|
||||||
|
|
||||||
|
const toFetch: string[] = [];
|
||||||
|
for (const userId of userIds) {
|
||||||
|
const fromCache = this.userByIdCache.get(userId);
|
||||||
|
if (fromCache) {
|
||||||
|
users.set(userId, fromCache);
|
||||||
|
} else {
|
||||||
|
toFetch.push(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toFetch.length > 0) {
|
||||||
|
const fetched = await this.usersRepository.findBy({
|
||||||
|
id: In(toFetch),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const user of fetched) {
|
||||||
|
users.set(user.id, user);
|
||||||
|
this.userByIdCache.set(user.id, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async isFollowing(follower: string | { id: string }, followee: string | { id: string }): Promise<boolean> {
|
||||||
|
const followerId = typeof(follower) === 'string' ? follower : follower.id;
|
||||||
|
const followeeId = typeof(followee) === 'string' ? followee : followee.id;
|
||||||
|
|
||||||
|
// This lets us use whichever one is in memory, falling back to DB fetch via userFollowingsCache.
|
||||||
|
return this.userFollowersCache.get(followeeId)?.has(followerId)
|
||||||
|
?? (await this.userFollowingsCache.fetch(followerId)).has(followeeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all hibernated followers.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async getHibernatedFollowers(followeeId: string): Promise<MiFollowing[]> {
|
||||||
|
const followers = await this.getFollowersWithHibernation(followeeId);
|
||||||
|
return followers.filter(f => f.isFollowerHibernated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all non-hibernated followers.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async getNonHibernatedFollowers(followeeId: string): Promise<MiFollowing[]> {
|
||||||
|
const followers = await this.getFollowersWithHibernation(followeeId);
|
||||||
|
return followers.filter(f => !f.isFollowerHibernated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns follower relations with populated isFollowerHibernated.
|
||||||
|
* If you don't need this field, then please use userFollowersCache directly for reduced overhead.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async getFollowersWithHibernation(followeeId: string): Promise<MiFollowing[]> {
|
||||||
|
const followers = await this.userFollowersCache.fetch(followeeId);
|
||||||
|
const hibernations = await this.hibernatedUserCache.fetchMany(followers.keys()).then(fs => fs.reduce((map, f) => {
|
||||||
|
map.set(f[0], f[1]);
|
||||||
|
return map;
|
||||||
|
}, new Map<string, boolean>));
|
||||||
|
return Array.from(followers.values()).map(following => ({
|
||||||
|
...following,
|
||||||
|
isFollowerHibernated: hibernations.get(following.followerId) ?? false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes follower and following relations for the given user.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async refreshFollowRelationsFor(userId: string): Promise<void> {
|
||||||
|
const followings = await this.userFollowingsCache.refresh(userId);
|
||||||
|
const followees = Array.from(followings.values()).map(f => f.followeeId);
|
||||||
|
await this.userFollowersCache.deleteMany(followees);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public clear(): void {
|
||||||
|
this.userByIdCache.clear();
|
||||||
|
this.localUserByNativeTokenCache.clear();
|
||||||
|
this.localUserByIdCache.clear();
|
||||||
|
this.uriPersonCache.clear();
|
||||||
|
this.userProfileCache.clear();
|
||||||
|
this.userMutingsCache.clear();
|
||||||
|
this.userBlockingCache.clear();
|
||||||
|
this.userBlockedCache.clear();
|
||||||
|
this.renoteMutingsCache.clear();
|
||||||
|
this.userFollowingsCache.clear();
|
||||||
|
this.userFollowStatsCache.clear();
|
||||||
|
this.translationsCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
this.redisForSub.off('message', this.onMessage);
|
this.internalEventService.off('userChangeSuspendedState', this.onUserEvent);
|
||||||
|
this.internalEventService.off('userChangeDeletedState', this.onUserEvent);
|
||||||
|
this.internalEventService.off('remoteUserUpdated', this.onUserEvent);
|
||||||
|
this.internalEventService.off('localUserUpdated', this.onUserEvent);
|
||||||
|
this.internalEventService.off('userChangeSuspendedState', this.onUserEvent);
|
||||||
|
this.internalEventService.off('userTokenRegenerated', this.onTokenEvent);
|
||||||
|
this.internalEventService.off('follow', this.onFollowEvent);
|
||||||
|
this.internalEventService.off('unfollow', this.onFollowEvent);
|
||||||
this.userByIdCache.dispose();
|
this.userByIdCache.dispose();
|
||||||
this.localUserByNativeTokenCache.dispose();
|
this.localUserByNativeTokenCache.dispose();
|
||||||
this.localUserByIdCache.dispose();
|
this.localUserByIdCache.dispose();
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ export class CaptchaError extends Error {
|
||||||
public readonly cause?: unknown;
|
public readonly cause?: unknown;
|
||||||
|
|
||||||
constructor(code: CaptchaErrorCode, message: string, cause?: unknown) {
|
constructor(code: CaptchaErrorCode, message: string, cause?: unknown) {
|
||||||
super(message);
|
super(message, cause ? { cause } : undefined);
|
||||||
this.code = code;
|
this.code = code;
|
||||||
this.cause = cause;
|
this.cause = cause;
|
||||||
this.name = 'CaptchaError';
|
this.name = 'CaptchaError';
|
||||||
|
|
@ -117,7 +117,7 @@ export class CaptchaService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(err => {
|
const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(err => {
|
||||||
throw new CaptchaError(captchaErrorCodes.requestFailed, `recaptcha-request-failed: ${err}`);
|
throw new CaptchaError(captchaErrorCodes.requestFailed, `recaptcha-request-failed: ${err}`, err);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success !== true) {
|
if (result.success !== true) {
|
||||||
|
|
@ -133,7 +133,7 @@ export class CaptchaService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(err => {
|
const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(err => {
|
||||||
throw new CaptchaError(captchaErrorCodes.requestFailed, `hcaptcha-request-failed: ${err}`);
|
throw new CaptchaError(captchaErrorCodes.requestFailed, `hcaptcha-request-failed: ${err}`, err);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success !== true) {
|
if (result.success !== true) {
|
||||||
|
|
@ -209,7 +209,7 @@ export class CaptchaService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => {
|
const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => {
|
||||||
throw new CaptchaError(captchaErrorCodes.requestFailed, `turnstile-request-failed: ${err}`);
|
throw new CaptchaError(captchaErrorCodes.requestFailed, `turnstile-request-failed: ${err}`, err);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success !== true) {
|
if (result.success !== true) {
|
||||||
|
|
@ -386,7 +386,7 @@ export class CaptchaService {
|
||||||
this.logger.info(err);
|
this.logger.info(err);
|
||||||
const error = err instanceof CaptchaError
|
const error = err instanceof CaptchaError
|
||||||
? err
|
? err
|
||||||
: new CaptchaError(captchaErrorCodes.unknown, `unknown error: ${err}`);
|
: new CaptchaError(captchaErrorCodes.unknown, `unknown error: ${err}`, err);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error,
|
error,
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,15 @@ import { DI } from '@/di-symbols.js';
|
||||||
import type { ChannelFollowingsRepository } from '@/models/_.js';
|
import type { ChannelFollowingsRepository } from '@/models/_.js';
|
||||||
import { MiChannel } from '@/models/_.js';
|
import { MiChannel } from '@/models/_.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEvents, GlobalEventService, InternalEventTypes } from '@/core/GlobalEventService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import type { MiLocalUser } from '@/models/User.js';
|
import type { MiLocalUser } from '@/models/User.js';
|
||||||
import { RedisKVCache } from '@/misc/cache.js';
|
import { QuantumKVCache } from '@/misc/QuantumKVCache.js';
|
||||||
|
import { InternalEventService } from './InternalEventService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ChannelFollowingService implements OnModuleInit {
|
export class ChannelFollowingService implements OnModuleInit {
|
||||||
public userFollowingChannelsCache: RedisKVCache<Set<string>>;
|
public userFollowingChannelsCache: QuantumKVCache<Set<string>>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.redis)
|
@Inject(DI.redis)
|
||||||
|
|
@ -27,19 +28,18 @@ export class ChannelFollowingService implements OnModuleInit {
|
||||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
|
private readonly internalEventService: InternalEventService,
|
||||||
) {
|
) {
|
||||||
this.userFollowingChannelsCache = new RedisKVCache<Set<string>>(this.redisClient, 'userFollowingChannels', {
|
this.userFollowingChannelsCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'userFollowingChannels', {
|
||||||
lifetime: 1000 * 60 * 30, // 30m
|
lifetime: 1000 * 60 * 30, // 30m
|
||||||
memoryCacheLifetime: 1000 * 60, // 1m
|
|
||||||
fetcher: (key) => this.channelFollowingsRepository.find({
|
fetcher: (key) => this.channelFollowingsRepository.find({
|
||||||
where: { followerId: key },
|
where: { followerId: key },
|
||||||
select: ['followeeId'],
|
select: ['followeeId'],
|
||||||
}).then(xs => new Set(xs.map(x => x.followeeId))),
|
}).then(xs => new Set(xs.map(x => x.followeeId))),
|
||||||
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
|
|
||||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.redisForSub.on('message', this.onMessage);
|
this.internalEventService.on('followChannel', this.onMessage);
|
||||||
|
this.internalEventService.on('unfollowChannel', this.onMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
|
|
@ -79,18 +79,15 @@ export class ChannelFollowingService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async onMessage(_: string, data: string): Promise<void> {
|
private async onMessage<E extends 'followChannel' | 'unfollowChannel'>(body: InternalEventTypes[E], type: E): Promise<void> {
|
||||||
const obj = JSON.parse(data);
|
{
|
||||||
|
|
||||||
if (obj.channel === 'internal') {
|
|
||||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'followChannel': {
|
case 'followChannel': {
|
||||||
this.userFollowingChannelsCache.refresh(body.userId);
|
await this.userFollowingChannelsCache.delete(body.userId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'unfollowChannel': {
|
case 'unfollowChannel': {
|
||||||
this.userFollowingChannelsCache.delete(body.userId);
|
await this.userFollowingChannelsCache.delete(body.userId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -99,6 +96,8 @@ export class ChannelFollowingService implements OnModuleInit {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
|
this.internalEventService.off('followChannel', this.onMessage);
|
||||||
|
this.internalEventService.off('unfollowChannel', this.onMessage);
|
||||||
this.userFollowingChannelsCache.dispose();
|
this.userFollowingChannelsCache.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import { HttpRequestService } from './HttpRequestService.js';
|
||||||
import { IdService } from './IdService.js';
|
import { IdService } from './IdService.js';
|
||||||
import { ImageProcessingService } from './ImageProcessingService.js';
|
import { ImageProcessingService } from './ImageProcessingService.js';
|
||||||
import { SystemAccountService } from './SystemAccountService.js';
|
import { SystemAccountService } from './SystemAccountService.js';
|
||||||
|
import { InternalEventService } from './InternalEventService.js';
|
||||||
import { InternalStorageService } from './InternalStorageService.js';
|
import { InternalStorageService } from './InternalStorageService.js';
|
||||||
import { MetaService } from './MetaService.js';
|
import { MetaService } from './MetaService.js';
|
||||||
import { MfmService } from './MfmService.js';
|
import { MfmService } from './MfmService.js';
|
||||||
|
|
@ -186,6 +187,7 @@ const $HashtagService: Provider = { provide: 'HashtagService', useExisting: Hash
|
||||||
const $HttpRequestService: Provider = { provide: 'HttpRequestService', useExisting: HttpRequestService };
|
const $HttpRequestService: Provider = { provide: 'HttpRequestService', useExisting: HttpRequestService };
|
||||||
const $IdService: Provider = { provide: 'IdService', useExisting: IdService };
|
const $IdService: Provider = { provide: 'IdService', useExisting: IdService };
|
||||||
const $ImageProcessingService: Provider = { provide: 'ImageProcessingService', useExisting: ImageProcessingService };
|
const $ImageProcessingService: Provider = { provide: 'ImageProcessingService', useExisting: ImageProcessingService };
|
||||||
|
const $InternalEventService: Provider = { provide: 'InternalEventService', useExisting: InternalEventService };
|
||||||
const $InternalStorageService: Provider = { provide: 'InternalStorageService', useExisting: InternalStorageService };
|
const $InternalStorageService: Provider = { provide: 'InternalStorageService', useExisting: InternalStorageService };
|
||||||
const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaService };
|
const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaService };
|
||||||
const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService };
|
const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService };
|
||||||
|
|
@ -345,6 +347,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
||||||
HttpRequestService,
|
HttpRequestService,
|
||||||
IdService,
|
IdService,
|
||||||
ImageProcessingService,
|
ImageProcessingService,
|
||||||
|
InternalEventService,
|
||||||
InternalStorageService,
|
InternalStorageService,
|
||||||
MetaService,
|
MetaService,
|
||||||
MfmService,
|
MfmService,
|
||||||
|
|
@ -500,6 +503,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
||||||
$HttpRequestService,
|
$HttpRequestService,
|
||||||
$IdService,
|
$IdService,
|
||||||
$ImageProcessingService,
|
$ImageProcessingService,
|
||||||
|
$InternalEventService,
|
||||||
$InternalStorageService,
|
$InternalStorageService,
|
||||||
$MetaService,
|
$MetaService,
|
||||||
$MfmService,
|
$MfmService,
|
||||||
|
|
@ -656,6 +660,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
||||||
HttpRequestService,
|
HttpRequestService,
|
||||||
IdService,
|
IdService,
|
||||||
ImageProcessingService,
|
ImageProcessingService,
|
||||||
|
InternalEventService,
|
||||||
InternalStorageService,
|
InternalStorageService,
|
||||||
MetaService,
|
MetaService,
|
||||||
MfmService,
|
MfmService,
|
||||||
|
|
@ -810,6 +815,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
||||||
$HttpRequestService,
|
$HttpRequestService,
|
||||||
$IdService,
|
$IdService,
|
||||||
$ImageProcessingService,
|
$ImageProcessingService,
|
||||||
|
$InternalEventService,
|
||||||
$InternalStorageService,
|
$InternalStorageService,
|
||||||
$MetaService,
|
$MetaService,
|
||||||
$MfmService,
|
$MfmService,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { LoggerService } from '@/core/LoggerService.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
|
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DownloadService {
|
export class DownloadService {
|
||||||
|
|
@ -37,7 +38,7 @@ export class DownloadService {
|
||||||
public async downloadUrl(url: string, path: string, options: { timeout?: number, operationTimeout?: number, maxSize?: number } = {} ): Promise<{
|
public async downloadUrl(url: string, path: string, options: { timeout?: number, operationTimeout?: number, maxSize?: number } = {} ): Promise<{
|
||||||
filename: string;
|
filename: string;
|
||||||
}> {
|
}> {
|
||||||
this.logger.info(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`);
|
this.logger.debug(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`);
|
||||||
|
|
||||||
const timeout = options.timeout ?? 30 * 1000;
|
const timeout = options.timeout ?? 30 * 1000;
|
||||||
const operationTimeout = options.operationTimeout ?? 60 * 1000;
|
const operationTimeout = options.operationTimeout ?? 60 * 1000;
|
||||||
|
|
@ -86,7 +87,7 @@ export class DownloadService {
|
||||||
filename = parsed.parameters.filename;
|
filename = parsed.parameters.filename;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.warn(`Failed to parse content-disposition: ${contentDisposition}`, { stack: e });
|
this.logger.warn(`Failed to parse content-disposition ${contentDisposition}: ${renderInlineError(e)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).on('downloadProgress', (progress: Got.Progress) => {
|
}).on('downloadProgress', (progress: Got.Progress) => {
|
||||||
|
|
@ -100,13 +101,17 @@ export class DownloadService {
|
||||||
await stream.pipeline(req, fs.createWriteStream(path));
|
await stream.pipeline(req, fs.createWriteStream(path));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Got.HTTPError) {
|
if (e instanceof Got.HTTPError) {
|
||||||
throw new StatusError(`${e.response.statusCode} ${e.response.statusMessage}`, e.response.statusCode, e.response.statusMessage);
|
throw new StatusError(`download error from ${url}`, e.response.statusCode, e.response.statusMessage, e);
|
||||||
} else {
|
} else if (e instanceof Got.RequestError || e instanceof Got.AbortError) {
|
||||||
|
throw new Error(String(e), { cause: e });
|
||||||
|
} else if (e instanceof Error) {
|
||||||
throw e;
|
throw e;
|
||||||
|
} else {
|
||||||
|
throw new Error(String(e), { cause: e });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.succ(`Download finished: ${chalk.cyan(url)}`);
|
this.logger.info(`Download finished: ${chalk.cyan(url)}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filename,
|
filename,
|
||||||
|
|
@ -118,7 +123,7 @@ export class DownloadService {
|
||||||
// Create temp file
|
// Create temp file
|
||||||
const [path, cleanup] = await createTemp();
|
const [path, cleanup] = await createTemp();
|
||||||
|
|
||||||
this.logger.info(`text file: Temp file is ${path}`);
|
this.logger.debug(`text file: Temp file is ${path}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// write content at URL to temp file
|
// write content at URL to temp file
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import { isMimeImage } from '@/misc/is-mime-image.js';
|
||||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||||
import { UtilityService } from '@/core/UtilityService.js';
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import { BunnyService } from '@/core/BunnyService.js';
|
import { BunnyService } from '@/core/BunnyService.js';
|
||||||
|
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||||
import { LoggerService } from './LoggerService.js';
|
import { LoggerService } from './LoggerService.js';
|
||||||
|
|
||||||
type AddFileArgs = {
|
type AddFileArgs = {
|
||||||
|
|
@ -159,6 +160,14 @@ export class DriveService {
|
||||||
// thunbnail, webpublic を必要なら生成
|
// thunbnail, webpublic を必要なら生成
|
||||||
const alts = await this.generateAlts(path, type, !file.uri);
|
const alts = await this.generateAlts(path, type, !file.uri);
|
||||||
|
|
||||||
|
if (type && type.startsWith('video/')) {
|
||||||
|
try {
|
||||||
|
await this.videoProcessingService.webOptimizeVideo(path, type);
|
||||||
|
} catch (err) {
|
||||||
|
this.registerLogger.warn(`Video optimization failed: ${renderInlineError(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.meta.useObjectStorage) {
|
if (this.meta.useObjectStorage) {
|
||||||
//#region ObjectStorage params
|
//#region ObjectStorage params
|
||||||
let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) ?? ['']);
|
let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) ?? ['']);
|
||||||
|
|
@ -194,7 +203,7 @@ export class DriveService {
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
//#region Uploads
|
//#region Uploads
|
||||||
this.registerLogger.info(`uploading original: ${key}`);
|
this.registerLogger.debug(`uploading original: ${key}`);
|
||||||
const uploads = [
|
const uploads = [
|
||||||
this.upload(key, fs.createReadStream(path), type, null, name),
|
this.upload(key, fs.createReadStream(path), type, null, name),
|
||||||
];
|
];
|
||||||
|
|
@ -203,7 +212,7 @@ export class DriveService {
|
||||||
webpublicKey = `${prefix}webpublic-${randomUUID()}.${alts.webpublic.ext}`;
|
webpublicKey = `${prefix}webpublic-${randomUUID()}.${alts.webpublic.ext}`;
|
||||||
webpublicUrl = `${ baseUrl }/${ webpublicKey }`;
|
webpublicUrl = `${ baseUrl }/${ webpublicKey }`;
|
||||||
|
|
||||||
this.registerLogger.info(`uploading webpublic: ${webpublicKey}`);
|
this.registerLogger.debug(`uploading webpublic: ${webpublicKey}`);
|
||||||
uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, alts.webpublic.ext, name));
|
uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, alts.webpublic.ext, name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,7 +220,7 @@ export class DriveService {
|
||||||
thumbnailKey = `${prefix}thumbnail-${randomUUID()}.${alts.thumbnail.ext}`;
|
thumbnailKey = `${prefix}thumbnail-${randomUUID()}.${alts.thumbnail.ext}`;
|
||||||
thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`;
|
thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`;
|
||||||
|
|
||||||
this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`);
|
this.registerLogger.debug(`uploading thumbnail: ${thumbnailKey}`);
|
||||||
uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext, `${name}.thumbnail`));
|
uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext, `${name}.thumbnail`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,11 +264,11 @@ export class DriveService {
|
||||||
const [url, thumbnailUrl, webpublicUrl] = await Promise.all(promises);
|
const [url, thumbnailUrl, webpublicUrl] = await Promise.all(promises);
|
||||||
|
|
||||||
if (thumbnailUrl) {
|
if (thumbnailUrl) {
|
||||||
this.registerLogger.info(`thumbnail stored: ${thumbnailAccessKey}`);
|
this.registerLogger.debug(`thumbnail stored: ${thumbnailAccessKey}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (webpublicUrl) {
|
if (webpublicUrl) {
|
||||||
this.registerLogger.info(`web stored: ${webpublicAccessKey}`);
|
this.registerLogger.debug(`web stored: ${webpublicAccessKey}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
file.storedInternal = true;
|
file.storedInternal = true;
|
||||||
|
|
@ -303,7 +312,7 @@ export class DriveService {
|
||||||
thumbnail,
|
thumbnail,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.registerLogger.warn(`GenerateVideoThumbnail failed: ${err}`);
|
this.registerLogger.warn(`GenerateVideoThumbnail failed: ${renderInlineError(err)}`);
|
||||||
return {
|
return {
|
||||||
webpublic: null,
|
webpublic: null,
|
||||||
thumbnail: null,
|
thumbnail: null,
|
||||||
|
|
@ -336,7 +345,7 @@ export class DriveService {
|
||||||
metadata.height && metadata.height <= 2048
|
metadata.height && metadata.height <= 2048
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.registerLogger.warn(`sharp failed: ${err}`);
|
this.registerLogger.warn(`sharp failed: ${renderInlineError(err)}`);
|
||||||
return {
|
return {
|
||||||
webpublic: null,
|
webpublic: null,
|
||||||
thumbnail: null,
|
thumbnail: null,
|
||||||
|
|
@ -347,7 +356,7 @@ export class DriveService {
|
||||||
let webpublic: IImage | null = null;
|
let webpublic: IImage | null = null;
|
||||||
|
|
||||||
if (generateWeb && !satisfyWebpublic && !isAnimated) {
|
if (generateWeb && !satisfyWebpublic && !isAnimated) {
|
||||||
this.registerLogger.info('creating web image');
|
this.registerLogger.debug('creating web image');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (['image/jpeg', 'image/webp', 'image/avif'].includes(type)) {
|
if (['image/jpeg', 'image/webp', 'image/avif'].includes(type)) {
|
||||||
|
|
@ -358,12 +367,12 @@ export class DriveService {
|
||||||
this.registerLogger.debug('web image not created (not an required image)');
|
this.registerLogger.debug('web image not created (not an required image)');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.registerLogger.warn('web image not created (an error occurred)', err as Error);
|
this.registerLogger.warn(`web image not created: ${renderInlineError(err)}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (satisfyWebpublic) this.registerLogger.info('web image not created (original satisfies webpublic)');
|
if (satisfyWebpublic) this.registerLogger.debug('web image not created (original satisfies webpublic)');
|
||||||
else if (isAnimated) this.registerLogger.info('web image not created (animated image)');
|
else if (isAnimated) this.registerLogger.debug('web image not created (animated image)');
|
||||||
else this.registerLogger.info('web image not created (from remote)');
|
else this.registerLogger.debug('web image not created (from remote)');
|
||||||
}
|
}
|
||||||
// #endregion webpublic
|
// #endregion webpublic
|
||||||
|
|
||||||
|
|
@ -377,7 +386,7 @@ export class DriveService {
|
||||||
thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 422);
|
thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 422);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.registerLogger.warn('thumbnail not created (an error occurred)', err as Error);
|
this.registerLogger.warn(`Error creating thumbnail: ${renderInlineError(err)}`);
|
||||||
}
|
}
|
||||||
// #endregion thumbnail
|
// #endregion thumbnail
|
||||||
|
|
||||||
|
|
@ -411,27 +420,21 @@ export class DriveService {
|
||||||
);
|
);
|
||||||
if (this.meta.objectStorageSetPublicRead) params.ACL = 'public-read';
|
if (this.meta.objectStorageSetPublicRead) params.ACL = 'public-read';
|
||||||
|
|
||||||
if (this.bunnyService.usingBunnyCDN(this.meta)) {
|
try {
|
||||||
await this.bunnyService.upload(this.meta, key, stream).catch(
|
if (this.bunnyService.usingBunnyCDN(this.meta)) {
|
||||||
err => {
|
await this.bunnyService.upload(this.meta, key, stream);
|
||||||
this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err);
|
} else {
|
||||||
},
|
const result = await this.s3Service.upload(this.meta, params);
|
||||||
);
|
if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput
|
||||||
} else {
|
this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`);
|
||||||
await this.s3Service.upload(this.meta, params)
|
} else { // AbortMultipartUploadCommandOutput
|
||||||
.then(
|
this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`);
|
||||||
result => {
|
throw new Error('S3 upload aborted');
|
||||||
if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput
|
}
|
||||||
this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`);
|
}
|
||||||
} else { // AbortMultipartUploadCommandOutput
|
} catch (err) {
|
||||||
this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`);
|
this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}: ${renderInlineError(err)}`);
|
||||||
}
|
throw err;
|
||||||
})
|
|
||||||
.catch(
|
|
||||||
err => {
|
|
||||||
this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -490,7 +493,6 @@ export class DriveService {
|
||||||
}: AddFileArgs): Promise<MiDriveFile> {
|
}: AddFileArgs): Promise<MiDriveFile> {
|
||||||
const userRoleNSFW = user && (await this.roleService.getUserPolicies(user.id)).alwaysMarkNsfw;
|
const userRoleNSFW = user && (await this.roleService.getUserPolicies(user.id)).alwaysMarkNsfw;
|
||||||
const info = await this.fileInfoService.getFileInfo(path);
|
const info = await this.fileInfoService.getFileInfo(path);
|
||||||
this.registerLogger.info(`${JSON.stringify(info)}`);
|
|
||||||
|
|
||||||
// detect name
|
// detect name
|
||||||
const detectedName = correctFilename(
|
const detectedName = correctFilename(
|
||||||
|
|
@ -500,6 +502,8 @@ export class DriveService {
|
||||||
ext ?? info.type.ext,
|
ext ?? info.type.ext,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.registerLogger.debug(`Detected file info: ${JSON.stringify(info)}`);
|
||||||
|
|
||||||
if (user && !force) {
|
if (user && !force) {
|
||||||
// Check if there is a file with the same hash
|
// Check if there is a file with the same hash
|
||||||
const matched = await this.driveFilesRepository.findOneBy({
|
const matched = await this.driveFilesRepository.findOneBy({
|
||||||
|
|
@ -508,7 +512,7 @@ export class DriveService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (matched) {
|
if (matched) {
|
||||||
this.registerLogger.info(`file with same hash is found: ${matched.id}`);
|
this.registerLogger.debug(`file with same hash is found: ${matched.id}`);
|
||||||
if (sensitive && !matched.isSensitive) {
|
if (sensitive && !matched.isSensitive) {
|
||||||
// The file is federated as sensitive for this time, but was federated as non-sensitive before.
|
// The file is federated as sensitive for this time, but was federated as non-sensitive before.
|
||||||
// Therefore, update the file to sensitive.
|
// Therefore, update the file to sensitive.
|
||||||
|
|
@ -636,14 +640,14 @@ export class DriveService {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// duplicate key error (when already registered)
|
// duplicate key error (when already registered)
|
||||||
if (isDuplicateKeyValueError(err)) {
|
if (isDuplicateKeyValueError(err)) {
|
||||||
this.registerLogger.info(`already registered ${file.uri}`);
|
this.registerLogger.debug(`already registered ${file.uri}`);
|
||||||
|
|
||||||
file = await this.driveFilesRepository.findOneBy({
|
file = await this.driveFilesRepository.findOneBy({
|
||||||
uri: file.uri!,
|
uri: file.uri!,
|
||||||
userId: user ? user.id : IsNull(),
|
userId: user ? user.id : IsNull(),
|
||||||
}) as MiDriveFile;
|
}) as MiDriveFile;
|
||||||
} else {
|
} else {
|
||||||
this.registerLogger.error(err as Error);
|
this.registerLogger.error('Error in drive register', err as Error);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -651,7 +655,7 @@ export class DriveService {
|
||||||
file = await (this.save(file, path, detectedName, info));
|
file = await (this.save(file, path, detectedName, info));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.registerLogger.succ(`drive file has been created ${file.id}`);
|
this.registerLogger.info(`Created file ${file.id} (${detectedName}) of type ${info.type.mime} for user ${user?.id ?? '<none>'}`);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
this.driveFileEntityService.pack(file, { self: true }).then(packedFile => {
|
this.driveFileEntityService.pack(file, { self: true }).then(packedFile => {
|
||||||
|
|
@ -847,7 +851,7 @@ export class DriveService {
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.name === 'NoSuchKey') {
|
if (err.name === 'NoSuchKey') {
|
||||||
this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`, err as Error);
|
this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Failed to delete the file from the object storage with the given key: ${key}`, {
|
throw new Error(`Failed to delete the file from the object storage with the given key: ${key}`, {
|
||||||
|
|
@ -884,13 +888,10 @@ export class DriveService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders });
|
const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders });
|
||||||
this.downloaderLogger.succ(`Got: ${driveFile.id}`);
|
this.downloaderLogger.debug(`Upload succeeded: created file ${driveFile.id}`);
|
||||||
return driveFile!;
|
return driveFile!;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.downloaderLogger.error(`Failed to create drive file: ${err}`, {
|
this.downloaderLogger.error(`Failed to create drive file from ${url}: ${renderInlineError(err)}`);
|
||||||
url: url,
|
|
||||||
e: err,
|
|
||||||
});
|
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|
|
||||||
|
|
@ -136,10 +136,10 @@ export class FanoutTimelineEndpointService {
|
||||||
const parentFilter = filter;
|
const parentFilter = filter;
|
||||||
filter = (note) => {
|
filter = (note) => {
|
||||||
if (!ps.ignoreAuthorFromInstanceBlock) {
|
if (!ps.ignoreAuthorFromInstanceBlock) {
|
||||||
if (this.utilityService.isBlockedHost(this.meta.blockedHosts, note.userHost)) return false;
|
if (note.userInstance?.isBlocked) return false;
|
||||||
}
|
}
|
||||||
if (note.userId !== note.renoteUserId && this.utilityService.isBlockedHost(this.meta.blockedHosts, note.renoteUserHost)) return false;
|
if (note.userId !== note.renoteUserId && note.renoteUserInstance?.isBlocked) return false;
|
||||||
if (note.userId !== note.replyUserId && this.utilityService.isBlockedHost(this.meta.blockedHosts, note.replyUserHost)) return false;
|
if (note.userId !== note.replyUserId && note.replyUserInstance?.isBlocked) return false;
|
||||||
|
|
||||||
return parentFilter(note);
|
return parentFilter(note);
|
||||||
};
|
};
|
||||||
|
|
@ -194,7 +194,10 @@ export class FanoutTimelineEndpointService {
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||||
.leftJoinAndSelect('note.channel', 'channel');
|
.leftJoinAndSelect('note.channel', 'channel')
|
||||||
|
.leftJoinAndSelect('note.userInstance', 'userInstance')
|
||||||
|
.leftJoinAndSelect('note.replyUserInstance', 'replyUserInstance')
|
||||||
|
.leftJoinAndSelect('note.renoteUserInstance', 'renoteUserInstance');
|
||||||
|
|
||||||
const notes = (await query.getMany()).filter(noteFilter);
|
const notes = (await query.getMany()).filter(noteFilter);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,24 @@
|
||||||
|
|
||||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
import { QueryFailedError } from 'typeorm';
|
import type { InstancesRepository, MiMeta } from '@/models/_.js';
|
||||||
import type { InstancesRepository } from '@/models/_.js';
|
|
||||||
import type { MiInstance } from '@/models/Instance.js';
|
import type { MiInstance } from '@/models/Instance.js';
|
||||||
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
import { MemoryKVCache } from '@/misc/cache.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { UtilityService } from '@/core/UtilityService.js';
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||||
|
import { Serialized } from '@/types.js';
|
||||||
|
import { diffArrays, diffArraysSimple } from '@/misc/diff-arrays.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FederatedInstanceService implements OnApplicationShutdown {
|
export class FederatedInstanceService implements OnApplicationShutdown {
|
||||||
public federatedInstanceCache: RedisKVCache<MiInstance | null>;
|
private readonly federatedInstanceCache: MemoryKVCache<MiInstance | null>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.redis)
|
@Inject(DI.redisForSub)
|
||||||
private redisClient: Redis.Redis,
|
private redisForSub: Redis.Redis,
|
||||||
|
|
||||||
@Inject(DI.instancesRepository)
|
@Inject(DI.instancesRepository)
|
||||||
private instancesRepository: InstancesRepository,
|
private instancesRepository: InstancesRepository,
|
||||||
|
|
@ -29,67 +30,46 @@ export class FederatedInstanceService implements OnApplicationShutdown {
|
||||||
private utilityService: UtilityService,
|
private utilityService: UtilityService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
) {
|
) {
|
||||||
this.federatedInstanceCache = new RedisKVCache<MiInstance | null>(this.redisClient, 'federatedInstance', {
|
this.federatedInstanceCache = new MemoryKVCache(1000 * 60 * 3); // 3m
|
||||||
lifetime: 1000 * 60 * 30, // 30m
|
this.redisForSub.on('message', this.onMessage);
|
||||||
memoryCacheLifetime: 1000 * 60 * 3, // 3m
|
|
||||||
fetcher: (key) => this.instancesRepository.findOneBy({ host: key }),
|
|
||||||
toRedisConverter: (value) => JSON.stringify(value),
|
|
||||||
fromRedisConverter: (value) => {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
if (parsed == null) return null;
|
|
||||||
return {
|
|
||||||
...parsed,
|
|
||||||
firstRetrievedAt: new Date(parsed.firstRetrievedAt),
|
|
||||||
latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null,
|
|
||||||
infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null,
|
|
||||||
notRespondingSince: parsed.notRespondingSince ? new Date(parsed.notRespondingSince) : null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async fetchOrRegister(host: string): Promise<MiInstance> {
|
public async fetchOrRegister(host: string): Promise<MiInstance> {
|
||||||
host = this.utilityService.toPuny(host);
|
host = this.utilityService.toPuny(host);
|
||||||
|
|
||||||
const cached = await this.federatedInstanceCache.get(host);
|
const cached = this.federatedInstanceCache.get(host);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const index = await this.instancesRepository.findOneBy({ host });
|
let index = await this.instancesRepository.findOneBy({ host });
|
||||||
|
|
||||||
if (index == null) {
|
if (index == null) {
|
||||||
let i;
|
await this.instancesRepository.createQueryBuilder('instance')
|
||||||
try {
|
.insert()
|
||||||
i = await this.instancesRepository.insertOne({
|
.values({
|
||||||
id: this.idService.gen(),
|
id: this.idService.gen(),
|
||||||
host,
|
host,
|
||||||
firstRetrievedAt: new Date(),
|
firstRetrievedAt: new Date(),
|
||||||
});
|
isBlocked: this.utilityService.isBlockedHost(host),
|
||||||
} catch (e: unknown) {
|
isSilenced: this.utilityService.isSilencedHost(host),
|
||||||
if (e instanceof QueryFailedError) {
|
isMediaSilenced: this.utilityService.isMediaSilencedHost(host),
|
||||||
if (isDuplicateKeyValueError(e)) {
|
isAllowListed: this.utilityService.isAllowListedHost(host),
|
||||||
i = await this.instancesRepository.findOneBy({ host });
|
isBubbled: this.utilityService.isBubbledHost(host),
|
||||||
}
|
})
|
||||||
}
|
.orIgnore()
|
||||||
|
.execute();
|
||||||
|
|
||||||
if (i == null) {
|
index = await this.instancesRepository.findOneByOrFail({ host });
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.federatedInstanceCache.set(host, i);
|
|
||||||
return i;
|
|
||||||
} else {
|
|
||||||
this.federatedInstanceCache.set(host, index);
|
|
||||||
return index;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.federatedInstanceCache.set(host, index);
|
||||||
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async fetch(host: string): Promise<MiInstance | null> {
|
public async fetch(host: string): Promise<MiInstance | null> {
|
||||||
host = this.utilityService.toPuny(host);
|
host = this.utilityService.toPuny(host);
|
||||||
|
|
||||||
const cached = await this.federatedInstanceCache.get(host);
|
const cached = this.federatedInstanceCache.get(host);
|
||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
const index = await this.instancesRepository.findOneBy({ host });
|
const index = await this.instancesRepository.findOneBy({ host });
|
||||||
|
|
@ -117,8 +97,35 @@ export class FederatedInstanceService implements OnApplicationShutdown {
|
||||||
this.federatedInstanceCache.set(result.host, result);
|
this.federatedInstanceCache.set(result.host, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncCache(before: Serialized<MiMeta | undefined>, after: Serialized<MiMeta>): void {
|
||||||
|
const changed =
|
||||||
|
diffArraysSimple(before?.blockedHosts, after.blockedHosts) ||
|
||||||
|
diffArraysSimple(before?.silencedHosts, after.silencedHosts) ||
|
||||||
|
diffArraysSimple(before?.mediaSilencedHosts, after.mediaSilencedHosts) ||
|
||||||
|
diffArraysSimple(before?.federationHosts, after.federationHosts) ||
|
||||||
|
diffArraysSimple(before?.bubbleInstances, after.bubbleInstances);
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
// We have to clear the whole thing, otherwise subdomains won't be synced.
|
||||||
|
this.federatedInstanceCache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async onMessage(_: string, data: string): Promise<void> {
|
||||||
|
const obj = JSON.parse(data);
|
||||||
|
|
||||||
|
if (obj.channel === 'internal') {
|
||||||
|
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||||
|
if (type === 'metaUpdated') {
|
||||||
|
this.syncCache(body.before, body.after);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
|
this.redisForSub.off('message', this.onMessage);
|
||||||
this.federatedInstanceCache.dispose();
|
this.federatedInstanceCache.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { URL } from 'node:url';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import tinycolor from 'tinycolor2';
|
import tinycolor from 'tinycolor2';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
import { load as cheerio } from 'cheerio';
|
import { load as cheerio } from 'cheerio/slim';
|
||||||
import type { MiInstance } from '@/models/Instance.js';
|
import type { MiInstance } from '@/models/Instance.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
|
@ -15,7 +15,8 @@ import { LoggerService } from '@/core/LoggerService.js';
|
||||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||||
import type { CheerioAPI } from 'cheerio';
|
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||||
|
import type { CheerioAPI } from 'cheerio/slim';
|
||||||
|
|
||||||
type NodeInfo = {
|
type NodeInfo = {
|
||||||
openRegistrations?: unknown;
|
openRegistrations?: unknown;
|
||||||
|
|
@ -90,7 +91,7 @@ export class FetchInstanceMetadataService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.info(`Fetching metadata of ${instance.host} ...`);
|
this.logger.debug(`Fetching metadata of ${instance.host} ...`);
|
||||||
|
|
||||||
const [info, dom, manifest] = await Promise.all([
|
const [info, dom, manifest] = await Promise.all([
|
||||||
this.fetchNodeinfo(instance).catch(() => null),
|
this.fetchNodeinfo(instance).catch(() => null),
|
||||||
|
|
@ -106,7 +107,7 @@ export class FetchInstanceMetadataService {
|
||||||
this.getDescription(info, dom, manifest).catch(() => null),
|
this.getDescription(info, dom, manifest).catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.logger.succ(`Successfuly fetched metadata of ${instance.host}`);
|
this.logger.debug(`Successfuly fetched metadata of ${instance.host}`);
|
||||||
|
|
||||||
const updates = {
|
const updates = {
|
||||||
infoUpdatedAt: new Date(),
|
infoUpdatedAt: new Date(),
|
||||||
|
|
@ -128,9 +129,9 @@ export class FetchInstanceMetadataService {
|
||||||
|
|
||||||
await this.federatedInstanceService.update(instance.id, updates);
|
await this.federatedInstanceService.update(instance.id, updates);
|
||||||
|
|
||||||
this.logger.succ(`Successfuly updated metadata of ${instance.host}`);
|
this.logger.info(`Successfully updated metadata of ${instance.host}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error(`Failed to update metadata of ${instance.host}: ${e}`);
|
this.logger.error(`Failed to update metadata of ${instance.host}: ${renderInlineError(e)}`);
|
||||||
} finally {
|
} finally {
|
||||||
await this.unlock(host);
|
await this.unlock(host);
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +139,7 @@ export class FetchInstanceMetadataService {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async fetchNodeinfo(instance: MiInstance): Promise<NodeInfo> {
|
private async fetchNodeinfo(instance: MiInstance): Promise<NodeInfo> {
|
||||||
this.logger.info(`Fetching nodeinfo of ${instance.host} ...`);
|
this.logger.debug(`Fetching nodeinfo of ${instance.host} ...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const wellknown = await this.httpRequestService.getJson('https://' + instance.host + '/.well-known/nodeinfo')
|
const wellknown = await this.httpRequestService.getJson('https://' + instance.host + '/.well-known/nodeinfo')
|
||||||
|
|
@ -170,11 +171,11 @@ export class FetchInstanceMetadataService {
|
||||||
throw err.statusCode ?? err.message;
|
throw err.statusCode ?? err.message;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.succ(`Successfuly fetched nodeinfo of ${instance.host}`);
|
this.logger.debug(`Successfuly fetched nodeinfo of ${instance.host}`);
|
||||||
|
|
||||||
return info as NodeInfo;
|
return info as NodeInfo;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${err}`);
|
this.logger.warn(`Failed to fetch nodeinfo of ${instance.host}: ${renderInlineError(err)}`);
|
||||||
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
@ -182,7 +183,7 @@ export class FetchInstanceMetadataService {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async fetchDom(instance: MiInstance): Promise<CheerioAPI> {
|
private async fetchDom(instance: MiInstance): Promise<CheerioAPI> {
|
||||||
this.logger.info(`Fetching HTML of ${instance.host} ...`);
|
this.logger.debug(`Fetching HTML of ${instance.host} ...`);
|
||||||
|
|
||||||
const url = 'https://' + instance.host;
|
const url = 'https://' + instance.host;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,13 @@ const TYPE_SVG = {
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FileInfoService {
|
export class FileInfoService {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
private ffprobeLogger: Logger;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.loggerService.getLogger('file-info');
|
this.logger = this.loggerService.getLogger('file-info');
|
||||||
|
this.ffprobeLogger = this.logger.createSubLogger('ffprobe');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -162,20 +164,19 @@ export class FileInfoService {
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
private hasVideoTrackOnVideoFile(path: string): Promise<boolean> {
|
private hasVideoTrackOnVideoFile(path: string): Promise<boolean> {
|
||||||
const sublogger = this.logger.createSubLogger('ffprobe');
|
this.ffprobeLogger.debug(`Checking the video file. File path: ${path}`);
|
||||||
sublogger.info(`Checking the video file. File path: ${path}`);
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
try {
|
try {
|
||||||
FFmpeg.ffprobe(path, (err, metadata) => {
|
FFmpeg.ffprobe(path, (err, metadata) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
sublogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err);
|
this.ffprobeLogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err);
|
||||||
resolve(true);
|
resolve(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolve(metadata.streams.some((stream) => stream.codec_type === 'video'));
|
resolve(metadata.streams.some((stream) => stream.codec_type === 'video'));
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sublogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err as Error);
|
this.ffprobeLogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err as Error);
|
||||||
resolve(true);
|
resolve(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,7 @@ export interface InternalEventTypes {
|
||||||
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
|
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
|
||||||
userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; };
|
userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; };
|
||||||
userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; };
|
userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; };
|
||||||
|
quantumCacheUpdated: { name: string, keys: string[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventTypesToEventPayload<T> = EventUnionFromDictionary<UndefinedAsNullAll<SerializedAll<T>>>;
|
type EventTypesToEventPayload<T> = EventUnionFromDictionary<UndefinedAsNullAll<SerializedAll<T>>>;
|
||||||
|
|
@ -353,12 +354,12 @@ export class GlobalEventService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private publish(channel: StreamChannels, type: string | null, value?: any): void {
|
private async publish(channel: StreamChannels, type: string | null, value?: any): Promise<void> {
|
||||||
const message = type == null ? value : value == null ?
|
const message = type == null ? value : value == null ?
|
||||||
{ type: type, body: null } :
|
{ type: type, body: null } :
|
||||||
{ type: type, body: value };
|
{ type: type, body: value };
|
||||||
|
|
||||||
this.redisForPub.publish(this.config.host, JSON.stringify({
|
await this.redisForPub.publish(this.config.host, JSON.stringify({
|
||||||
channel: channel,
|
channel: channel,
|
||||||
message: message,
|
message: message,
|
||||||
}));
|
}));
|
||||||
|
|
@ -369,6 +370,11 @@ export class GlobalEventService {
|
||||||
this.publish('internal', type, typeof value === 'undefined' ? null : value);
|
this.publish('internal', type, typeof value === 'undefined' ? null : value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async publishInternalEventAsync<K extends keyof InternalEventTypes>(type: K, value?: InternalEventTypes[K]): Promise<void> {
|
||||||
|
await this.publish('internal', type, typeof value === 'undefined' ? null : value);
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public publishBroadcastStream<K extends keyof BroadcastTypes>(type: K, value?: BroadcastTypes[K]): void {
|
public publishBroadcastStream<K extends keyof BroadcastTypes>(type: K, value?: BroadcastTypes[K]): void {
|
||||||
this.publish('broadcast', type, typeof value === 'undefined' ? null : value);
|
this.publish('broadcast', type, typeof value === 'undefined' ? null : value);
|
||||||
|
|
|
||||||
|
|
@ -235,7 +235,7 @@ export class HttpRequestService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise<IObjectWithId> {
|
public async getActivityJson(url: string, isLocalAddressAllowed = false, allowAnonymous = false): Promise<IObjectWithId> {
|
||||||
this.apUtilityService.assertApUrl(url);
|
this.apUtilityService.assertApUrl(url);
|
||||||
|
|
||||||
const res = await this.send(url, {
|
const res = await this.send(url, {
|
||||||
|
|
@ -255,7 +255,11 @@ export class HttpRequestService {
|
||||||
|
|
||||||
// Make sure the object ID matches the final URL (which is where it actually exists).
|
// Make sure the object ID matches the final URL (which is where it actually exists).
|
||||||
// The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match.
|
// The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match.
|
||||||
this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url);
|
if (allowAnonymous && activity.id == null) {
|
||||||
|
activity.id = res.url;
|
||||||
|
} else {
|
||||||
|
this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url);
|
||||||
|
}
|
||||||
|
|
||||||
return activity as IObjectWithId;
|
return activity as IObjectWithId;
|
||||||
}
|
}
|
||||||
|
|
@ -327,7 +331,7 @@ export class HttpRequestService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok && extra.throwErrorWhenResponseNotOk) {
|
if (!res.ok && extra.throwErrorWhenResponseNotOk) {
|
||||||
throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText);
|
throw new StatusError(`request error from ${url}`, res.status, res.statusText);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|
|
||||||
103
packages/backend/src/core/InternalEventService.ts
Normal file
103
packages/backend/src/core/InternalEventService.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
|
import type { GlobalEvents, InternalEventTypes } from '@/core/GlobalEventService.js';
|
||||||
|
import { bindThis } from '@/decorators.js';
|
||||||
|
|
||||||
|
export type Listener<K extends keyof InternalEventTypes> = (value: InternalEventTypes[K], key: K, isLocal: boolean) => void | Promise<void>;
|
||||||
|
|
||||||
|
export interface ListenerProps {
|
||||||
|
ignoreLocal?: boolean,
|
||||||
|
ignoreRemote?: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InternalEventService implements OnApplicationShutdown {
|
||||||
|
private readonly listeners = new Map<keyof InternalEventTypes, Map<Listener<keyof InternalEventTypes>, ListenerProps>>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.redisForSub)
|
||||||
|
private readonly redisForSub: Redis.Redis,
|
||||||
|
|
||||||
|
private readonly globalEventService: GlobalEventService,
|
||||||
|
) {
|
||||||
|
this.redisForSub.on('message', this.onMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public on<K extends keyof InternalEventTypes>(type: K, listener: Listener<K>, props?: ListenerProps): void {
|
||||||
|
let set = this.listeners.get(type);
|
||||||
|
if (!set) {
|
||||||
|
set = new Map();
|
||||||
|
this.listeners.set(type, set);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Functionally, this is just a set with metadata on the values.
|
||||||
|
set.set(listener as Listener<keyof InternalEventTypes>, props ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public off<K extends keyof InternalEventTypes>(type: K, listener: Listener<K>): void {
|
||||||
|
this.listeners.get(type)?.delete(listener as Listener<keyof InternalEventTypes>);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async emit<K extends keyof InternalEventTypes>(type: K, value: InternalEventTypes[K]): Promise<void> {
|
||||||
|
await this.emitInternal(type, value, true);
|
||||||
|
await this.globalEventService.publishInternalEventAsync(type, { ...value, _pid: process.pid });
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async emitInternal<K extends keyof InternalEventTypes>(type: K, value: InternalEventTypes[K], isLocal: boolean): Promise<void> {
|
||||||
|
const listeners = this.listeners.get(type);
|
||||||
|
if (!listeners) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promises: Promise<void>[] = [];
|
||||||
|
for (const [listener, props] of listeners) {
|
||||||
|
if ((isLocal && !props.ignoreLocal) || (!isLocal && !props.ignoreRemote)) {
|
||||||
|
const promise = Promise.resolve(listener(value, type, isLocal));
|
||||||
|
promises.push(promise);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async onMessage(_: string, data: string): Promise<void> {
|
||||||
|
const obj = JSON.parse(data);
|
||||||
|
|
||||||
|
if (obj.channel === 'internal') {
|
||||||
|
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
||||||
|
if (!isLocalInternalEvent(body) || body._pid !== process.pid) {
|
||||||
|
await this.emitInternal(type, body as InternalEventTypes[keyof InternalEventTypes], false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public dispose(): void {
|
||||||
|
this.redisForSub.off('message', this.onMessage);
|
||||||
|
this.listeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public onApplicationShutdown(): void {
|
||||||
|
this.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocalInternalEvent {
|
||||||
|
_pid: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalInternalEvent(body: object): body is LocalInternalEvent {
|
||||||
|
return '_pid' in body && typeof(body._pid) === 'number';
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import { DI } from '@/di-symbols.js';
|
||||||
import type { LatestNotesRepository, NotesRepository } from '@/models/_.js';
|
import type { LatestNotesRepository, NotesRepository } from '@/models/_.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.js';
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
import Logger from '@/logger.js';
|
import Logger from '@/logger.js';
|
||||||
|
import { QueryService } from './QueryService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LatestNoteService {
|
export class LatestNoteService {
|
||||||
|
|
@ -14,11 +15,12 @@ export class LatestNoteService {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.notesRepository)
|
@Inject(DI.notesRepository)
|
||||||
private notesRepository: NotesRepository,
|
private readonly notesRepository: NotesRepository,
|
||||||
|
|
||||||
@Inject(DI.latestNotesRepository)
|
@Inject(DI.latestNotesRepository)
|
||||||
private latestNotesRepository: LatestNotesRepository,
|
private readonly latestNotesRepository: LatestNotesRepository,
|
||||||
|
|
||||||
|
private readonly queryService: QueryService,
|
||||||
loggerService: LoggerService,
|
loggerService: LoggerService,
|
||||||
) {
|
) {
|
||||||
this.logger = loggerService.getLogger('LatestNoteService');
|
this.logger = loggerService.getLogger('LatestNoteService');
|
||||||
|
|
@ -91,7 +93,7 @@ export class LatestNoteService {
|
||||||
|
|
||||||
// Find the newest remaining note for the user.
|
// Find the newest remaining note for the user.
|
||||||
// We exclude DMs and pure renotes.
|
// We exclude DMs and pure renotes.
|
||||||
const nextLatest = await this.notesRepository
|
const query = this.notesRepository
|
||||||
.createQueryBuilder('note')
|
.createQueryBuilder('note')
|
||||||
.select()
|
.select()
|
||||||
.where({
|
.where({
|
||||||
|
|
@ -106,18 +108,11 @@ export class LatestNoteService {
|
||||||
? Not(null)
|
? Not(null)
|
||||||
: null,
|
: null,
|
||||||
})
|
})
|
||||||
.andWhere(`
|
.orderBy({ id: 'DESC' });
|
||||||
(
|
|
||||||
note."renoteId" IS NULL
|
this.queryService.andIsNotRenote(query, 'note');
|
||||||
OR note.text IS NOT NULL
|
|
||||||
OR note.cw IS NOT NULL
|
const nextLatest = await query.getOne();
|
||||||
OR note."replyId" IS NOT NULL
|
|
||||||
OR note."hasPoll"
|
|
||||||
OR note."fileIds" != '{}'
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
.orderBy({ id: 'DESC' })
|
|
||||||
.getOne();
|
|
||||||
if (!nextLatest) return;
|
if (!nextLatest) return;
|
||||||
|
|
||||||
// Record it as the latest
|
// Record it as the latest
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { MiMeta } from '@/models/Meta.js';
|
import { MiMeta } from '@/models/Meta.js';
|
||||||
|
|
@ -12,6 +12,9 @@ import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
||||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||||
|
import { MiInstance } from '@/models/Instance.js';
|
||||||
|
import { diffArrays } from '@/misc/diff-arrays.js';
|
||||||
|
import type { MetasRepository } from '@/models/_.js';
|
||||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -26,6 +29,9 @@ export class MetaService implements OnApplicationShutdown {
|
||||||
@Inject(DI.db)
|
@Inject(DI.db)
|
||||||
private db: DataSource,
|
private db: DataSource,
|
||||||
|
|
||||||
|
@Inject(DI.metasRepository)
|
||||||
|
private readonly metasRepository: MetasRepository,
|
||||||
|
|
||||||
private featuredService: FeaturedService,
|
private featuredService: FeaturedService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
) {
|
) {
|
||||||
|
|
@ -67,35 +73,35 @@ export class MetaService implements OnApplicationShutdown {
|
||||||
public async fetch(noCache = false): Promise<MiMeta> {
|
public async fetch(noCache = false): Promise<MiMeta> {
|
||||||
if (!noCache && this.cache) return this.cache;
|
if (!noCache && this.cache) return this.cache;
|
||||||
|
|
||||||
return await this.db.transaction(async transactionalEntityManager => {
|
// 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する
|
||||||
// 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する
|
let meta = await this.metasRepository.createQueryBuilder('meta')
|
||||||
const metas = await transactionalEntityManager.find(MiMeta, {
|
.select()
|
||||||
order: {
|
.orderBy({
|
||||||
|
id: 'DESC',
|
||||||
|
})
|
||||||
|
.limit(1)
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (!meta) {
|
||||||
|
await this.metasRepository.createQueryBuilder('meta')
|
||||||
|
.insert()
|
||||||
|
.values({
|
||||||
|
id: 'x',
|
||||||
|
})
|
||||||
|
.orIgnore()
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
meta = await this.metasRepository.createQueryBuilder('meta')
|
||||||
|
.select()
|
||||||
|
.orderBy({
|
||||||
id: 'DESC',
|
id: 'DESC',
|
||||||
},
|
})
|
||||||
});
|
.limit(1)
|
||||||
|
.getOneOrFail();
|
||||||
|
}
|
||||||
|
|
||||||
const meta = metas[0];
|
this.cache = meta;
|
||||||
|
return meta;
|
||||||
if (meta) {
|
|
||||||
this.cache = meta;
|
|
||||||
return meta;
|
|
||||||
} else {
|
|
||||||
// metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う
|
|
||||||
const saved = await transactionalEntityManager
|
|
||||||
.upsert(
|
|
||||||
MiMeta,
|
|
||||||
{
|
|
||||||
id: 'x',
|
|
||||||
},
|
|
||||||
['id'],
|
|
||||||
)
|
|
||||||
.then((x) => transactionalEntityManager.findOneByOrFail(MiMeta, x.identifiers[0]));
|
|
||||||
|
|
||||||
this.cache = saved;
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -103,7 +109,7 @@ export class MetaService implements OnApplicationShutdown {
|
||||||
let before: MiMeta | undefined;
|
let before: MiMeta | undefined;
|
||||||
|
|
||||||
const updated = await this.db.transaction(async transactionalEntityManager => {
|
const updated = await this.db.transaction(async transactionalEntityManager => {
|
||||||
const metas = await transactionalEntityManager.find(MiMeta, {
|
const metas: (MiMeta | undefined)[] = await transactionalEntityManager.find(MiMeta, {
|
||||||
order: {
|
order: {
|
||||||
id: 'DESC',
|
id: 'DESC',
|
||||||
},
|
},
|
||||||
|
|
@ -126,6 +132,10 @@ export class MetaService implements OnApplicationShutdown {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Propagate changes to blockedHosts, silencedHosts, mediaSilencedHosts, federationInstances, and bubbleInstances to the relevant instance rows
|
||||||
|
// Do this inside the transaction to avoid potential race condition (when an instance gets registered while we're updating).
|
||||||
|
await this.persistBlocks(transactionalEntityManager, before ?? {}, afters[0]);
|
||||||
|
|
||||||
return afters[0];
|
return afters[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -159,4 +169,49 @@ export class MetaService implements OnApplicationShutdown {
|
||||||
public onApplicationShutdown(signal?: string | undefined): void {
|
public onApplicationShutdown(signal?: string | undefined): void {
|
||||||
this.dispose();
|
this.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async persistBlocks(tem: EntityManager, before: Partial<MiMeta>, after: Partial<MiMeta>): Promise<void> {
|
||||||
|
await this.persistBlock(tem, before.blockedHosts, after.blockedHosts, 'isBlocked');
|
||||||
|
await this.persistBlock(tem, before.silencedHosts, after.silencedHosts, 'isSilenced');
|
||||||
|
await this.persistBlock(tem, before.mediaSilencedHosts, after.mediaSilencedHosts, 'isMediaSilenced');
|
||||||
|
await this.persistBlock(tem, before.federationHosts, after.federationHosts, 'isAllowListed');
|
||||||
|
await this.persistBlock(tem, before.bubbleInstances, after.bubbleInstances, 'isBubbled');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistBlock(tem: EntityManager, before: string[] | undefined, after: string[] | undefined, field: keyof MiInstance): Promise<void> {
|
||||||
|
const { added, removed } = diffArrays(before, after);
|
||||||
|
|
||||||
|
if (removed.length > 0) {
|
||||||
|
await this.updateInstancesByHost(tem, field, false, removed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (added.length > 0) {
|
||||||
|
await this.updateInstancesByHost(tem, field, true, added);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateInstancesByHost(tem: EntityManager, field: keyof MiInstance, value: boolean, hosts: string[]): Promise<void> {
|
||||||
|
// Use non-array queries when possible, as they are indexed and can be much faster.
|
||||||
|
if (hosts.length === 1) {
|
||||||
|
const pattern = genHostPattern(hosts[0]);
|
||||||
|
await tem
|
||||||
|
.createQueryBuilder(MiInstance, 'instance')
|
||||||
|
.update()
|
||||||
|
.set({ [field]: value })
|
||||||
|
.where('(lower(reverse("host")) || \'.\') LIKE :pattern', { pattern })
|
||||||
|
.execute();
|
||||||
|
} else if (hosts.length > 1) {
|
||||||
|
const patterns = hosts.map(host => genHostPattern(host));
|
||||||
|
await tem
|
||||||
|
.createQueryBuilder(MiInstance, 'instance')
|
||||||
|
.update()
|
||||||
|
.set({ [field]: value })
|
||||||
|
.where('(lower(reverse("host")) || \'.\') LIKE ANY (:patterns)', { patterns })
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function genHostPattern(host: string): string {
|
||||||
|
return host.toLowerCase().split('').reverse().join('') + '.%';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,25 +5,22 @@
|
||||||
|
|
||||||
import { URL } from 'node:url';
|
import { URL } from 'node:url';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import * as parse5 from 'parse5';
|
import { isText, isTag, Text } from 'domhandler';
|
||||||
import { type Document, type HTMLParagraphElement, Window } from 'happy-dom';
|
import * as htmlparser2 from 'htmlparser2';
|
||||||
|
import { Node, Document, ChildNode, Element, ParentNode } from 'domhandler';
|
||||||
|
import * as domserializer from 'dom-serializer';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { intersperse } from '@/misc/prelude/array.js';
|
import { intersperse } from '@/misc/prelude/array.js';
|
||||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||||
import type { IMentionedRemoteUsers } from '@/models/Note.js';
|
import type { IMentionedRemoteUsers } from '@/models/Note.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import type { DefaultTreeAdapterMap } from 'parse5';
|
import type * as mfm from 'mfm-js';
|
||||||
import type * as mfm from '@transfem-org/sfm-js';
|
|
||||||
|
|
||||||
const treeAdapter = parse5.defaultTreeAdapter;
|
|
||||||
type Node = DefaultTreeAdapterMap['node'];
|
|
||||||
type ChildNode = DefaultTreeAdapterMap['childNode'];
|
|
||||||
|
|
||||||
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
|
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
|
||||||
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
|
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
|
||||||
|
|
||||||
export type Appender = (document: Document, body: HTMLParagraphElement) => void;
|
export type Appender = (document: Document, body: Element) => void;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MfmService {
|
export class MfmService {
|
||||||
|
|
@ -40,7 +37,7 @@ export class MfmService {
|
||||||
|
|
||||||
const normalizedHashtagNames = hashtagNames == null ? undefined : new Set<string>(hashtagNames.map(x => normalizeForSearch(x)));
|
const normalizedHashtagNames = hashtagNames == null ? undefined : new Set<string>(hashtagNames.map(x => normalizeForSearch(x)));
|
||||||
|
|
||||||
const dom = parse5.parseFragment(html);
|
const dom = htmlparser2.parseDocument(html);
|
||||||
|
|
||||||
let text = '';
|
let text = '';
|
||||||
|
|
||||||
|
|
@ -51,57 +48,50 @@ export class MfmService {
|
||||||
return text.trim();
|
return text.trim();
|
||||||
|
|
||||||
function getText(node: Node): string {
|
function getText(node: Node): string {
|
||||||
if (treeAdapter.isTextNode(node)) return node.value;
|
if (isText(node)) return node.data;
|
||||||
if (!treeAdapter.isElementNode(node)) return '';
|
if (!isTag(node)) return '';
|
||||||
if (node.nodeName === 'br') return '\n';
|
if (node.tagName === 'br') return '\n';
|
||||||
|
|
||||||
if (node.childNodes) {
|
return node.childNodes.map(n => getText(n)).join('');
|
||||||
return node.childNodes.map(n => getText(n)).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendChildren(childNodes: ChildNode[]): void {
|
function appendChildren(childNodes: ChildNode[]): void {
|
||||||
if (childNodes) {
|
for (const n of childNodes) {
|
||||||
for (const n of childNodes) {
|
analyze(n);
|
||||||
analyze(n);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function analyze(node: Node) {
|
function analyze(node: Node) {
|
||||||
if (treeAdapter.isTextNode(node)) {
|
if (isText(node)) {
|
||||||
text += node.value;
|
text += node.data;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip comment or document type node
|
// Skip comment or document type node
|
||||||
if (!treeAdapter.isElementNode(node)) {
|
if (!isTag(node)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (node.nodeName) {
|
switch (node.tagName) {
|
||||||
case 'br': {
|
case 'br': {
|
||||||
text += '\n';
|
text += '\n';
|
||||||
break;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'a': {
|
case 'a': {
|
||||||
const txt = getText(node);
|
const txt = getText(node);
|
||||||
const rel = node.attrs.find(x => x.name === 'rel');
|
const rel = node.attribs.rel;
|
||||||
const href = node.attrs.find(x => x.name === 'href');
|
const href = node.attribs.href;
|
||||||
|
|
||||||
// ハッシュタグ
|
// ハッシュタグ
|
||||||
if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) {
|
if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) {
|
||||||
text += txt;
|
text += txt;
|
||||||
// メンション
|
// メンション
|
||||||
} else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) {
|
} else if (txt.startsWith('@') && !(rel && rel.startsWith('me '))) {
|
||||||
const part = txt.split('@');
|
const part = txt.split('@');
|
||||||
|
|
||||||
if (part.length === 2 && href) {
|
if (part.length === 2 && href) {
|
||||||
//#region ホスト名部分が省略されているので復元する
|
//#region ホスト名部分が省略されているので復元する
|
||||||
const acct = `${txt}@${(new URL(href.value)).hostname}`;
|
const acct = `${txt}@${(new URL(href)).hostname}`;
|
||||||
text += acct;
|
text += acct;
|
||||||
//#endregion
|
//#endregion
|
||||||
} else if (part.length === 3) {
|
} else if (part.length === 3) {
|
||||||
|
|
@ -116,25 +106,32 @@ export class MfmService {
|
||||||
if (!href) {
|
if (!href) {
|
||||||
return txt;
|
return txt;
|
||||||
}
|
}
|
||||||
if (!txt || txt === href.value) { // #6383: Missing text node
|
if (!txt || txt === href) { // #6383: Missing text node
|
||||||
if (href.value.match(urlRegexFull)) {
|
if (href.match(urlRegexFull)) {
|
||||||
return href.value;
|
return href;
|
||||||
} else {
|
} else {
|
||||||
return `<${href.value}>`;
|
return `<${href}>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) {
|
if (href.match(urlRegex) && !href.match(urlRegexFull)) {
|
||||||
return `[${txt}](<${href.value}>)`; // #6846
|
return `[${txt}](<${href}>)`; // #6846
|
||||||
} else {
|
} else {
|
||||||
return `[${txt}](${href.value})`;
|
return `[${txt}](${href})`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
text += generateLink();
|
text += generateLink();
|
||||||
}
|
}
|
||||||
break;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't produce invalid empty MFM
|
||||||
|
if (node.childNodes.length < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (node.tagName) {
|
||||||
case 'h1': {
|
case 'h1': {
|
||||||
text += '**【';
|
text += '**【';
|
||||||
appendChildren(node.childNodes);
|
appendChildren(node.childNodes);
|
||||||
|
|
@ -185,14 +182,17 @@ export class MfmService {
|
||||||
case 'ruby--': {
|
case 'ruby--': {
|
||||||
let ruby: [string, string][] = [];
|
let ruby: [string, string][] = [];
|
||||||
for (const child of node.childNodes) {
|
for (const child of node.childNodes) {
|
||||||
if (child.nodeName === 'rp') {
|
if (isText(child) && !/\s|\[|\]/.test(child.data)) {
|
||||||
|
ruby.push([child.data, '']);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (treeAdapter.isTextNode(child) && !/\s|\[|\]/.test(child.value)) {
|
if (!isTag(child)) {
|
||||||
ruby.push([child.value, '']);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (child.nodeName === 'rt' && ruby.length > 0) {
|
if (child.tagName === 'rp') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (child.tagName === 'rt' && ruby.length > 0) {
|
||||||
const rt = getText(child);
|
const rt = getText(child);
|
||||||
if (/\s|\[|\]/.test(rt)) {
|
if (/\s|\[|\]/.test(rt)) {
|
||||||
// If any space is included in rt, it is treated as a normal text
|
// If any space is included in rt, it is treated as a normal text
|
||||||
|
|
@ -217,7 +217,7 @@ export class MfmService {
|
||||||
|
|
||||||
// block code (<pre><code>)
|
// block code (<pre><code>)
|
||||||
case 'pre': {
|
case 'pre': {
|
||||||
if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
|
if (node.childNodes.length === 1 && isTag(node.childNodes[0]) && node.childNodes[0].tagName === 'code') {
|
||||||
text += '\n```\n';
|
text += '\n```\n';
|
||||||
text += getText(node.childNodes[0]);
|
text += getText(node.childNodes[0]);
|
||||||
text += '\n```\n';
|
text += '\n```\n';
|
||||||
|
|
@ -302,17 +302,17 @@ export class MfmService {
|
||||||
let nonRtNodes = [];
|
let nonRtNodes = [];
|
||||||
// scan children, ignore `rp`, split on `rt`
|
// scan children, ignore `rp`, split on `rt`
|
||||||
for (const child of node.childNodes) {
|
for (const child of node.childNodes) {
|
||||||
if (treeAdapter.isTextNode(child)) {
|
if (isText(child)) {
|
||||||
nonRtNodes.push(child);
|
nonRtNodes.push(child);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!treeAdapter.isElementNode(child)) {
|
if (!isTag(child)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (child.nodeName === 'rp') {
|
if (child.tagName === 'rp') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (child.nodeName === 'rt') {
|
if (child.tagName === 'rt') {
|
||||||
// the only case in which we don't need a `$[group ]`
|
// the only case in which we don't need a `$[group ]`
|
||||||
// is when both sides of the ruby are simple words
|
// is when both sides of the ruby are simple words
|
||||||
const needsGroup = nonRtNodes.length > 1 ||
|
const needsGroup = nonRtNodes.length > 1 ||
|
||||||
|
|
@ -350,45 +350,44 @@ export class MfmService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { happyDOM, window } = new Window();
|
const doc = new Document([]);
|
||||||
|
|
||||||
const doc = window.document;
|
const body = new Element('p', {});
|
||||||
|
doc.childNodes.push(body);
|
||||||
|
|
||||||
const body = doc.createElement('p');
|
function appendChildren(children: mfm.MfmNode[], targetElement: ParentNode): void {
|
||||||
|
for (const child of children.map(x => handle(x))) {
|
||||||
function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
|
targetElement.childNodes.push(child);
|
||||||
if (children) {
|
|
||||||
for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fnDefault(node: mfm.MfmFn) {
|
function fnDefault(node: mfm.MfmFn) {
|
||||||
const el = doc.createElement('i');
|
const el = new Element('i', {});
|
||||||
appendChildren(node.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any } = {
|
const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => ChildNode } = {
|
||||||
bold: (node) => {
|
bold: (node) => {
|
||||||
const el = doc.createElement('b');
|
const el = new Element('b', {});
|
||||||
appendChildren(node.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
small: (node) => {
|
small: (node) => {
|
||||||
const el = doc.createElement('small');
|
const el = new Element('small', {});
|
||||||
appendChildren(node.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
strike: (node) => {
|
strike: (node) => {
|
||||||
const el = doc.createElement('del');
|
const el = new Element('del', {});
|
||||||
appendChildren(node.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
italic: (node) => {
|
italic: (node) => {
|
||||||
const el = doc.createElement('i');
|
const el = new Element('i', {});
|
||||||
appendChildren(node.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
@ -399,11 +398,12 @@ export class MfmService {
|
||||||
const text = node.children[0].type === 'text' ? node.children[0].props.text : '';
|
const text = node.children[0].type === 'text' ? node.children[0].props.text : '';
|
||||||
try {
|
try {
|
||||||
const date = new Date(parseInt(text, 10) * 1000);
|
const date = new Date(parseInt(text, 10) * 1000);
|
||||||
const el = doc.createElement('time');
|
const el = new Element('time', {
|
||||||
el.setAttribute('datetime', date.toISOString());
|
datetime: date.toISOString(),
|
||||||
el.textContent = date.toISOString();
|
});
|
||||||
|
el.childNodes.push(new Text(date.toISOString()));
|
||||||
return el;
|
return el;
|
||||||
} catch (err) {
|
} catch {
|
||||||
return fnDefault(node);
|
return fnDefault(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -412,20 +412,20 @@ export class MfmService {
|
||||||
if (node.children.length === 1) {
|
if (node.children.length === 1) {
|
||||||
const child = node.children[0];
|
const child = node.children[0];
|
||||||
const text = child.type === 'text' ? child.props.text : '';
|
const text = child.type === 'text' ? child.props.text : '';
|
||||||
const rubyEl = doc.createElement('ruby');
|
const rubyEl = new Element('ruby', {});
|
||||||
const rtEl = doc.createElement('rt');
|
const rtEl = new Element('rt', {});
|
||||||
|
|
||||||
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
|
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
|
||||||
const rpStartEl = doc.createElement('rp');
|
const rpStartEl = new Element('rp', {});
|
||||||
rpStartEl.appendChild(doc.createTextNode('('));
|
rpStartEl.childNodes.push(new Text('('));
|
||||||
const rpEndEl = doc.createElement('rp');
|
const rpEndEl = new Element('rp', {});
|
||||||
rpEndEl.appendChild(doc.createTextNode(')'));
|
rpEndEl.childNodes.push(new Text(')'));
|
||||||
|
|
||||||
rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
|
rubyEl.childNodes.push(new Text(text.split(' ')[0]));
|
||||||
rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
|
rtEl.childNodes.push(new Text(text.split(' ')[1]));
|
||||||
rubyEl.appendChild(rpStartEl);
|
rubyEl.childNodes.push(rpStartEl);
|
||||||
rubyEl.appendChild(rtEl);
|
rubyEl.childNodes.push(rtEl);
|
||||||
rubyEl.appendChild(rpEndEl);
|
rubyEl.childNodes.push(rpEndEl);
|
||||||
return rubyEl;
|
return rubyEl;
|
||||||
} else {
|
} else {
|
||||||
const rt = node.children.at(-1);
|
const rt = node.children.at(-1);
|
||||||
|
|
@ -435,20 +435,20 @@ export class MfmService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = rt.type === 'text' ? rt.props.text : '';
|
const text = rt.type === 'text' ? rt.props.text : '';
|
||||||
const rubyEl = doc.createElement('ruby');
|
const rubyEl = new Element('ruby', {});
|
||||||
const rtEl = doc.createElement('rt');
|
const rtEl = new Element('rt', {});
|
||||||
|
|
||||||
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
|
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
|
||||||
const rpStartEl = doc.createElement('rp');
|
const rpStartEl = new Element('rp', {});
|
||||||
rpStartEl.appendChild(doc.createTextNode('('));
|
rpStartEl.childNodes.push(new Text('('));
|
||||||
const rpEndEl = doc.createElement('rp');
|
const rpEndEl = new Element('rp', {});
|
||||||
rpEndEl.appendChild(doc.createTextNode(')'));
|
rpEndEl.childNodes.push(new Text(')'));
|
||||||
|
|
||||||
appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
|
appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
|
||||||
rtEl.appendChild(doc.createTextNode(text.trim()));
|
rtEl.childNodes.push(new Text(text.trim()));
|
||||||
rubyEl.appendChild(rpStartEl);
|
rubyEl.childNodes.push(rpStartEl);
|
||||||
rubyEl.appendChild(rtEl);
|
rubyEl.childNodes.push(rtEl);
|
||||||
rubyEl.appendChild(rpEndEl);
|
rubyEl.childNodes.push(rpEndEl);
|
||||||
return rubyEl;
|
return rubyEl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -456,7 +456,7 @@ export class MfmService {
|
||||||
// hack for ruby, should never be needed because we should
|
// hack for ruby, should never be needed because we should
|
||||||
// never send this out to other instances
|
// never send this out to other instances
|
||||||
case 'group': {
|
case 'group': {
|
||||||
const el = doc.createElement('span');
|
const el = new Element('span', {});
|
||||||
appendChildren(node.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
@ -468,125 +468,135 @@ export class MfmService {
|
||||||
},
|
},
|
||||||
|
|
||||||
blockCode: (node) => {
|
blockCode: (node) => {
|
||||||
const pre = doc.createElement('pre');
|
const pre = new Element('pre', {});
|
||||||
const inner = doc.createElement('code');
|
const inner = new Element('code', {});
|
||||||
inner.textContent = node.props.code;
|
inner.childNodes.push(new Text(node.props.code));
|
||||||
pre.appendChild(inner);
|
pre.childNodes.push(inner);
|
||||||
return pre;
|
return pre;
|
||||||
},
|
},
|
||||||
|
|
||||||
center: (node) => {
|
center: (node) => {
|
||||||
const el = doc.createElement('div');
|
const el = new Element('div', {});
|
||||||
appendChildren(node.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
emojiCode: (node) => {
|
emojiCode: (node) => {
|
||||||
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
|
return new Text(`\u200B:${node.props.name}:\u200B`);
|
||||||
},
|
},
|
||||||
|
|
||||||
unicodeEmoji: (node) => {
|
unicodeEmoji: (node) => {
|
||||||
return doc.createTextNode(node.props.emoji);
|
return new Text(node.props.emoji);
|
||||||
},
|
},
|
||||||
|
|
||||||
hashtag: (node) => {
|
hashtag: (node) => {
|
||||||
const a = doc.createElement('a');
|
const a = new Element('a', {
|
||||||
a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
|
href: `${this.config.url}/tags/${node.props.hashtag}`,
|
||||||
a.textContent = `#${node.props.hashtag}`;
|
rel: 'tag',
|
||||||
a.setAttribute('rel', 'tag');
|
});
|
||||||
|
a.childNodes.push(new Text(`#${node.props.hashtag}`));
|
||||||
return a;
|
return a;
|
||||||
},
|
},
|
||||||
|
|
||||||
inlineCode: (node) => {
|
inlineCode: (node) => {
|
||||||
const el = doc.createElement('code');
|
const el = new Element('code', {});
|
||||||
el.textContent = node.props.code;
|
el.childNodes.push(new Text(node.props.code));
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
mathInline: (node) => {
|
mathInline: (node) => {
|
||||||
const el = doc.createElement('code');
|
const el = new Element('code', {});
|
||||||
el.textContent = node.props.formula;
|
el.childNodes.push(new Text(node.props.formula));
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
mathBlock: (node) => {
|
mathBlock: (node) => {
|
||||||
const el = doc.createElement('code');
|
const el = new Element('code', {});
|
||||||
el.textContent = node.props.formula;
|
el.childNodes.push(new Text(node.props.formula));
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
link: (node) => {
|
link: (node) => {
|
||||||
const a = doc.createElement('a');
|
const a = new Element('a', {
|
||||||
a.setAttribute('href', node.props.url);
|
href: node.props.url,
|
||||||
|
});
|
||||||
appendChildren(node.children, a);
|
appendChildren(node.children, a);
|
||||||
return a;
|
return a;
|
||||||
},
|
},
|
||||||
|
|
||||||
mention: (node) => {
|
mention: (node) => {
|
||||||
const a = doc.createElement('a');
|
|
||||||
const { username, host, acct } = node.props;
|
const { username, host, acct } = node.props;
|
||||||
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username.toLowerCase() === username.toLowerCase() && remoteUser.host?.toLowerCase() === host?.toLowerCase());
|
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username.toLowerCase() === username.toLowerCase() && remoteUser.host?.toLowerCase() === host?.toLowerCase());
|
||||||
a.setAttribute('href', remoteUserInfo
|
|
||||||
? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri)
|
const a = new Element('a', {
|
||||||
: `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`);
|
href: remoteUserInfo
|
||||||
a.className = 'u-url mention';
|
? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri)
|
||||||
a.textContent = acct;
|
: `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`,
|
||||||
|
class: 'u-url mention',
|
||||||
|
});
|
||||||
|
a.childNodes.push(new Text(acct));
|
||||||
return a;
|
return a;
|
||||||
},
|
},
|
||||||
|
|
||||||
quote: (node) => {
|
quote: (node) => {
|
||||||
const el = doc.createElement('blockquote');
|
const el = new Element('blockquote', {});
|
||||||
appendChildren(node.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
text: (node) => {
|
text: (node) => {
|
||||||
if (!node.props.text.match(/[\r\n]/)) {
|
if (!node.props.text.match(/[\r\n]/)) {
|
||||||
return doc.createTextNode(node.props.text);
|
return new Text(node.props.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
const el = doc.createElement('span');
|
const el = new Element('span', {});
|
||||||
const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
|
const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => new Text(x));
|
||||||
|
|
||||||
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
||||||
el.appendChild(x === 'br' ? doc.createElement('br') : x);
|
el.childNodes.push(x === 'br' ? new Element('br', {}) : x);
|
||||||
}
|
}
|
||||||
|
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
url: (node) => {
|
url: (node) => {
|
||||||
const a = doc.createElement('a');
|
const a = new Element('a', {
|
||||||
a.setAttribute('href', node.props.url);
|
href: node.props.url,
|
||||||
a.textContent = node.props.url;
|
});
|
||||||
|
a.childNodes.push(new Text(node.props.url));
|
||||||
return a;
|
return a;
|
||||||
},
|
},
|
||||||
|
|
||||||
search: (node) => {
|
search: (node) => {
|
||||||
const a = doc.createElement('a');
|
const a = new Element('a', {
|
||||||
a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
|
href: `https://www.google.com/search?q=${node.props.query}`,
|
||||||
a.textContent = node.props.content;
|
});
|
||||||
|
a.childNodes.push(new Text(node.props.content));
|
||||||
return a;
|
return a;
|
||||||
},
|
},
|
||||||
|
|
||||||
plain: (node) => {
|
plain: (node) => {
|
||||||
const el = doc.createElement('span');
|
const el = new Element('span', {});
|
||||||
appendChildren(node.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Utility function to make TypeScript behave
|
||||||
|
function handle<T extends mfm.MfmNode>(node: T): ChildNode {
|
||||||
|
const handler = handlers[node.type] as (node: T) => ChildNode;
|
||||||
|
return handler(node);
|
||||||
|
}
|
||||||
|
|
||||||
appendChildren(nodes, body);
|
appendChildren(nodes, body);
|
||||||
|
|
||||||
for (const additionalAppender of additionalAppenders) {
|
for (const additionalAppender of additionalAppenders) {
|
||||||
additionalAppender(doc, body);
|
additionalAppender(doc, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
const serialized = body.outerHTML;
|
return domserializer.render(body, {
|
||||||
|
encodeEntities: 'utf8'
|
||||||
happyDOM.close().catch(err => {});
|
});
|
||||||
|
|
||||||
return serialized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// the toMastoApiHtml function was taken from Iceshrimp and written by zotan and modified by marie to work with the current MK version
|
// the toMastoApiHtml function was taken from Iceshrimp and written by zotan and modified by marie to work with the current MK version
|
||||||
|
|
@ -598,55 +608,55 @@ export class MfmService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { happyDOM, window } = new Window();
|
const doc = new Document([]);
|
||||||
|
|
||||||
const doc = window.document;
|
const body = new Element('p', {});
|
||||||
|
doc.childNodes.push(body);
|
||||||
|
|
||||||
const body = doc.createElement('p');
|
function appendChildren(children: mfm.MfmNode[], targetElement: ParentNode): void {
|
||||||
|
for (const child of children) {
|
||||||
function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
|
const result = handle(child);
|
||||||
if (children) {
|
targetElement.childNodes.push(result);
|
||||||
for (const child of children.map((x) => (handlers as any)[x.type](x))) targetElement.appendChild(child);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlers: {
|
const handlers: {
|
||||||
[K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any;
|
[K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => ChildNode;
|
||||||
} = {
|
} = {
|
||||||
bold(node) {
|
bold(node) {
|
||||||
const el = doc.createElement('span');
|
const el = new Element('span', {});
|
||||||
el.textContent = '**';
|
el.childNodes.push(new Text('**'));
|
||||||
appendChildren(node.children, el);
|
appendChildren(node.children, el);
|
||||||
el.textContent += '**';
|
el.childNodes.push(new Text('**'));
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
small(node) {
|
small(node) {
|
||||||
const el = doc.createElement('small');
|
const el = new Element('small', {});
|
||||||
appendChildren(node.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
strike(node) {
|
strike(node) {
|
||||||
const el = doc.createElement('span');
|
const el = new Element('span', {});
|
||||||
el.textContent = '~~';
|
el.childNodes.push(new Text('~~'));
|
||||||
appendChildren(node.children, el);
|
appendChildren(node.children, el);
|
||||||
el.textContent += '~~';
|
el.childNodes.push(new Text('~~'));
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
italic(node) {
|
italic(node) {
|
||||||
const el = doc.createElement('span');
|
const el = new Element('span', {});
|
||||||
el.textContent = '*';
|
el.childNodes.push(new Text('*'));
|
||||||
appendChildren(node.children, el);
|
appendChildren(node.children, el);
|
||||||
el.textContent += '*';
|
el.childNodes.push(new Text('*'));
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
fn(node) {
|
fn(node) {
|
||||||
switch (node.props.name) {
|
switch (node.props.name) {
|
||||||
case 'group': { // hack for ruby
|
case 'group': { // hack for ruby
|
||||||
const el = doc.createElement('span');
|
const el = new Element('span', {});
|
||||||
appendChildren(node.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
@ -654,119 +664,121 @@ export class MfmService {
|
||||||
if (node.children.length === 1) {
|
if (node.children.length === 1) {
|
||||||
const child = node.children[0];
|
const child = node.children[0];
|
||||||
const text = child.type === 'text' ? child.props.text : '';
|
const text = child.type === 'text' ? child.props.text : '';
|
||||||
const rubyEl = doc.createElement('ruby');
|
const rubyEl = new Element('ruby', {});
|
||||||
const rtEl = doc.createElement('rt');
|
const rtEl = new Element('rt', {});
|
||||||
|
|
||||||
const rpStartEl = doc.createElement('rp');
|
const rpStartEl = new Element('rp', {});
|
||||||
rpStartEl.appendChild(doc.createTextNode('('));
|
rpStartEl.childNodes.push(new Text('('));
|
||||||
const rpEndEl = doc.createElement('rp');
|
const rpEndEl = new Element('rp', {});
|
||||||
rpEndEl.appendChild(doc.createTextNode(')'));
|
rpEndEl.childNodes.push(new Text(')'));
|
||||||
|
|
||||||
rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
|
rubyEl.childNodes.push(new Text(text.split(' ')[0]));
|
||||||
rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
|
rtEl.childNodes.push(new Text(text.split(' ')[1]));
|
||||||
rubyEl.appendChild(rpStartEl);
|
rubyEl.childNodes.push(rpStartEl);
|
||||||
rubyEl.appendChild(rtEl);
|
rubyEl.childNodes.push(rtEl);
|
||||||
rubyEl.appendChild(rpEndEl);
|
rubyEl.childNodes.push(rpEndEl);
|
||||||
return rubyEl;
|
return rubyEl;
|
||||||
} else {
|
} else {
|
||||||
const rt = node.children.at(-1);
|
const rt = node.children.at(-1);
|
||||||
|
|
||||||
if (!rt) {
|
if (!rt) {
|
||||||
const el = doc.createElement('span');
|
const el = new Element('span', {});
|
||||||
appendChildren(node.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = rt.type === 'text' ? rt.props.text : '';
|
const text = rt.type === 'text' ? rt.props.text : '';
|
||||||
const rubyEl = doc.createElement('ruby');
|
const rubyEl = new Element('ruby', {});
|
||||||
const rtEl = doc.createElement('rt');
|
const rtEl = new Element('rt', {});
|
||||||
|
|
||||||
const rpStartEl = doc.createElement('rp');
|
const rpStartEl = new Element('rp', {});
|
||||||
rpStartEl.appendChild(doc.createTextNode('('));
|
rpStartEl.childNodes.push(new Text('('));
|
||||||
const rpEndEl = doc.createElement('rp');
|
const rpEndEl = new Element('rp', {});
|
||||||
rpEndEl.appendChild(doc.createTextNode(')'));
|
rpEndEl.childNodes.push(new Text(')'));
|
||||||
|
|
||||||
appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
|
appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
|
||||||
rtEl.appendChild(doc.createTextNode(text.trim()));
|
rtEl.childNodes.push(new Text(text.trim()));
|
||||||
rubyEl.appendChild(rpStartEl);
|
rubyEl.childNodes.push(rpStartEl);
|
||||||
rubyEl.appendChild(rtEl);
|
rubyEl.childNodes.push(rtEl);
|
||||||
rubyEl.appendChild(rpEndEl);
|
rubyEl.childNodes.push(rpEndEl);
|
||||||
return rubyEl;
|
return rubyEl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
const el = doc.createElement('span');
|
const el = new Element('span', {});
|
||||||
el.textContent = '*';
|
el.childNodes.push(new Text('*'));
|
||||||
appendChildren(node.children, el);
|
appendChildren(node.children, el);
|
||||||
el.textContent += '*';
|
el.childNodes.push(new Text('*'));
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
blockCode(node) {
|
blockCode(node) {
|
||||||
const pre = doc.createElement('pre');
|
const pre = new Element('pre', {});
|
||||||
const inner = doc.createElement('code');
|
const inner = new Element('code', {});
|
||||||
|
|
||||||
const nodes = node.props.code
|
const nodes = node.props.code
|
||||||
.split(/\r\n|\r|\n/)
|
.split(/\r\n|\r|\n/)
|
||||||
.map((x) => doc.createTextNode(x));
|
.map((x) => new Text(x));
|
||||||
|
|
||||||
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
||||||
inner.appendChild(x === 'br' ? doc.createElement('br') : x);
|
inner.childNodes.push(x === 'br' ? new Element('br', {}) : x);
|
||||||
}
|
}
|
||||||
|
|
||||||
pre.appendChild(inner);
|
pre.childNodes.push(inner);
|
||||||
return pre;
|
return pre;
|
||||||
},
|
},
|
||||||
|
|
||||||
center(node) {
|
center(node) {
|
||||||
const el = doc.createElement('div');
|
const el = new Element('div', {});
|
||||||
appendChildren(node.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
emojiCode(node) {
|
emojiCode(node) {
|
||||||
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
|
return new Text(`\u200B:${node.props.name}:\u200B`);
|
||||||
},
|
},
|
||||||
|
|
||||||
unicodeEmoji(node) {
|
unicodeEmoji(node) {
|
||||||
return doc.createTextNode(node.props.emoji);
|
return new Text(node.props.emoji);
|
||||||
},
|
},
|
||||||
|
|
||||||
hashtag: (node) => {
|
hashtag: (node) => {
|
||||||
const a = doc.createElement('a');
|
const a = new Element('a', {
|
||||||
a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
|
href: `${this.config.url}/tags/${node.props.hashtag}`,
|
||||||
a.textContent = `#${node.props.hashtag}`;
|
rel: 'tag',
|
||||||
a.setAttribute('rel', 'tag');
|
class: 'hashtag',
|
||||||
a.setAttribute('class', 'hashtag');
|
});
|
||||||
|
a.childNodes.push(new Text(`#${node.props.hashtag}`));
|
||||||
return a;
|
return a;
|
||||||
},
|
},
|
||||||
|
|
||||||
inlineCode(node) {
|
inlineCode(node) {
|
||||||
const el = doc.createElement('code');
|
const el = new Element('code', {});
|
||||||
el.textContent = node.props.code;
|
el.childNodes.push(new Text(node.props.code));
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
mathInline(node) {
|
mathInline(node) {
|
||||||
const el = doc.createElement('code');
|
const el = new Element('code', {});
|
||||||
el.textContent = node.props.formula;
|
el.childNodes.push(new Text(node.props.formula));
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
mathBlock(node) {
|
mathBlock(node) {
|
||||||
const el = doc.createElement('code');
|
const el = new Element('code', {});
|
||||||
el.textContent = node.props.formula;
|
el.childNodes.push(new Text(node.props.formula));
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
link(node) {
|
link(node) {
|
||||||
const a = doc.createElement('a');
|
const a = new Element('a', {
|
||||||
a.setAttribute('rel', 'nofollow noopener noreferrer');
|
rel: 'nofollow noopener noreferrer',
|
||||||
a.setAttribute('target', '_blank');
|
target: '_blank',
|
||||||
a.setAttribute('href', node.props.url);
|
href: node.props.url,
|
||||||
|
});
|
||||||
appendChildren(node.children, a);
|
appendChildren(node.children, a);
|
||||||
return a;
|
return a;
|
||||||
},
|
},
|
||||||
|
|
@ -775,92 +787,107 @@ export class MfmService {
|
||||||
const { username, host, acct } = node.props;
|
const { username, host, acct } = node.props;
|
||||||
const resolved = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
|
const resolved = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
|
||||||
|
|
||||||
const el = doc.createElement('span');
|
const el = new Element('span', {});
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
el.textContent = acct;
|
el.childNodes.push(new Text(acct));
|
||||||
} else {
|
} else {
|
||||||
el.setAttribute('class', 'h-card');
|
el.attribs.class = 'h-card';
|
||||||
el.setAttribute('translate', 'no');
|
el.attribs.translate = 'no';
|
||||||
const a = doc.createElement('a');
|
const a = new Element('a', {
|
||||||
a.setAttribute('href', resolved.url ? resolved.url : resolved.uri);
|
href: resolved.url ? resolved.url : resolved.uri,
|
||||||
a.className = 'u-url mention';
|
class: 'u-url mention',
|
||||||
const span = doc.createElement('span');
|
});
|
||||||
span.textContent = resolved.username || username;
|
const span = new Element('span', {});
|
||||||
a.textContent = '@';
|
span.childNodes.push(new Text(resolved.username || username));
|
||||||
a.appendChild(span);
|
a.childNodes.push(new Text('@'));
|
||||||
el.appendChild(a);
|
a.childNodes.push(span);
|
||||||
|
el.childNodes.push(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
quote(node) {
|
quote(node) {
|
||||||
const el = doc.createElement('blockquote');
|
const el = new Element('blockquote', {});
|
||||||
appendChildren(node.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
text(node) {
|
text(node) {
|
||||||
const el = doc.createElement('span');
|
if (!node.props.text.match(/[\r\n]/)) {
|
||||||
|
return new Text(node.props.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = new Element('span', {});
|
||||||
const nodes = node.props.text
|
const nodes = node.props.text
|
||||||
.split(/\r\n|\r|\n/)
|
.split(/\r\n|\r|\n/)
|
||||||
.map((x) => doc.createTextNode(x));
|
.map((x) => new Text(x));
|
||||||
|
|
||||||
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
||||||
el.appendChild(x === 'br' ? doc.createElement('br') : x);
|
el.childNodes.push(x === 'br' ? new Element('br', {}) : x);
|
||||||
}
|
}
|
||||||
|
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
|
|
||||||
url(node) {
|
url(node) {
|
||||||
const a = doc.createElement('a');
|
const a = new Element('a', {
|
||||||
a.setAttribute('rel', 'nofollow noopener noreferrer');
|
rel: 'nofollow noopener noreferrer',
|
||||||
a.setAttribute('target', '_blank');
|
target: '_blank',
|
||||||
a.setAttribute('href', node.props.url);
|
href: node.props.url,
|
||||||
a.textContent = node.props.url.replace(/^https?:\/\//, '');
|
});
|
||||||
|
a.childNodes.push(new Text(node.props.url.replace(/^https?:\/\//, '')));
|
||||||
return a;
|
return a;
|
||||||
},
|
},
|
||||||
|
|
||||||
search: (node) => {
|
search: (node) => {
|
||||||
const a = doc.createElement('a');
|
const a = new Element('a', {
|
||||||
a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
|
href: `https://www.google.com/search?q=${node.props.query}`,
|
||||||
a.textContent = node.props.content;
|
});
|
||||||
|
a.childNodes.push(new Text(node.props.content));
|
||||||
return a;
|
return a;
|
||||||
},
|
},
|
||||||
|
|
||||||
plain(node) {
|
plain(node) {
|
||||||
const el = doc.createElement('span');
|
const el = new Element('span', {});
|
||||||
appendChildren(node.children, el);
|
appendChildren(node.children, el);
|
||||||
return el;
|
return el;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Utility function to make TypeScript behave
|
||||||
|
function handle<T extends mfm.MfmNode>(node: T): ChildNode {
|
||||||
|
const handler = handlers[node.type] as (node: T) => ChildNode;
|
||||||
|
return handler(node);
|
||||||
|
}
|
||||||
|
|
||||||
appendChildren(nodes, body);
|
appendChildren(nodes, body);
|
||||||
|
|
||||||
if (quoteUri !== null) {
|
if (quoteUri !== null) {
|
||||||
const a = doc.createElement('a');
|
const a = new Element('a', {
|
||||||
a.setAttribute('href', quoteUri);
|
href: quoteUri,
|
||||||
a.textContent = quoteUri.replace(/^https?:\/\//, '');
|
});
|
||||||
|
a.childNodes.push(new Text(quoteUri.replace(/^https?:\/\//, '')));
|
||||||
|
|
||||||
const quote = doc.createElement('span');
|
const quote = new Element('span', {
|
||||||
quote.setAttribute('class', 'quote-inline');
|
class: 'quote-inline',
|
||||||
quote.appendChild(doc.createElement('br'));
|
});
|
||||||
quote.appendChild(doc.createElement('br'));
|
quote.childNodes.push(new Element('br', {}));
|
||||||
quote.innerHTML += 'RE: ';
|
quote.childNodes.push(new Element('br', {}));
|
||||||
quote.appendChild(a);
|
quote.childNodes.push(new Text('RE: '));
|
||||||
|
quote.childNodes.push(a);
|
||||||
|
|
||||||
body.appendChild(quote);
|
body.childNodes.push(quote);
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = body.outerHTML;
|
let result = domserializer.render(body, {
|
||||||
|
encodeEntities: 'utf8'
|
||||||
|
});
|
||||||
|
|
||||||
if (inline) {
|
if (inline) {
|
||||||
result = result.replace(/^<p>/, '').replace(/<\/p>$/, '');
|
result = result.replace(/^<p>/, '').replace(/<\/p>$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
happyDOM.close().catch(() => {});
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { setImmediate } from 'node:timers/promises';
|
import { setImmediate } from 'node:timers/promises';
|
||||||
import * as mfm from '@transfem-org/sfm-js';
|
import * as mfm from 'mfm-js';
|
||||||
import { In, DataSource, IsNull, LessThan } from 'typeorm';
|
import { In, DataSource, IsNull, LessThan } from 'typeorm';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
|
|
@ -296,7 +296,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
case 'followers':
|
case 'followers':
|
||||||
// 他人のfollowers noteはreject
|
// 他人のfollowers noteはreject
|
||||||
if (data.renote.userId !== user.id) {
|
if (data.renote.userId !== user.id) {
|
||||||
throw new Error('Renote target is not public or home');
|
throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is not public or home');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renote対象がfollowersならfollowersにする
|
// Renote対象がfollowersならfollowersにする
|
||||||
|
|
@ -304,7 +304,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
break;
|
break;
|
||||||
case 'specified':
|
case 'specified':
|
||||||
// specified / direct noteはreject
|
// specified / direct noteはreject
|
||||||
throw new Error('Renote target is not public or home');
|
throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is not public or home');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -317,7 +317,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
if (data.renote.userId !== user.id) {
|
if (data.renote.userId !== user.id) {
|
||||||
const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id);
|
const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id);
|
||||||
if (blocked) {
|
if (blocked) {
|
||||||
throw new Error('blocked');
|
throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is blocked');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -489,10 +489,10 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
|
|
||||||
// should really not happen, but better safe than sorry
|
// should really not happen, but better safe than sorry
|
||||||
if (data.reply?.id === insert.id) {
|
if (data.reply?.id === insert.id) {
|
||||||
throw new Error('A note can\'t reply to itself');
|
throw new IdentifiableError('ea93b7c2-3d6c-4e10-946b-00d50b1a75cb', 'A note can\'t reply to itself');
|
||||||
}
|
}
|
||||||
if (data.renote?.id === insert.id) {
|
if (data.renote?.id === insert.id) {
|
||||||
throw new Error('A note can\'t renote itself');
|
throw new IdentifiableError('ea93b7c2-3d6c-4e10-946b-00d50b1a75cb', 'A note can\'t renote itself');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.uri != null) insert.uri = data.uri;
|
if (data.uri != null) insert.uri = data.uri;
|
||||||
|
|
@ -549,8 +549,6 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(e);
|
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -608,11 +606,11 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.reply == null) {
|
if (data.reply == null) {
|
||||||
// TODO: キャッシュ
|
this.cacheService.userFollowersCache.fetch(user.id).then(async followingsMap => {
|
||||||
this.followingsRepository.findBy({
|
const followings = Array
|
||||||
followeeId: user.id,
|
.from(followingsMap.values())
|
||||||
notify: 'normal',
|
.filter(f => f.notify === 'normal');
|
||||||
}).then(async followings => {
|
|
||||||
if (note.visibility !== 'specified') {
|
if (note.visibility !== 'specified') {
|
||||||
const isPureRenote = this.isRenote(data) && !this.isQuote(data) ? true : false;
|
const isPureRenote = this.isRenote(data) && !this.isQuote(data) ? true : false;
|
||||||
for (const following of followings) {
|
for (const following of followings) {
|
||||||
|
|
@ -733,7 +731,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
//#region AP deliver
|
//#region AP deliver
|
||||||
if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
|
if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
|
||||||
trackTask(async () => {
|
trackTask(async () => {
|
||||||
const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user);
|
const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
|
||||||
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
|
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
|
||||||
|
|
||||||
// メンションされたリモートユーザーに配送
|
// メンションされたリモートユーザーに配送
|
||||||
|
|
@ -876,17 +874,6 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1);
|
this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
|
||||||
private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) {
|
|
||||||
if (data.localOnly) return null;
|
|
||||||
|
|
||||||
const content = this.isRenote(data) && !this.isQuote(data)
|
|
||||||
? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
|
|
||||||
: this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, user, false), note);
|
|
||||||
|
|
||||||
return this.apRendererService.addContext(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private index(note: MiNote) {
|
private index(note: MiNote) {
|
||||||
if (note.text == null && note.cw == null) return;
|
if (note.text == null && note.cw == null) return;
|
||||||
|
|
@ -950,14 +937,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
// TODO: キャッシュ?
|
// TODO: キャッシュ?
|
||||||
// eslint-disable-next-line prefer-const
|
// eslint-disable-next-line prefer-const
|
||||||
let [followings, userListMemberships] = await Promise.all([
|
let [followings, userListMemberships] = await Promise.all([
|
||||||
this.followingsRepository.find({
|
this.cacheService.getNonHibernatedFollowers(user.id),
|
||||||
where: {
|
|
||||||
followeeId: user.id,
|
|
||||||
followerHost: IsNull(),
|
|
||||||
isFollowerHibernated: false,
|
|
||||||
},
|
|
||||||
select: ['followerId', 'withReplies'],
|
|
||||||
}),
|
|
||||||
this.userListMembershipsRepository.find({
|
this.userListMembershipsRepository.find({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|
@ -973,6 +953,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
|
|
||||||
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
|
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
|
||||||
for (const following of followings) {
|
for (const following of followings) {
|
||||||
|
if (following.followerHost !== null) continue;
|
||||||
// 基本的にvisibleUserIdsには自身のidが含まれている前提であること
|
// 基本的にvisibleUserIdsには自身のidが含まれている前提であること
|
||||||
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;
|
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;
|
||||||
|
|
||||||
|
|
@ -1074,17 +1055,19 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hibernatedUsers.length > 0) {
|
if (hibernatedUsers.length > 0) {
|
||||||
this.usersRepository.update({
|
await Promise.all([
|
||||||
id: In(hibernatedUsers.map(x => x.id)),
|
this.usersRepository.update({
|
||||||
}, {
|
id: In(hibernatedUsers.map(x => x.id)),
|
||||||
isHibernated: true,
|
}, {
|
||||||
});
|
isHibernated: true,
|
||||||
|
}),
|
||||||
this.followingsRepository.update({
|
this.followingsRepository.update({
|
||||||
followerId: In(hibernatedUsers.map(x => x.id)),
|
followerId: In(hibernatedUsers.map(x => x.id)),
|
||||||
}, {
|
}, {
|
||||||
isFollowerHibernated: true,
|
isFollowerHibernated: true,
|
||||||
});
|
}),
|
||||||
|
this.cacheService.hibernatedUserCache.setMany(hibernatedUsers.map(x => [x.id, true])),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { setImmediate } from 'node:timers/promises';
|
import { setImmediate } from 'node:timers/promises';
|
||||||
import * as mfm from '@transfem-org/sfm-js';
|
import * as mfm from 'mfm-js';
|
||||||
import { DataSource, In, IsNull, LessThan } from 'typeorm';
|
import { DataSource, In, IsNull, LessThan } from 'typeorm';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
|
|
@ -309,7 +309,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
|
|
||||||
if (this.isRenote(data)) {
|
if (this.isRenote(data)) {
|
||||||
if (data.renote.id === oldnote.id) {
|
if (data.renote.id === oldnote.id) {
|
||||||
throw new UnrecoverableError(`edit failed for ${oldnote.id}: cannot renote itself`);
|
throw new IdentifiableError('ea93b7c2-3d6c-4e10-946b-00d50b1a75cb', `edit failed for ${oldnote.id}: cannot renote itself`);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (data.renote.visibility) {
|
switch (data.renote.visibility) {
|
||||||
|
|
@ -325,7 +325,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
case 'followers':
|
case 'followers':
|
||||||
// 他人のfollowers noteはreject
|
// 他人のfollowers noteはreject
|
||||||
if (data.renote.userId !== user.id) {
|
if (data.renote.userId !== user.id) {
|
||||||
throw new Error('Renote target is not public or home');
|
throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is not public or home');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renote対象がfollowersならfollowersにする
|
// Renote対象がfollowersならfollowersにする
|
||||||
|
|
@ -333,7 +333,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
break;
|
break;
|
||||||
case 'specified':
|
case 'specified':
|
||||||
// specified / direct noteはreject
|
// specified / direct noteはreject
|
||||||
throw new Error('Renote target is not public or home');
|
throw new IdentifiableError('b6352a84-e5cd-4b05-a26c-63437a6b98ba', 'Renote target is not public or home');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -675,7 +675,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
//#region AP deliver
|
//#region AP deliver
|
||||||
if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
|
if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
|
||||||
trackTask(async () => {
|
trackTask(async () => {
|
||||||
const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user);
|
const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
|
||||||
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
|
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
|
||||||
|
|
||||||
// メンションされたリモートユーザーに配送
|
// メンションされたリモートユーザーに配送
|
||||||
|
|
@ -770,17 +770,6 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
(note.files != null && note.files.length > 0);
|
(note.files != null && note.files.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
|
||||||
private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) {
|
|
||||||
if (data.localOnly) return null;
|
|
||||||
|
|
||||||
const content = this.isRenote(data) && !this.isQuote(data)
|
|
||||||
? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
|
|
||||||
: this.apRendererService.renderUpdate(await this.apRendererService.renderUpNote(note, user, false), user);
|
|
||||||
|
|
||||||
return this.apRendererService.addContext(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private index(note: MiNote) {
|
private index(note: MiNote) {
|
||||||
if (note.text == null && note.cw == null) return;
|
if (note.text == null && note.cw == null) return;
|
||||||
|
|
@ -833,14 +822,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
// TODO: キャッシュ?
|
// TODO: キャッシュ?
|
||||||
// eslint-disable-next-line prefer-const
|
// eslint-disable-next-line prefer-const
|
||||||
let [followings, userListMemberships] = await Promise.all([
|
let [followings, userListMemberships] = await Promise.all([
|
||||||
this.followingsRepository.find({
|
this.cacheService.getNonHibernatedFollowers(user.id),
|
||||||
where: {
|
|
||||||
followeeId: user.id,
|
|
||||||
followerHost: IsNull(),
|
|
||||||
isFollowerHibernated: false,
|
|
||||||
},
|
|
||||||
select: ['followerId', 'withReplies'],
|
|
||||||
}),
|
|
||||||
this.userListMembershipsRepository.find({
|
this.userListMembershipsRepository.find({
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|
@ -856,6 +838,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
|
|
||||||
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
|
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
|
||||||
for (const following of followings) {
|
for (const following of followings) {
|
||||||
|
if (following.followerHost !== null) continue;
|
||||||
// 基本的にvisibleUserIdsには自身のidが含まれている前提であること
|
// 基本的にvisibleUserIdsには自身のidが含まれている前提であること
|
||||||
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;
|
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;
|
||||||
|
|
||||||
|
|
@ -957,17 +940,19 @@ export class NoteEditService implements OnApplicationShutdown {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hibernatedUsers.length > 0) {
|
if (hibernatedUsers.length > 0) {
|
||||||
this.usersRepository.update({
|
await Promise.all([
|
||||||
id: In(hibernatedUsers.map(x => x.id)),
|
this.usersRepository.update({
|
||||||
}, {
|
id: In(hibernatedUsers.map(x => x.id)),
|
||||||
isHibernated: true,
|
}, {
|
||||||
});
|
isHibernated: true,
|
||||||
|
}),
|
||||||
this.followingsRepository.update({
|
this.followingsRepository.update({
|
||||||
followerId: In(hibernatedUsers.map(x => x.id)),
|
followerId: In(hibernatedUsers.map(x => x.id)),
|
||||||
}, {
|
}, {
|
||||||
isFollowerHibernated: true,
|
isFollowerHibernated: true,
|
||||||
});
|
}),
|
||||||
|
this.cacheService.hibernatedUserCache.setMany(hibernatedUsers.map(x => [x.id, true])),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ export class NotePiningService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (note == null) {
|
if (note == null) {
|
||||||
throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', 'No such note.');
|
throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', `Note ${noteId} does not exist`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.db.transaction(async tem => {
|
await this.db.transaction(async tem => {
|
||||||
|
|
@ -102,7 +102,7 @@ export class NotePiningService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (note == null) {
|
if (note == null) {
|
||||||
throw new IdentifiableError('b302d4cf-c050-400a-bbb3-be208681f40c', 'No such note.');
|
throw new IdentifiableError('b302d4cf-c050-400a-bbb3-be208681f40c', `Note ${noteId} does not exist`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.userNotePiningsRepository.delete({
|
this.userNotePiningsRepository.delete({
|
||||||
|
|
|
||||||
|
|
@ -113,27 +113,27 @@ export class NotificationService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recieveConfig?.type === 'following') {
|
if (recieveConfig?.type === 'following') {
|
||||||
const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId));
|
const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId));
|
||||||
if (!isFollowing) {
|
if (!isFollowing) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} else if (recieveConfig?.type === 'follower') {
|
} else if (recieveConfig?.type === 'follower') {
|
||||||
const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId));
|
const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId));
|
||||||
if (!isFollower) {
|
if (!isFollower) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} else if (recieveConfig?.type === 'mutualFollow') {
|
} else if (recieveConfig?.type === 'mutualFollow') {
|
||||||
const [isFollowing, isFollower] = await Promise.all([
|
const [isFollowing, isFollower] = await Promise.all([
|
||||||
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
|
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)),
|
||||||
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
|
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)),
|
||||||
]);
|
]);
|
||||||
if (!(isFollowing && isFollower)) {
|
if (!(isFollowing && isFollower)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} else if (recieveConfig?.type === 'followingOrFollower') {
|
} else if (recieveConfig?.type === 'followingOrFollower') {
|
||||||
const [isFollowing, isFollower] = await Promise.all([
|
const [isFollowing, isFollower] = await Promise.all([
|
||||||
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
|
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)),
|
||||||
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
|
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)),
|
||||||
]);
|
]);
|
||||||
if (!isFollowing && !isFollower) {
|
if (!isFollowing && !isFollower) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ import type { Packed } from '@/misc/json-schema.js';
|
||||||
import { getNoteSummary } from '@/misc/get-note-summary.js';
|
import { getNoteSummary } from '@/misc/get-note-summary.js';
|
||||||
import type { MiMeta, MiSwSubscription, SwSubscriptionsRepository } from '@/models/_.js';
|
import type { MiMeta, MiSwSubscription, SwSubscriptionsRepository } from '@/models/_.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { RedisKVCache } from '@/misc/cache.js';
|
import { QuantumKVCache } from '@/misc/QuantumKVCache.js';
|
||||||
|
import { InternalEventService } from '@/core/InternalEventService.js';
|
||||||
|
|
||||||
// Defined also packages/sw/types.ts#L13
|
// Defined also packages/sw/types.ts#L13
|
||||||
type PushNotificationsTypes = {
|
type PushNotificationsTypes = {
|
||||||
|
|
@ -48,7 +49,7 @@ function truncateBody<T extends keyof PushNotificationsTypes>(type: T, body: Pus
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PushNotificationService implements OnApplicationShutdown {
|
export class PushNotificationService implements OnApplicationShutdown {
|
||||||
private subscriptionsCache: RedisKVCache<MiSwSubscription[]>;
|
private subscriptionsCache: QuantumKVCache<MiSwSubscription[]>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.config)
|
@Inject(DI.config)
|
||||||
|
|
@ -62,13 +63,11 @@ export class PushNotificationService implements OnApplicationShutdown {
|
||||||
|
|
||||||
@Inject(DI.swSubscriptionsRepository)
|
@Inject(DI.swSubscriptionsRepository)
|
||||||
private swSubscriptionsRepository: SwSubscriptionsRepository,
|
private swSubscriptionsRepository: SwSubscriptionsRepository,
|
||||||
|
private readonly internalEventService: InternalEventService,
|
||||||
) {
|
) {
|
||||||
this.subscriptionsCache = new RedisKVCache<MiSwSubscription[]>(this.redisClient, 'userSwSubscriptions', {
|
this.subscriptionsCache = new QuantumKVCache<MiSwSubscription[]>(this.internalEventService, 'userSwSubscriptions', {
|
||||||
lifetime: 1000 * 60 * 60 * 1, // 1h
|
lifetime: 1000 * 60 * 60 * 1, // 1h
|
||||||
memoryCacheLifetime: 1000 * 60 * 3, // 3m
|
|
||||||
fetcher: (key) => this.swSubscriptionsRepository.findBy({ userId: key }),
|
fetcher: (key) => this.swSubscriptionsRepository.findBy({ userId: key }),
|
||||||
toRedisConverter: (value) => JSON.stringify(value),
|
|
||||||
fromRedisConverter: (value) => JSON.parse(value),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,8 +113,8 @@ export class PushNotificationService implements OnApplicationShutdown {
|
||||||
endpoint: subscription.endpoint,
|
endpoint: subscription.endpoint,
|
||||||
auth: subscription.auth,
|
auth: subscription.auth,
|
||||||
publickey: subscription.publickey,
|
publickey: subscription.publickey,
|
||||||
}).then(() => {
|
}).then(async () => {
|
||||||
this.refreshCache(userId);
|
await this.refreshCache(userId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -123,8 +122,8 @@ export class PushNotificationService implements OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public refreshCache(userId: string): void {
|
public async refreshCache(userId: string): Promise<void> {
|
||||||
this.subscriptionsCache.refresh(userId);
|
await this.subscriptionsCache.refresh(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,14 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Brackets, ObjectLiteral } from 'typeorm';
|
import { Brackets, Not, WhereExpressionBuilder } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { MiUser } from '@/models/User.js';
|
import type { MiUser } from '@/models/User.js';
|
||||||
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta } from '@/models/_.js';
|
import { MiInstance } from '@/models/Instance.js';
|
||||||
|
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta, InstancesRepository } from '@/models/_.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import type { SelectQueryBuilder } from 'typeorm';
|
import type { SelectQueryBuilder, ObjectLiteral } from 'typeorm';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QueryService {
|
export class QueryService {
|
||||||
|
|
@ -36,6 +37,9 @@ export class QueryService {
|
||||||
@Inject(DI.renoteMutingsRepository)
|
@Inject(DI.renoteMutingsRepository)
|
||||||
private renoteMutingsRepository: RenoteMutingsRepository,
|
private renoteMutingsRepository: RenoteMutingsRepository,
|
||||||
|
|
||||||
|
@Inject(DI.instancesRepository)
|
||||||
|
private readonly instancesRepository: InstancesRepository,
|
||||||
|
|
||||||
@Inject(DI.meta)
|
@Inject(DI.meta)
|
||||||
private meta: MiMeta,
|
private meta: MiMeta,
|
||||||
|
|
||||||
|
|
@ -72,218 +76,485 @@ export class QueryService {
|
||||||
|
|
||||||
// ここでいうBlockedは被Blockedの意
|
// ここでいうBlockedは被Blockedの意
|
||||||
@bindThis
|
@bindThis
|
||||||
public generateBlockedUserQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
public generateBlockedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||||
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
|
||||||
.select('blocking.blockerId')
|
|
||||||
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
|
|
||||||
|
|
||||||
// 投稿の作者にブロックされていない かつ
|
// 投稿の作者にブロックされていない かつ
|
||||||
// 投稿の返信先の作者にブロックされていない かつ
|
// 投稿の返信先の作者にブロックされていない かつ
|
||||||
// 投稿の引用元の作者にブロックされていない
|
// 投稿の引用元の作者にブロックされていない
|
||||||
q
|
return this
|
||||||
.andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`)
|
.andNotBlockingUser(q, 'note.userId', ':meId')
|
||||||
.andWhere(new Brackets(qb => {
|
.andWhere(new Brackets(qb => this
|
||||||
qb
|
.orNotBlockingUser(qb, 'note.replyUserId', ':meId')
|
||||||
.where('note.replyUserId IS NULL')
|
.orWhere('note.replyUserId IS NULL')))
|
||||||
.orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
|
.andWhere(new Brackets(qb => this
|
||||||
}))
|
.orNotBlockingUser(qb, 'note.renoteUserId', ':meId')
|
||||||
.andWhere(new Brackets(qb => {
|
.orWhere('note.renoteUserId IS NULL')))
|
||||||
qb
|
.setParameters({ meId: me.id });
|
||||||
.where('note.renoteUserId IS NULL')
|
|
||||||
.orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
|
|
||||||
}));
|
|
||||||
|
|
||||||
q.setParameters(blockingQuery.getParameters());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public generateBlockQueryForUsers(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
public generateBlockQueryForUsers<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||||
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
this.andNotBlockingUser(q, ':meId', 'user.id');
|
||||||
.select('blocking.blockeeId')
|
this.andNotBlockingUser(q, 'user.id', ':meId');
|
||||||
.where('blocking.blockerId = :blockerId', { blockerId: me.id });
|
return q.setParameters({ meId: me.id });
|
||||||
|
|
||||||
const blockedQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
|
||||||
.select('blocking.blockerId')
|
|
||||||
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
|
|
||||||
|
|
||||||
q.andWhere(`user.id NOT IN (${ blockingQuery.getQuery() })`);
|
|
||||||
q.setParameters(blockingQuery.getParameters());
|
|
||||||
|
|
||||||
q.andWhere(`user.id NOT IN (${ blockedQuery.getQuery() })`);
|
|
||||||
q.setParameters(blockedQuery.getParameters());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
public generateMutedNoteThreadQuery<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||||
const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
|
return this
|
||||||
.select('threadMuted.threadId')
|
.andNotMutingThread(q, ':meId', 'note.id')
|
||||||
.where('threadMuted.userId = :userId', { userId: me.id });
|
.andWhere(new Brackets(qb => this
|
||||||
|
.orNotMutingThread(qb, ':meId', 'note.threadId')
|
||||||
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
|
.orWhere('note.threadId IS NULL')))
|
||||||
q.andWhere(new Brackets(qb => {
|
.setParameters({ meId: me.id });
|
||||||
qb
|
|
||||||
.where('note.threadId IS NULL')
|
|
||||||
.orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
|
|
||||||
}));
|
|
||||||
|
|
||||||
q.setParameters(mutedQuery.getParameters());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public generateMutedUserQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void {
|
public generateMutedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
|
||||||
.select('muting.muteeId')
|
|
||||||
.where('muting.muterId = :muterId', { muterId: me.id });
|
|
||||||
|
|
||||||
if (exclude) {
|
|
||||||
mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
|
|
||||||
.select('user_profile.mutedInstances')
|
|
||||||
.where('user_profile.userId = :muterId', { muterId: me.id });
|
|
||||||
|
|
||||||
// 投稿の作者をミュートしていない かつ
|
// 投稿の作者をミュートしていない かつ
|
||||||
// 投稿の返信先の作者をミュートしていない かつ
|
// 投稿の返信先の作者をミュートしていない かつ
|
||||||
// 投稿の引用元の作者をミュートしていない
|
// 投稿の引用元の作者をミュートしていない
|
||||||
q
|
return this
|
||||||
.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`)
|
.andNotMutingUser(q, ':meId', 'note.userId', exclude)
|
||||||
.andWhere(new Brackets(qb => {
|
.andWhere(new Brackets(qb => this
|
||||||
qb
|
.orNotMutingUser(qb, ':meId', 'note.replyUserId', exclude)
|
||||||
.where('note.replyUserId IS NULL')
|
.orWhere('note.replyUserId IS NULL')))
|
||||||
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
|
.andWhere(new Brackets(qb => this
|
||||||
}))
|
.orNotMutingUser(qb, ':meId', 'note.renoteUserId', exclude)
|
||||||
.andWhere(new Brackets(qb => {
|
.orWhere('note.renoteUserId IS NULL')))
|
||||||
qb
|
// TODO exclude should also pass a host to skip these instances
|
||||||
.where('note.renoteUserId IS NULL')
|
|
||||||
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
|
|
||||||
}))
|
|
||||||
// mute instances
|
// mute instances
|
||||||
.andWhere(new Brackets(qb => {
|
.andWhere(new Brackets(qb => this
|
||||||
qb
|
.andNotMutingInstance(qb, ':meId', 'note.userHost')
|
||||||
.andWhere('note.userHost IS NULL')
|
.orWhere('note.userHost IS NULL')))
|
||||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
|
.andWhere(new Brackets(qb => this
|
||||||
}))
|
.orNotMutingInstance(qb, ':meId', 'note.replyUserHost')
|
||||||
.andWhere(new Brackets(qb => {
|
.orWhere('note.replyUserHost IS NULL')))
|
||||||
qb
|
.andWhere(new Brackets(qb => this
|
||||||
.where('note.replyUserHost IS NULL')
|
.orNotMutingInstance(qb, ':meId', 'note.renoteUserHost')
|
||||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
|
.orWhere('note.renoteUserHost IS NULL')))
|
||||||
}))
|
.setParameters({ meId: me.id });
|
||||||
.andWhere(new Brackets(qb => {
|
|
||||||
qb
|
|
||||||
.where('note.renoteUserHost IS NULL')
|
|
||||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
|
|
||||||
}));
|
|
||||||
|
|
||||||
q.setParameters(mutingQuery.getParameters());
|
|
||||||
q.setParameters(mutingInstanceQuery.getParameters());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public generateMutedUserQueryForUsers(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
public generateMutedUserQueryForUsers<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
return this
|
||||||
.select('muting.muteeId')
|
.andNotMutingUser(q, ':meId', 'user.id')
|
||||||
.where('muting.muterId = :muterId', { muterId: me.id });
|
.setParameters({ meId: me.id });
|
||||||
|
|
||||||
q.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`);
|
|
||||||
|
|
||||||
q.setParameters(mutingQuery.getParameters());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This intentionally skips isSuspended, isDeleted, makeNotesFollowersOnlyBefore, makeNotesHiddenBefore, and requireSigninToViewContents.
|
||||||
|
// NoteEntityService checks these automatically and calls hideNote() to hide them without breaking threads.
|
||||||
|
// For moderation purposes, you can set isSilenced to forcibly hide existing posts by a user.
|
||||||
@bindThis
|
@bindThis
|
||||||
public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void {
|
public generateVisibilityQuery<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null): SelectQueryBuilder<E> {
|
||||||
// This code must always be synchronized with the checks in Notes.isVisibleForMe.
|
// This code must always be synchronized with the checks in Notes.isVisibleForMe.
|
||||||
if (me == null) {
|
return q.andWhere(new Brackets(qb => {
|
||||||
q.andWhere(new Brackets(qb => {
|
// Public post
|
||||||
qb
|
qb.orWhere('note.visibility = \'public\'')
|
||||||
.where('note.visibility = \'public\'')
|
.orWhere('note.visibility = \'home\'');
|
||||||
.orWhere('note.visibility = \'home\'');
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
|
||||||
.select('following.followeeId')
|
|
||||||
.where('following.followerId = :meId');
|
|
||||||
|
|
||||||
q.andWhere(new Brackets(qb => {
|
if (me != null) {
|
||||||
qb
|
qb
|
||||||
// 公開投稿である
|
// My post
|
||||||
.where(new Brackets(qb => {
|
.orWhere(':meId = note.userId')
|
||||||
qb
|
// Visible to me
|
||||||
.where('note.visibility = \'public\'')
|
|
||||||
.orWhere('note.visibility = \'home\'');
|
|
||||||
}))
|
|
||||||
// または 自分自身
|
|
||||||
.orWhere('note.userId = :meId')
|
|
||||||
// または 自分宛て
|
|
||||||
.orWhere(':meIdAsList <@ note.visibleUserIds')
|
.orWhere(':meIdAsList <@ note.visibleUserIds')
|
||||||
.orWhere(new Brackets(qb => {
|
// Followers-only post
|
||||||
qb
|
.orWhere(new Brackets(qb => qb
|
||||||
// または フォロワー宛ての投稿であり、
|
.andWhere(new Brackets(qbb => this
|
||||||
.where('note.visibility = \'followers\'')
|
// Following author
|
||||||
.andWhere(new Brackets(qb => {
|
.orFollowingUser(qbb, ':meId', 'note.userId')
|
||||||
qb
|
// Mentions me
|
||||||
// 自分がフォロワーである
|
.orWhere(':meIdAsList <@ note.mentions')
|
||||||
.where(`note.userId IN (${ followingQuery.getQuery() })`)
|
// Reply to me
|
||||||
// または 自分の投稿へのリプライ
|
.orWhere(':meId = note.replyUserId')))
|
||||||
.orWhere('note.replyUserId = :meId')
|
.andWhere('note.visibility = \'followers\'')));
|
||||||
.orWhere(':meIdAsList <@ note.mentions');
|
|
||||||
}));
|
q.setParameters({ meId: me.id, meIdAsList: [me.id] });
|
||||||
}));
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public generateMutedUserRenotesQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me: { id: MiUser['id'] }): SelectQueryBuilder<E> {
|
||||||
|
return q
|
||||||
|
.andWhere(new Brackets(qb => this
|
||||||
|
.orNotMutingRenote(qb, ':meId', 'note.userId')
|
||||||
|
.orWhere('note.renoteId IS NULL')
|
||||||
|
.orWhere('note.text IS NOT NULL')
|
||||||
|
.orWhere('note.cw IS NOT NULL')
|
||||||
|
.orWhere('note.replyId IS NOT NULL')
|
||||||
|
.orWhere('note.hasPoll = true')
|
||||||
|
.orWhere('note.fileIds != \'{}\'')))
|
||||||
|
.setParameters({ meId: me.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public generateExcludedRenotesQueryForNotes<Q extends WhereExpressionBuilder>(q: Q): Q {
|
||||||
|
return this.andIsNotRenote(q, 'note');
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public generateBlockedHostQueryForNote<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, excludeAuthor?: boolean): SelectQueryBuilder<E> {
|
||||||
|
const checkFor = (key: 'user' | 'replyUser' | 'renoteUser') => this
|
||||||
|
.leftJoinInstance(q, `note.${key}Instance`, `${key}Instance`)
|
||||||
|
.andWhere(new Brackets(qb => {
|
||||||
|
qb
|
||||||
|
.orWhere(`"${key}Instance" IS NULL`) // local
|
||||||
|
.orWhere(`"${key}Instance"."isBlocked" = false`); // not blocked
|
||||||
|
|
||||||
|
if (excludeAuthor) {
|
||||||
|
qb.orWhere(`note.userId = note.${key}Id`); // author
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
q.setParameters({ meId: me.id, meIdAsList: [me.id] });
|
if (!excludeAuthor) {
|
||||||
|
checkFor('user');
|
||||||
}
|
}
|
||||||
|
checkFor('replyUser');
|
||||||
|
checkFor('renoteUser');
|
||||||
|
|
||||||
|
return q;
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
|
public generateSilencedUserQueryForNotes<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, me?: { id: MiUser['id'] } | null): SelectQueryBuilder<E> {
|
||||||
|
if (!me) {
|
||||||
|
return q.andWhere('user.isSilenced = false');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this
|
||||||
|
.leftJoinInstance(q, 'note.userInstance', 'userInstance')
|
||||||
|
.andWhere(new Brackets(qb => this
|
||||||
|
// case 1: we are following the user
|
||||||
|
.orFollowingUser(qb, ':meId', 'note.userId')
|
||||||
|
// case 2: user not silenced AND instance not silenced
|
||||||
|
.orWhere(new Brackets(qbb => qbb
|
||||||
|
.andWhere(new Brackets(qbbb => qbbb
|
||||||
|
.orWhere('"userInstance"."isSilenced" = false')
|
||||||
|
.orWhere('"userInstance" IS NULL')))
|
||||||
|
.andWhere('user.isSilenced = false')))))
|
||||||
|
.setParameters({ meId: me.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Left-joins an instance in to the query with a given alias and optional condition.
|
||||||
|
* These calls are de-duplicated - multiple uses of the same alias are skipped.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public leftJoinInstance<E extends ObjectLiteral>(q: SelectQueryBuilder<E>, relation: string | typeof MiInstance, alias: string, condition?: string): SelectQueryBuilder<E> {
|
||||||
|
// Skip if it's already joined, otherwise we'll get an error
|
||||||
|
if (!q.expressionMap.joinAttributes.some(j => j.alias.name === alias)) {
|
||||||
|
q.leftJoin(relation, alias, condition);
|
||||||
|
}
|
||||||
|
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds OR condition that noteProp (note ID) refers to a quote.
|
||||||
|
* The prop should be an expression, not a raw value.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public orIsQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||||
|
return this.addIsQuote(q, noteProp, 'orWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds AND condition that noteProp (note ID) refers to a quote.
|
||||||
|
* The prop should be an expression, not a raw value.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public andIsQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||||
|
return this.addIsQuote(q, noteProp, 'andWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
private addIsQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||||
|
return q[join](new Brackets(qb => qb
|
||||||
|
.andWhere(`${noteProp}.renoteId IS NOT NULL`)
|
||||||
|
.andWhere(new Brackets(qbb => qbb
|
||||||
|
.orWhere(`${noteProp}.text IS NOT NULL`)
|
||||||
|
.orWhere(`${noteProp}.cw IS NOT NULL`)
|
||||||
|
.orWhere(`${noteProp}.replyId IS NOT NULL`)
|
||||||
|
.orWhere(`${noteProp}.hasPoll = true`)
|
||||||
|
.orWhere(`${noteProp}.fileIds != '{}'`)))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds OR condition that noteProp (note ID) does not refer to a quote.
|
||||||
|
* The prop should be an expression, not a raw value.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public orIsNotQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||||
|
return this.addIsNotQuote(q, noteProp, 'orWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds AND condition that noteProp (note ID) does not refer to a quote.
|
||||||
|
* The prop should be an expression, not a raw value.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public andIsNotQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||||
|
return this.addIsNotQuote(q, noteProp, 'andWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
private addIsNotQuote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||||
|
return q[join](new Brackets(qb => qb
|
||||||
|
.orWhere(`${noteProp}.renoteId IS NULL`)
|
||||||
|
.orWhere(new Brackets(qb => qb
|
||||||
|
.andWhere(`${noteProp}.text IS NULL`)
|
||||||
|
.andWhere(`${noteProp}.cw IS NULL`)
|
||||||
|
.andWhere(`${noteProp}.replyId IS NULL`)
|
||||||
|
.andWhere(`${noteProp}.hasPoll = false`)
|
||||||
|
.andWhere(`${noteProp}.fileIds = '{}'`)))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds OR condition that noteProp (note ID) refers to a renote.
|
||||||
|
* The prop should be an expression, not a raw value.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public orIsRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||||
|
return this.addIsRenote(q, noteProp, 'orWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds AND condition that noteProp (note ID) refers to a renote.
|
||||||
|
* The prop should be an expression, not a raw value.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public andIsRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||||
|
return this.addIsRenote(q, noteProp, 'andWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
private addIsRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||||
|
return q[join](new Brackets(qb => qb
|
||||||
|
.andWhere(`${noteProp}.renoteId IS NOT NULL`)
|
||||||
|
.andWhere(`${noteProp}.text IS NULL`)
|
||||||
|
.andWhere(`${noteProp}.cw IS NULL`)
|
||||||
|
.andWhere(`${noteProp}.replyId IS NULL`)
|
||||||
|
.andWhere(`${noteProp}.hasPoll = false`)
|
||||||
|
.andWhere(`${noteProp}.fileIds = '{}'`)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds OR condition that noteProp (note ID) does not refer to a renote.
|
||||||
|
* The prop should be an expression, not a raw value.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public orIsNotRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||||
|
return this.addIsNotRenote(q, noteProp, 'orWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds AND condition that noteProp (note ID) does not refer to a renote.
|
||||||
|
* The prop should be an expression, not a raw value.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public andIsNotRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string): Q {
|
||||||
|
return this.addIsNotRenote(q, noteProp, 'andWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
private addIsNotRenote<Q extends WhereExpressionBuilder>(q: Q, noteProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||||
|
return q[join](new Brackets(qb => qb
|
||||||
|
.orWhere(`${noteProp}.renoteId IS NULL`)
|
||||||
|
.orWhere(`${noteProp}.text IS NOT NULL`)
|
||||||
|
.orWhere(`${noteProp}.cw IS NOT NULL`)
|
||||||
|
.orWhere(`${noteProp}.replyId IS NOT NULL`)
|
||||||
|
.orWhere(`${noteProp}.hasPoll = true`)
|
||||||
|
.orWhere(`${noteProp}.fileIds != '{}'`)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds OR condition that followerProp (user ID) is following followeeProp (user ID).
|
||||||
|
* Both props should be expressions, not raw values.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public orFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
|
||||||
|
return this.addFollowingUser(q, followerProp, followeeProp, 'orWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds AND condition that followerProp (user ID) is following followeeProp (user ID).
|
||||||
|
* Both props should be expressions, not raw values.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public andFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
|
||||||
|
return this.addFollowingUser(q, followerProp, followeeProp, 'andWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
private addFollowingUser<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||||
|
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||||
|
.select('1')
|
||||||
|
.andWhere(`following.followerId = ${followerProp}`)
|
||||||
|
.andWhere(`following.followeeId = ${followeeProp}`);
|
||||||
|
|
||||||
|
return q[join](`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters());
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds OR condition that followerProp (user ID) is following followeeProp (channel ID).
|
||||||
|
* Both props should be expressions, not raw values.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public orFollowingChannel<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
|
||||||
|
return this.addFollowingChannel(q, followerProp, followeeProp, 'orWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds AND condition that followerProp (user ID) is following followeeProp (channel ID).
|
||||||
|
* Both props should be expressions, not raw values.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public andFollowingChannel<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string): Q {
|
||||||
|
return this.addFollowingChannel(q, followerProp, followeeProp, 'andWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
private addFollowingChannel<Q extends WhereExpressionBuilder>(q: Q, followerProp: string, followeeProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||||
|
const followingQuery = this.channelFollowingsRepository.createQueryBuilder('following')
|
||||||
|
.select('1')
|
||||||
|
.andWhere(`following.followerId = ${followerProp}`)
|
||||||
|
.andWhere(`following.followeeId = ${followeeProp}`);
|
||||||
|
|
||||||
|
return q[join](`EXISTS (${followingQuery.getQuery()})`, followingQuery.getParameters());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds OR condition that blockerProp (user ID) is not blocking blockeeProp (user ID).
|
||||||
|
* Both props should be expressions, not raw values.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public orNotBlockingUser<Q extends WhereExpressionBuilder>(q: Q, blockerProp: string, blockeeProp: string): Q {
|
||||||
|
return this.excludeBlockingUser(q, blockerProp, blockeeProp, 'orWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds AND condition that blockerProp (user ID) is not blocking blockeeProp (user ID).
|
||||||
|
* Both props should be expressions, not raw values.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public andNotBlockingUser<Q extends WhereExpressionBuilder>(q: Q, blockerProp: string, blockeeProp: string): Q {
|
||||||
|
return this.excludeBlockingUser(q, blockerProp, blockeeProp, 'andWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
private excludeBlockingUser<Q extends WhereExpressionBuilder>(q: Q, blockerProp: string, blockeeProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||||
|
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
||||||
|
.select('1')
|
||||||
|
.andWhere(`blocking.blockerId = ${blockerProp}`)
|
||||||
|
.andWhere(`blocking.blockeeId = ${blockeeProp}`);
|
||||||
|
|
||||||
|
return q[join](`NOT EXISTS (${blockingQuery.getQuery()})`, blockingQuery.getParameters());
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds OR condition that muterProp (user ID) is not muting muteeProp (user ID).
|
||||||
|
* Both props should be expressions, not raw values.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public orNotMutingUser<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q {
|
||||||
|
return this.excludeMutingUser(q, muterProp, muteeProp, 'orWhere', exclude);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds AND condition that muterProp (user ID) is not muting muteeProp (user ID).
|
||||||
|
* Both props should be expressions, not raw values.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public andNotMutingUser<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, exclude?: { id: MiUser['id'] }): Q {
|
||||||
|
return this.excludeMutingUser(q, muterProp, muteeProp, 'andWhere', exclude);
|
||||||
|
}
|
||||||
|
|
||||||
|
private excludeMutingUser<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere', exclude?: { id: MiUser['id'] }): Q {
|
||||||
|
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
||||||
|
.select('1')
|
||||||
|
.andWhere(`muting.muterId = ${muterProp}`)
|
||||||
|
.andWhere(`muting.muteeId = ${muteeProp}`);
|
||||||
|
|
||||||
|
if (exclude) {
|
||||||
|
mutingQuery.andWhere({ muteeId: Not(exclude.id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return q[join](`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds OR condition that muterProp (user ID) is not muting renotes by muteeProp (user ID).
|
||||||
|
* Both props should be expressions, not raw values.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public orNotMutingRenote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
|
||||||
|
return this.excludeMutingRenote(q, muterProp, muteeProp, 'orWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds AND condition that muterProp (user ID) is not muting renotes by muteeProp (user ID).
|
||||||
|
* Both props should be expressions, not raw values.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public andNotMutingRenote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
|
||||||
|
return this.excludeMutingRenote(q, muterProp, muteeProp, 'andWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
private excludeMutingRenote<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||||
const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting')
|
const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting')
|
||||||
.select('renote_muting.muteeId')
|
.select('1')
|
||||||
.where('renote_muting.muterId = :muterId', { muterId: me.id });
|
.andWhere(`renote_muting.muterId = ${muterProp}`)
|
||||||
|
.andWhere(`renote_muting.muteeId = ${muteeProp}`);
|
||||||
|
|
||||||
q.andWhere(new Brackets(qb => {
|
return q[join](`NOT EXISTS (${mutingQuery.getQuery()})`, mutingQuery.getParameters());
|
||||||
qb
|
};
|
||||||
.where(new Brackets(qb => {
|
|
||||||
qb.where('note.renoteId IS NOT NULL');
|
|
||||||
qb.andWhere('note.text IS NULL');
|
|
||||||
qb.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`);
|
|
||||||
}))
|
|
||||||
.orWhere('note.renoteId IS NULL')
|
|
||||||
.orWhere('note.text IS NOT NULL');
|
|
||||||
}));
|
|
||||||
|
|
||||||
q.setParameters(mutingQuery.getParameters());
|
/**
|
||||||
|
* Adds OR condition that muterProp (user ID) is not muting muteeProp (instance host).
|
||||||
|
* Both props should be expressions, not raw values.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public orNotMutingInstance<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
|
||||||
|
return this.excludeMutingInstance(q, muterProp, muteeProp, 'orWhere');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds AND condition that muterProp (user ID) is not muting muteeProp (instance host).
|
||||||
|
* Both props should be expressions, not raw values.
|
||||||
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public generateBlockedHostQueryForNote(q: SelectQueryBuilder<any>, excludeAuthor?: boolean): void {
|
public andNotMutingInstance<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
|
||||||
let nonBlockedHostQuery: (part: string) => string;
|
return this.excludeMutingInstance(q, muterProp, muteeProp, 'andWhere');
|
||||||
if (this.meta.blockedHosts.length === 0) {
|
}
|
||||||
nonBlockedHostQuery = () => '1=1';
|
|
||||||
} else {
|
|
||||||
nonBlockedHostQuery = (match: string) => `('.' || ${match}) NOT ILIKE ALL(select '%.' || x from (select unnest("blockedHosts") as x from "meta") t)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (excludeAuthor) {
|
private excludeMutingInstance<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||||
const instanceSuspension = (user: string) => new Brackets(qb => qb
|
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
|
||||||
.where(`note.${user}Id IS NULL`) // no corresponding user
|
.select('1')
|
||||||
.orWhere(`note.userId = note.${user}Id`)
|
.andWhere(`user_profile.userId = ${muterProp}`)
|
||||||
.orWhere(`note.${user}Host IS NULL`) // local
|
.andWhere(`"user_profile"."mutedInstances"::jsonb ? ${muteeProp}`);
|
||||||
.orWhere(nonBlockedHostQuery(`note.${user}Host`)));
|
|
||||||
|
|
||||||
q
|
return q[join](`NOT EXISTS (${mutingInstanceQuery.getQuery()})`, mutingInstanceQuery.getParameters());
|
||||||
.andWhere(instanceSuspension('replyUser'))
|
}
|
||||||
.andWhere(instanceSuspension('renoteUser'));
|
|
||||||
} else {
|
|
||||||
const instanceSuspension = (user: string) => new Brackets(qb => qb
|
|
||||||
.where(`note.${user}Id IS NULL`) // no corresponding user
|
|
||||||
.orWhere(`note.${user}Host IS NULL`) // local
|
|
||||||
.orWhere(nonBlockedHostQuery(`note.${user}Host`)));
|
|
||||||
|
|
||||||
q
|
/**
|
||||||
.andWhere(instanceSuspension('user'))
|
* Adds OR condition that muterProp (user ID) is not muting muteeProp (note ID).
|
||||||
.andWhere(instanceSuspension('replyUser'))
|
* Both props should be expressions, not raw values.
|
||||||
.andWhere(instanceSuspension('renoteUser'));
|
*/
|
||||||
}
|
@bindThis
|
||||||
|
public orNotMutingThread<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
|
||||||
|
return this.excludeMutingThread(q, muterProp, muteeProp, 'orWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds AND condition that muterProp (user ID) is not muting muteeProp (note ID).
|
||||||
|
* Both props should be expressions, not raw values.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public andNotMutingThread<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string): Q {
|
||||||
|
return this.excludeMutingThread(q, muterProp, muteeProp, 'andWhere');
|
||||||
|
}
|
||||||
|
|
||||||
|
private excludeMutingThread<Q extends WhereExpressionBuilder>(q: Q, muterProp: string, muteeProp: string, join: 'andWhere' | 'orWhere'): Q {
|
||||||
|
const threadMutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
|
||||||
|
.select('1')
|
||||||
|
.andWhere(`threadMuted.userId = ${muterProp}`)
|
||||||
|
.andWhere(`threadMuted.threadId = ${muteeProp}`);
|
||||||
|
|
||||||
|
return q[join](`NOT EXISTS (${threadMutedQuery.getQuery()})`, threadMutedQuery.getParameters());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import type { MiRemoteUser, MiUser } from '@/models/User.js';
|
import type { MiRemoteUser, MiUser } from '@/models/User.js';
|
||||||
import type { MiNote } from '@/models/Note.js';
|
import type { MiNote } from '@/models/Note.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import type { MiNoteReaction } from '@/models/NoteReaction.js';
|
import { MiNoteReaction } from '@/models/NoteReaction.js';
|
||||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { NotificationService } from '@/core/NotificationService.js';
|
import { NotificationService } from '@/core/NotificationService.js';
|
||||||
|
|
@ -31,6 +31,7 @@ import { isQuote, isRenote } from '@/misc/is-renote.js';
|
||||||
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
|
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
|
||||||
import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js';
|
import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js';
|
||||||
import { CacheService } from '@/core/CacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
import type { DataSource } from 'typeorm';
|
||||||
|
|
||||||
const FALLBACK = '\u2764';
|
const FALLBACK = '\u2764';
|
||||||
|
|
||||||
|
|
@ -89,6 +90,9 @@ export class ReactionService {
|
||||||
@Inject(DI.emojisRepository)
|
@Inject(DI.emojisRepository)
|
||||||
private emojisRepository: EmojisRepository,
|
private emojisRepository: EmojisRepository,
|
||||||
|
|
||||||
|
@Inject(DI.db)
|
||||||
|
private readonly db: DataSource,
|
||||||
|
|
||||||
private utilityService: UtilityService,
|
private utilityService: UtilityService,
|
||||||
private customEmojiService: CustomEmojiService,
|
private customEmojiService: CustomEmojiService,
|
||||||
private roleService: RoleService,
|
private roleService: RoleService,
|
||||||
|
|
@ -113,12 +117,12 @@ export class ReactionService {
|
||||||
if (note.userId !== user.id) {
|
if (note.userId !== user.id) {
|
||||||
const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id);
|
const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id);
|
||||||
if (blocked) {
|
if (blocked) {
|
||||||
throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7');
|
throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7', 'Note not accessible for you.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// check visibility
|
// check visibility
|
||||||
if (!await this.noteEntityService.isVisibleForMe(note, user.id)) {
|
if (!await this.noteEntityService.isVisibleForMe(note, user.id, { me: user })) {
|
||||||
throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.');
|
throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,26 +180,28 @@ export class ReactionService {
|
||||||
reaction,
|
reaction,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
const result = await this.db.transaction(async tem => {
|
||||||
await this.noteReactionsRepository.insert(record);
|
await tem.createQueryBuilder(MiNoteReaction, 'noteReaction')
|
||||||
} catch (e) {
|
.insert()
|
||||||
if (isDuplicateKeyValueError(e)) {
|
.values(record)
|
||||||
const exists = await this.noteReactionsRepository.findOneByOrFail({
|
.orIgnore()
|
||||||
noteId: note.id,
|
.execute();
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (exists.reaction !== reaction) {
|
return await tem.createQueryBuilder(MiNoteReaction, 'noteReaction')
|
||||||
// 別のリアクションがすでにされていたら置き換える
|
.select()
|
||||||
await this.delete(user, note);
|
.where({ noteId: note.id, userId: user.id })
|
||||||
await this.noteReactionsRepository.insert(record);
|
.getOneOrFail();
|
||||||
} else {
|
});
|
||||||
// 同じリアクションがすでにされていたらエラー
|
|
||||||
throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298');
|
if (result.id !== record.id) {
|
||||||
}
|
// Conflict with the same ID => nothing to do.
|
||||||
} else {
|
if (result.reaction === record.reaction) {
|
||||||
throw e;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 別のリアクションがすでにされていたら置き換える
|
||||||
|
await this.delete(user, note);
|
||||||
|
await this.noteReactionsRepository.insert(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment reactions count
|
// Increment reactions count
|
||||||
|
|
@ -316,14 +322,14 @@ export class ReactionService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (exist == null) {
|
if (exist == null) {
|
||||||
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted');
|
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'reaction does not exist');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete reaction
|
// Delete reaction
|
||||||
const result = await this.noteReactionsRepository.delete(exist.id);
|
const result = await this.noteReactionsRepository.delete(exist.id);
|
||||||
|
|
||||||
if (result.affected !== 1) {
|
if (result.affected !== 1) {
|
||||||
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted');
|
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'reaction does not exist');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrement reactions count
|
// Decrement reactions count
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { URL } from 'node:url';
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { IsNull } from 'typeorm';
|
import { IsNull } from 'typeorm';
|
||||||
|
|
@ -18,6 +17,7 @@ import { RemoteLoggerService } from '@/core/RemoteLoggerService.js';
|
||||||
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
|
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
|
||||||
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
|
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RemoteUserResolveService {
|
export class RemoteUserResolveService {
|
||||||
|
|
@ -44,27 +44,13 @@ export class RemoteUserResolveService {
|
||||||
const usernameLower = username.toLowerCase();
|
const usernameLower = username.toLowerCase();
|
||||||
|
|
||||||
if (host == null) {
|
if (host == null) {
|
||||||
this.logger.info(`return local user: ${usernameLower}`);
|
return await this.usersRepository.findOneByOrFail({ usernameLower, host: IsNull() }) as MiLocalUser;
|
||||||
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
|
|
||||||
if (u == null) {
|
|
||||||
throw new Error('user not found');
|
|
||||||
} else {
|
|
||||||
return u;
|
|
||||||
}
|
|
||||||
}) as MiLocalUser;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
host = this.utilityService.toPuny(host);
|
host = this.utilityService.toPuny(host);
|
||||||
|
|
||||||
if (host === this.utilityService.toPuny(this.config.host)) {
|
if (host === this.utilityService.toPuny(this.config.host)) {
|
||||||
this.logger.info(`return local user: ${usernameLower}`);
|
return await this.usersRepository.findOneByOrFail({ usernameLower, host: IsNull() }) as MiLocalUser;
|
||||||
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
|
|
||||||
if (u == null) {
|
|
||||||
throw new Error('user not found');
|
|
||||||
} else {
|
|
||||||
return u;
|
|
||||||
}
|
|
||||||
}) as MiLocalUser;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.usersRepository.findOneBy({ usernameLower, host }) as MiRemoteUser | null;
|
const user = await this.usersRepository.findOneBy({ usernameLower, host }) as MiRemoteUser | null;
|
||||||
|
|
@ -82,7 +68,7 @@ export class RemoteUserResolveService {
|
||||||
.getUserFromApId(self.href)
|
.getUserFromApId(self.href)
|
||||||
.then((u) => {
|
.then((u) => {
|
||||||
if (u == null) {
|
if (u == null) {
|
||||||
throw new Error('local user not found');
|
throw new Error(`local user not found: ${self.href}`);
|
||||||
} else {
|
} else {
|
||||||
return u;
|
return u;
|
||||||
}
|
}
|
||||||
|
|
@ -90,7 +76,7 @@ export class RemoteUserResolveService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.succ(`return new remote user: ${chalk.magenta(acctLower)}`);
|
this.logger.info(`Fetching new remote user ${chalk.magenta(acctLower)} from ${self.href}`);
|
||||||
return await this.apPersonService.createPerson(self.href);
|
return await this.apPersonService.createPerson(self.href);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,18 +87,16 @@ export class RemoteUserResolveService {
|
||||||
lastFetchedAt: new Date(),
|
lastFetchedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.info(`try resync: ${acctLower}`);
|
|
||||||
const self = await this.resolveSelf(acctLower);
|
const self = await this.resolveSelf(acctLower);
|
||||||
|
|
||||||
if (user.uri !== self.href) {
|
if (user.uri !== self.href) {
|
||||||
// if uri mismatch, Fix (user@host <=> AP's Person id(RemoteUser.uri)) mapping.
|
// if uri mismatch, Fix (user@host <=> AP's Person id(RemoteUser.uri)) mapping.
|
||||||
this.logger.info(`uri missmatch: ${acctLower}`);
|
this.logger.warn(`Detected URI mismatch for ${acctLower}`);
|
||||||
this.logger.info(`recovery missmatch uri for (username=${username}, host=${host}) from ${user.uri} to ${self.href}`);
|
|
||||||
|
|
||||||
// validate uri
|
// validate uri
|
||||||
const uri = new URL(self.href);
|
const uriHost = this.utilityService.extractDbHost(self.href);
|
||||||
if (uri.hostname !== host) {
|
if (uriHost !== host) {
|
||||||
throw new Error('Invalid uri');
|
throw new Error(`Failed to correct URI for ${acctLower}: new URI ${self.href} has different host from previous URI ${user.uri}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.usersRepository.update({
|
await this.usersRepository.update({
|
||||||
|
|
@ -121,37 +105,28 @@ export class RemoteUserResolveService {
|
||||||
}, {
|
}, {
|
||||||
uri: self.href,
|
uri: self.href,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
this.logger.info(`uri is fine: ${acctLower}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.logger.info(`Corrected URI for ${acctLower} from ${user.uri} to ${self.href}`);
|
||||||
|
|
||||||
await this.apPersonService.updatePerson(self.href);
|
await this.apPersonService.updatePerson(self.href);
|
||||||
|
|
||||||
this.logger.info(`return resynced remote user: ${acctLower}`);
|
return await this.usersRepository.findOneByOrFail({ uri: self.href }) as MiLocalUser | MiRemoteUser;
|
||||||
return await this.usersRepository.findOneBy({ uri: self.href }).then(u => {
|
|
||||||
if (u == null) {
|
|
||||||
throw new Error('user not found');
|
|
||||||
} else {
|
|
||||||
return u as MiLocalUser | MiRemoteUser;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.info(`return existing remote user: ${acctLower}`);
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async resolveSelf(acctLower: string): Promise<ILink> {
|
private async resolveSelf(acctLower: string): Promise<ILink> {
|
||||||
this.logger.info(`WebFinger for ${chalk.yellow(acctLower)}`);
|
|
||||||
const finger = await this.webfingerService.webfinger(acctLower).catch(err => {
|
const finger = await this.webfingerService.webfinger(acctLower).catch(err => {
|
||||||
this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ err.statusCode ?? err.message }`);
|
this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: ${renderInlineError(err)}`);
|
||||||
throw new Error(`Failed to WebFinger for ${acctLower}: ${ err.statusCode ?? err.message }`);
|
throw new Error(`Failed to WebFinger for ${acctLower}: error thrown`, { cause: err });
|
||||||
});
|
});
|
||||||
const self = finger.links.find(link => link.rel != null && link.rel.toLowerCase() === 'self');
|
const self = finger.links.find(link => link.rel != null && link.rel.toLowerCase() === 'self');
|
||||||
if (!self) {
|
if (!self) {
|
||||||
this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: self link not found`);
|
this.logger.error(`Failed to WebFinger for ${chalk.yellow(acctLower)}: self link not found`);
|
||||||
throw new Error('self link not found');
|
throw new Error(`Failed to WebFinger for ${acctLower}: self link not found`);
|
||||||
}
|
}
|
||||||
return self;
|
return self;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -587,6 +587,8 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
||||||
lastActiveDate: parsed.user1.lastActiveDate != null ? new Date(parsed.user1.lastActiveDate) : null,
|
lastActiveDate: parsed.user1.lastActiveDate != null ? new Date(parsed.user1.lastActiveDate) : null,
|
||||||
lastFetchedAt: parsed.user1.lastFetchedAt != null ? new Date(parsed.user1.lastFetchedAt) : null,
|
lastFetchedAt: parsed.user1.lastFetchedAt != null ? new Date(parsed.user1.lastFetchedAt) : null,
|
||||||
movedAt: parsed.user1.movedAt != null ? new Date(parsed.user1.movedAt) : null,
|
movedAt: parsed.user1.movedAt != null ? new Date(parsed.user1.movedAt) : null,
|
||||||
|
instance: null,
|
||||||
|
userProfile: null,
|
||||||
} : null,
|
} : null,
|
||||||
user2: parsed.user2 != null ? {
|
user2: parsed.user2 != null ? {
|
||||||
...parsed.user2,
|
...parsed.user2,
|
||||||
|
|
@ -597,6 +599,8 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
|
||||||
lastActiveDate: parsed.user2.lastActiveDate != null ? new Date(parsed.user2.lastActiveDate) : null,
|
lastActiveDate: parsed.user2.lastActiveDate != null ? new Date(parsed.user2.lastActiveDate) : null,
|
||||||
lastFetchedAt: parsed.user2.lastFetchedAt != null ? new Date(parsed.user2.lastFetchedAt) : null,
|
lastFetchedAt: parsed.user2.lastFetchedAt != null ? new Date(parsed.user2.lastFetchedAt) : null,
|
||||||
movedAt: parsed.user2.movedAt != null ? new Date(parsed.user2.movedAt) : null,
|
movedAt: parsed.user2.movedAt != null ? new Date(parsed.user2.movedAt) : null,
|
||||||
|
instance: null,
|
||||||
|
userProfile: null,
|
||||||
} : null,
|
} : null,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -86,10 +86,10 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
||||||
canManageCustomEmojis: false,
|
canManageCustomEmojis: false,
|
||||||
canManageAvatarDecorations: false,
|
canManageAvatarDecorations: false,
|
||||||
canSearchNotes: false,
|
canSearchNotes: false,
|
||||||
canUseTranslator: true,
|
canUseTranslator: false,
|
||||||
canHideAds: false,
|
canHideAds: false,
|
||||||
driveCapacityMb: 100,
|
driveCapacityMb: 100,
|
||||||
maxFileSizeMb: 10,
|
maxFileSizeMb: 25,
|
||||||
alwaysMarkNsfw: false,
|
alwaysMarkNsfw: false,
|
||||||
canUpdateBioMedia: true,
|
canUpdateBioMedia: true,
|
||||||
pinLimit: 5,
|
pinLimit: 5,
|
||||||
|
|
|
||||||
|
|
@ -77,8 +77,10 @@ export class UserBlockingService implements OnModuleInit {
|
||||||
|
|
||||||
await this.blockingsRepository.insert(blocking);
|
await this.blockingsRepository.insert(blocking);
|
||||||
|
|
||||||
this.cacheService.userBlockingCache.refresh(blocker.id);
|
await Promise.all([
|
||||||
this.cacheService.userBlockedCache.refresh(blockee.id);
|
this.cacheService.userBlockingCache.delete(blocker.id),
|
||||||
|
this.cacheService.userBlockedCache.delete(blockee.id),
|
||||||
|
]);
|
||||||
|
|
||||||
this.globalEventService.publishInternalEvent('blockingCreated', {
|
this.globalEventService.publishInternalEvent('blockingCreated', {
|
||||||
blockerId: blocker.id,
|
blockerId: blocker.id,
|
||||||
|
|
@ -168,8 +170,10 @@ export class UserBlockingService implements OnModuleInit {
|
||||||
|
|
||||||
await this.blockingsRepository.delete(blocking.id);
|
await this.blockingsRepository.delete(blocking.id);
|
||||||
|
|
||||||
this.cacheService.userBlockingCache.refresh(blocker.id);
|
await Promise.all([
|
||||||
this.cacheService.userBlockedCache.refresh(blockee.id);
|
this.cacheService.userBlockingCache.delete(blocker.id),
|
||||||
|
this.cacheService.userBlockedCache.delete(blockee.id),
|
||||||
|
]);
|
||||||
|
|
||||||
this.globalEventService.publishInternalEvent('blockingDeleted', {
|
this.globalEventService.publishInternalEvent('blockingDeleted', {
|
||||||
blockerId: blocker.id,
|
blockerId: blocker.id,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import { AccountMoveService } from '@/core/AccountMoveService.js';
|
||||||
import { UtilityService } from '@/core/UtilityService.js';
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import type { ThinUser } from '@/queue/types.js';
|
import type { ThinUser } from '@/queue/types.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.js';
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
|
import { InternalEventService } from '@/core/InternalEventService.js';
|
||||||
import type Logger from '../logger.js';
|
import type Logger from '../logger.js';
|
||||||
|
|
||||||
type Local = MiLocalUser | {
|
type Local = MiLocalUser | {
|
||||||
|
|
@ -86,6 +87,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
private accountMoveService: AccountMoveService,
|
private accountMoveService: AccountMoveService,
|
||||||
private perUserFollowingChart: PerUserFollowingChart,
|
private perUserFollowingChart: PerUserFollowingChart,
|
||||||
private instanceChart: InstanceChart,
|
private instanceChart: InstanceChart,
|
||||||
|
private readonly internalEventService: InternalEventService,
|
||||||
|
|
||||||
loggerService: LoggerService,
|
loggerService: LoggerService,
|
||||||
) {
|
) {
|
||||||
|
|
@ -145,12 +147,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
if (blocked) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked');
|
if (blocked) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await this.followingsRepository.exists({
|
if (await this.cacheService.isFollowing(follower, followee)) {
|
||||||
where: {
|
|
||||||
followerId: follower.id,
|
|
||||||
followeeId: followee.id,
|
|
||||||
},
|
|
||||||
})) {
|
|
||||||
// すでにフォロー関係が存在している場合
|
// すでにフォロー関係が存在している場合
|
||||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||||
// リモート → ローカル: acceptを送り返しておしまい
|
// リモート → ローカル: acceptを送り返しておしまい
|
||||||
|
|
@ -178,24 +175,14 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
let autoAccept = false;
|
let autoAccept = false;
|
||||||
|
|
||||||
// 鍵アカウントであっても、既にフォローされていた場合はスルー
|
// 鍵アカウントであっても、既にフォローされていた場合はスルー
|
||||||
const isFollowing = await this.followingsRepository.exists({
|
const isFollowing = await this.cacheService.isFollowing(follower, followee);
|
||||||
where: {
|
|
||||||
followerId: follower.id,
|
|
||||||
followeeId: followee.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (isFollowing) {
|
if (isFollowing) {
|
||||||
autoAccept = true;
|
autoAccept = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// フォローしているユーザーは自動承認オプション
|
// フォローしているユーザーは自動承認オプション
|
||||||
if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) {
|
if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) {
|
||||||
const isFollowed = await this.followingsRepository.exists({
|
const isFollowed = await this.cacheService.isFollowing(followee, follower); // intentionally reversed parameters
|
||||||
where: {
|
|
||||||
followerId: followee.id,
|
|
||||||
followeeId: follower.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isFollowed) autoAccept = true;
|
if (isFollowed) autoAccept = true;
|
||||||
}
|
}
|
||||||
|
|
@ -204,12 +191,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
if (followee.isLocked && !autoAccept) {
|
if (followee.isLocked && !autoAccept) {
|
||||||
autoAccept = !!(await this.accountMoveService.validateAlsoKnownAs(
|
autoAccept = !!(await this.accountMoveService.validateAlsoKnownAs(
|
||||||
follower,
|
follower,
|
||||||
(oldSrc, newSrc) => this.followingsRepository.exists({
|
(oldSrc, newSrc) => this.cacheService.isFollowing(newSrc, followee),
|
||||||
where: {
|
|
||||||
followeeId: followee.id,
|
|
||||||
followerId: newSrc.id,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
true,
|
true,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -264,7 +246,8 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.cacheService.userFollowingsCache.refresh(follower.id);
|
// Handled by CacheService
|
||||||
|
//this.cacheService.userFollowingsCache.refresh(follower.id);
|
||||||
|
|
||||||
const requestExist = await this.followRequestsRepository.exists({
|
const requestExist = await this.followRequestsRepository.exists({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -291,7 +274,7 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
}, followee.id);
|
}, followee.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.globalEventService.publishInternalEvent('follow', { followerId: follower.id, followeeId: followee.id });
|
await this.internalEventService.emit('follow', { followerId: follower.id, followeeId: followee.id });
|
||||||
|
|
||||||
const [followeeUser, followerUser] = await Promise.all([
|
const [followeeUser, followerUser] = await Promise.all([
|
||||||
this.usersRepository.findOneByOrFail({ id: followee.id }),
|
this.usersRepository.findOneByOrFail({ id: followee.id }),
|
||||||
|
|
@ -363,31 +346,29 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
},
|
},
|
||||||
silent = false,
|
silent = false,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const following = await this.followingsRepository.findOne({
|
const [
|
||||||
relations: {
|
followerUser,
|
||||||
follower: true,
|
followeeUser,
|
||||||
followee: true,
|
following,
|
||||||
},
|
] = await Promise.all([
|
||||||
where: {
|
this.cacheService.findUserById(follower.id),
|
||||||
followerId: follower.id,
|
this.cacheService.findUserById(followee.id),
|
||||||
followeeId: followee.id,
|
this.cacheService.userFollowingsCache.fetch(follower.id).then(fs => fs.get(followee.id)),
|
||||||
},
|
]);
|
||||||
});
|
|
||||||
|
|
||||||
if (following === null || !following.follower || !following.followee) {
|
if (following == null) {
|
||||||
this.logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした');
|
this.logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.followingsRepository.delete(following.id);
|
await this.followingsRepository.delete(following.id);
|
||||||
|
await this.internalEventService.emit('unfollow', { followerId: follower.id, followeeId: followee.id });
|
||||||
|
|
||||||
this.cacheService.userFollowingsCache.refresh(follower.id);
|
this.decrementFollowing(followerUser, followeeUser);
|
||||||
|
|
||||||
this.decrementFollowing(following.follower, following.followee);
|
|
||||||
|
|
||||||
if (!silent && this.userEntityService.isLocalUser(follower)) {
|
if (!silent && this.userEntityService.isLocalUser(follower)) {
|
||||||
// Publish unfollow event
|
// Publish unfollow event
|
||||||
this.userEntityService.pack(followee.id, follower, {
|
this.userEntityService.pack(followeeUser, follower, {
|
||||||
schema: 'UserDetailedNotMe',
|
schema: 'UserDetailedNotMe',
|
||||||
}).then(async packed => {
|
}).then(async packed => {
|
||||||
this.globalEventService.publishMainStream(follower.id, 'unfollow', packed);
|
this.globalEventService.publishMainStream(follower.id, 'unfollow', packed);
|
||||||
|
|
@ -412,8 +393,6 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
follower: MiUser,
|
follower: MiUser,
|
||||||
followee: MiUser,
|
followee: MiUser,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.globalEventService.publishInternalEvent('unfollow', { followerId: follower.id, followeeId: followee.id });
|
|
||||||
|
|
||||||
// Neither followee nor follower has moved.
|
// Neither followee nor follower has moved.
|
||||||
if (!follower.movedToUri && !followee.movedToUri) {
|
if (!follower.movedToUri && !followee.movedToUri) {
|
||||||
//#region Decrement following / followers counts
|
//#region Decrement following / followers counts
|
||||||
|
|
@ -687,22 +666,22 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
private async removeFollow(followee: Both, follower: Both): Promise<void> {
|
private async removeFollow(followee: Both, follower: Both): Promise<void> {
|
||||||
const following = await this.followingsRepository.findOne({
|
const [
|
||||||
relations: {
|
followerUser,
|
||||||
followee: true,
|
followeeUser,
|
||||||
follower: true,
|
following,
|
||||||
},
|
] = await Promise.all([
|
||||||
where: {
|
this.cacheService.findUserById(follower.id),
|
||||||
followeeId: followee.id,
|
this.cacheService.findUserById(followee.id),
|
||||||
followerId: follower.id,
|
this.cacheService.userFollowingsCache.fetch(follower.id).then(fs => fs.get(followee.id)),
|
||||||
},
|
]);
|
||||||
});
|
|
||||||
|
|
||||||
if (!following || !following.followee || !following.follower) return;
|
if (!following) return;
|
||||||
|
|
||||||
await this.followingsRepository.delete(following.id);
|
await this.followingsRepository.delete(following.id);
|
||||||
|
await this.internalEventService.emit('unfollow', { followerId: follower.id, followeeId: followee.id });
|
||||||
|
|
||||||
this.decrementFollowing(following.follower, following.followee);
|
this.decrementFollowing(followerUser, followeeUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -733,36 +712,26 @@ export class UserFollowingService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public getFollowees(userId: MiUser['id']) {
|
public async getFollowees(userId: MiUser['id']) {
|
||||||
return this.followingsRepository.createQueryBuilder('following')
|
const followings = await this.cacheService.userFollowingsCache.fetch(userId);
|
||||||
.select('following.followeeId')
|
return Array.from(followings.values());
|
||||||
.where('following.followerId = :followerId', { followerId: userId })
|
|
||||||
.getMany();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) {
|
public async isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) {
|
||||||
return this.followingsRepository.exists({
|
return this.cacheService.isFollowing(followerId, followeeId);
|
||||||
where: {
|
|
||||||
followerId,
|
|
||||||
followeeId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async isMutual(aUserId: MiUser['id'], bUserId: MiUser['id']) {
|
public async isMutual(aUserId: MiUser['id'], bUserId: MiUser['id']) {
|
||||||
const count = await this.followingsRepository.createQueryBuilder('following')
|
const [
|
||||||
.where(new Brackets(qb => {
|
isFollowing,
|
||||||
qb.where('following.followerId = :aUserId', { aUserId })
|
isFollowed,
|
||||||
.andWhere('following.followeeId = :bUserId', { bUserId });
|
] = await Promise.all([
|
||||||
}))
|
this.isFollowing(aUserId, bUserId),
|
||||||
.orWhere(new Brackets(qb => {
|
this.isFollowing(bUserId, aUserId),
|
||||||
qb.where('following.followerId = :bUserId', { bUserId })
|
]);
|
||||||
.andWhere('following.followeeId = :aUserId', { aUserId });
|
|
||||||
}))
|
|
||||||
.getCount();
|
|
||||||
|
|
||||||
return count === 2;
|
return isFollowing && isFollowed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,14 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import * as Redis from 'ioredis';
|
import * as Redis from 'ioredis';
|
||||||
import type { MiUser } from '@/models/User.js';
|
import type { MiUser } from '@/models/User.js';
|
||||||
import type { UserKeypairsRepository } from '@/models/_.js';
|
import type { UserKeypairsRepository } from '@/models/_.js';
|
||||||
import { RedisKVCache } from '@/misc/cache.js';
|
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
||||||
import type { MiUserKeypair } from '@/models/UserKeypair.js';
|
import type { MiUserKeypair } from '@/models/UserKeypair.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserKeypairService implements OnApplicationShutdown {
|
export class UserKeypairService implements OnApplicationShutdown {
|
||||||
private cache: RedisKVCache<MiUserKeypair>;
|
private cache: MemoryKVCache<MiUserKeypair>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.redis)
|
@Inject(DI.redis)
|
||||||
|
|
@ -23,18 +23,12 @@ export class UserKeypairService implements OnApplicationShutdown {
|
||||||
@Inject(DI.userKeypairsRepository)
|
@Inject(DI.userKeypairsRepository)
|
||||||
private userKeypairsRepository: UserKeypairsRepository,
|
private userKeypairsRepository: UserKeypairsRepository,
|
||||||
) {
|
) {
|
||||||
this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', {
|
this.cache = new MemoryKVCache<MiUserKeypair>(1000 * 60 * 60 * 24); // 24h
|
||||||
lifetime: 1000 * 60 * 60 * 24, // 24h
|
|
||||||
memoryCacheLifetime: 1000 * 60 * 60, // 1h
|
|
||||||
fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }),
|
|
||||||
toRedisConverter: (value) => JSON.stringify(value),
|
|
||||||
fromRedisConverter: (value) => JSON.parse(value),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getUserKeypair(userId: MiUser['id']): Promise<MiUserKeypair> {
|
public async getUserKeypair(userId: MiUser['id']): Promise<MiUserKeypair> {
|
||||||
return await this.cache.fetch(userId);
|
return await this.cache.fetch(userId, () => this.userKeypairsRepository.findOneByOrFail({ userId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
||||||
|
|
@ -11,21 +11,22 @@ import type { MiUser } from '@/models/User.js';
|
||||||
import type { MiUserList } from '@/models/UserList.js';
|
import type { MiUserList } from '@/models/UserList.js';
|
||||||
import type { MiUserListMembership } from '@/models/UserListMembership.js';
|
import type { MiUserListMembership } from '@/models/UserListMembership.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import type { GlobalEvents } from '@/core/GlobalEventService.js';
|
import type { GlobalEvents, InternalEventTypes } from '@/core/GlobalEventService.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { QueueService } from '@/core/QueueService.js';
|
import { QueueService } from '@/core/QueueService.js';
|
||||||
import { RedisKVCache } from '@/misc/cache.js';
|
import { QuantumKVCache } from '@/misc/QuantumKVCache.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||||
|
import { InternalEventService } from '@/core/InternalEventService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserListService implements OnApplicationShutdown, OnModuleInit {
|
export class UserListService implements OnApplicationShutdown, OnModuleInit {
|
||||||
public static TooManyUsersError = class extends Error {};
|
public static TooManyUsersError = class extends Error {};
|
||||||
|
|
||||||
public membersCache: RedisKVCache<Set<string>>;
|
public membersCache: QuantumKVCache<Set<string>>;
|
||||||
private roleService: RoleService;
|
private roleService: RoleService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
@ -48,16 +49,15 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit {
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private queueService: QueueService,
|
private queueService: QueueService,
|
||||||
private systemAccountService: SystemAccountService,
|
private systemAccountService: SystemAccountService,
|
||||||
|
private readonly internalEventService: InternalEventService,
|
||||||
) {
|
) {
|
||||||
this.membersCache = new RedisKVCache<Set<string>>(this.redisClient, 'userListMembers', {
|
this.membersCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'userListMembers', {
|
||||||
lifetime: 1000 * 60 * 30, // 30m
|
lifetime: 1000 * 60 * 30, // 30m
|
||||||
memoryCacheLifetime: 1000 * 60, // 1m
|
|
||||||
fetcher: (key) => this.userListMembershipsRepository.find({ where: { userListId: key }, select: ['userId'] }).then(xs => new Set(xs.map(x => x.userId))),
|
fetcher: (key) => this.userListMembershipsRepository.find({ where: { userListId: key }, select: ['userId'] }).then(xs => new Set(xs.map(x => x.userId))),
|
||||||
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
|
|
||||||
fromRedisConverter: (value) => new Set(JSON.parse(value)),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.redisForSub.on('message', this.onMessage);
|
this.internalEventService.on('userListMemberAdded', this.onMessage);
|
||||||
|
this.internalEventService.on('userListMemberRemoved', this.onMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
|
|
@ -65,15 +65,12 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async onMessage(_: string, data: string): Promise<void> {
|
private async onMessage<E extends 'userListMemberAdded' | 'userListMemberRemoved'>(body: InternalEventTypes[E], type: E): Promise<void> {
|
||||||
const obj = JSON.parse(data);
|
{
|
||||||
|
|
||||||
if (obj.channel === 'internal') {
|
|
||||||
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'userListMemberAdded': {
|
case 'userListMemberAdded': {
|
||||||
const { userListId, memberId } = body;
|
const { userListId, memberId } = body;
|
||||||
const members = await this.membersCache.get(userListId);
|
const members = this.membersCache.get(userListId);
|
||||||
if (members) {
|
if (members) {
|
||||||
members.add(memberId);
|
members.add(memberId);
|
||||||
}
|
}
|
||||||
|
|
@ -81,7 +78,7 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit {
|
||||||
}
|
}
|
||||||
case 'userListMemberRemoved': {
|
case 'userListMemberRemoved': {
|
||||||
const { userListId, memberId } = body;
|
const { userListId, memberId } = body;
|
||||||
const members = await this.membersCache.get(userListId);
|
const members = this.membersCache.get(userListId);
|
||||||
if (members) {
|
if (members) {
|
||||||
members.delete(memberId);
|
members.delete(memberId);
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +147,8 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
this.redisForSub.off('message', this.onMessage);
|
this.internalEventService.off('userListMemberAdded', this.onMessage);
|
||||||
|
this.internalEventService.off('userListMemberRemoved', this.onMessage);
|
||||||
this.membersCache.dispose();
|
this.membersCache.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export class UserMutingService {
|
||||||
muteeId: target.id,
|
muteeId: target.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.cacheService.userMutingsCache.refresh(user.id);
|
await this.cacheService.userMutingsCache.delete(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -43,9 +43,6 @@ export class UserMutingService {
|
||||||
id: In(mutings.map(m => m.id)),
|
id: In(mutings.map(m => m.id)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const muterIds = [...new Set(mutings.map(m => m.muterId))];
|
await this.cacheService.userMutingsCache.deleteMany(mutings.map(m => m.muterId));
|
||||||
for (const muterId of muterIds) {
|
|
||||||
this.cacheService.userMutingsCache.refresh(muterId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ export class UserRenoteMutingService {
|
||||||
muteeId: target.id,
|
muteeId: target.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.cacheService.renoteMutingsCache.refresh(user.id);
|
await this.cacheService.renoteMutingsCache.delete(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -44,9 +44,6 @@ export class UserRenoteMutingService {
|
||||||
id: In(mutings.map(m => m.id)),
|
id: In(mutings.map(m => m.id)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const muterIds = [...new Set(mutings.map(m => m.muterId))];
|
await this.cacheService.renoteMutingsCache.deleteMany(mutings.map(m => m.muterId));
|
||||||
for (const muterId of muterIds) {
|
|
||||||
await this.cacheService.renoteMutingsCache.refresh(muterId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { DI } from '@/di-symbols.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
|
|
@ -20,6 +21,7 @@ export class UserService {
|
||||||
private followingsRepository: FollowingsRepository,
|
private followingsRepository: FollowingsRepository,
|
||||||
private systemWebhookService: SystemWebhookService,
|
private systemWebhookService: SystemWebhookService,
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,14 +40,17 @@ export class UserService {
|
||||||
});
|
});
|
||||||
const wokeUp = result.isHibernated;
|
const wokeUp = result.isHibernated;
|
||||||
if (wokeUp) {
|
if (wokeUp) {
|
||||||
this.usersRepository.update(user.id, {
|
await Promise.all([
|
||||||
isHibernated: false,
|
this.usersRepository.update(user.id, {
|
||||||
});
|
isHibernated: false,
|
||||||
this.followingsRepository.update({
|
}),
|
||||||
followerId: user.id,
|
this.followingsRepository.update({
|
||||||
}, {
|
followerId: user.id,
|
||||||
isFollowerHibernated: false,
|
}, {
|
||||||
});
|
isFollowerHibernated: false,
|
||||||
|
}),
|
||||||
|
this.cacheService.hibernatedUserCache.set(user.id, false),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.usersRepository.update(user.id, {
|
this.usersRepository.update(user.id, {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Not, IsNull } from 'typeorm';
|
import { Not, IsNull } from 'typeorm';
|
||||||
import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js';
|
import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js';
|
||||||
import type { MiUser } from '@/models/User.js';
|
import { MiUser } from '@/models/User.js';
|
||||||
import { QueueService } from '@/core/QueueService.js';
|
import { QueueService } from '@/core/QueueService.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
|
@ -16,9 +16,16 @@ import { bindThis } from '@/decorators.js';
|
||||||
import { RelationshipJobData } from '@/queue/types.js';
|
import { RelationshipJobData } from '@/queue/types.js';
|
||||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||||
import { isSystemAccount } from '@/misc/is-system-account.js';
|
import { isSystemAccount } from '@/misc/is-system-account.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
|
import type Logger from '@/logger.js';
|
||||||
|
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||||
|
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserSuspendService {
|
export class UserSuspendService {
|
||||||
|
private readonly logger: Logger;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.usersRepository)
|
@Inject(DI.usersRepository)
|
||||||
private usersRepository: UsersRepository,
|
private usersRepository: UsersRepository,
|
||||||
|
|
@ -34,7 +41,11 @@ export class UserSuspendService {
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private apRendererService: ApRendererService,
|
private apRendererService: ApRendererService,
|
||||||
private moderationLogService: ModerationLogService,
|
private moderationLogService: ModerationLogService,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
|
|
||||||
|
loggerService: LoggerService,
|
||||||
) {
|
) {
|
||||||
|
this.logger = loggerService.getLogger('user-suspend');
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -45,16 +56,16 @@ export class UserSuspendService {
|
||||||
isSuspended: true,
|
isSuspended: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.moderationLogService.log(moderator, 'suspend', {
|
await this.moderationLogService.log(moderator, 'suspend', {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
userUsername: user.username,
|
userUsername: user.username,
|
||||||
userHost: user.host,
|
userHost: user.host,
|
||||||
});
|
});
|
||||||
|
|
||||||
(async () => {
|
trackPromise((async () => {
|
||||||
await this.postSuspend(user).catch(e => {});
|
await this.postSuspend(user);
|
||||||
await this.unFollowAll(user).catch(e => {});
|
await this.freezeAll(user);
|
||||||
})();
|
})().catch(e => this.logger.error(`Error suspending user ${user.id}: ${renderInlineError(e)}`)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -63,33 +74,36 @@ export class UserSuspendService {
|
||||||
isSuspended: false,
|
isSuspended: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.moderationLogService.log(moderator, 'unsuspend', {
|
await this.moderationLogService.log(moderator, 'unsuspend', {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
userUsername: user.username,
|
userUsername: user.username,
|
||||||
userHost: user.host,
|
userHost: user.host,
|
||||||
});
|
});
|
||||||
|
|
||||||
(async () => {
|
trackPromise((async () => {
|
||||||
await this.postUnsuspend(user).catch(e => {});
|
await this.postUnsuspend(user);
|
||||||
})();
|
await this.unFreezeAll(user);
|
||||||
|
})().catch(e => this.logger.error(`Error un-suspending for user ${user.id}: ${renderInlineError(e)}`)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise<void> {
|
private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise<void> {
|
||||||
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
|
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
|
||||||
|
|
||||||
|
/*
|
||||||
this.followRequestsRepository.delete({
|
this.followRequestsRepository.delete({
|
||||||
followeeId: user.id,
|
followeeId: user.id,
|
||||||
});
|
});
|
||||||
this.followRequestsRepository.delete({
|
this.followRequestsRepository.delete({
|
||||||
followerId: user.id,
|
followerId: user.id,
|
||||||
});
|
});
|
||||||
|
*/
|
||||||
|
|
||||||
if (this.userEntityService.isLocalUser(user)) {
|
if (this.userEntityService.isLocalUser(user)) {
|
||||||
// 知り得る全SharedInboxにDelete配信
|
// 知り得る全SharedInboxにDelete配信
|
||||||
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
|
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
|
||||||
|
|
||||||
const queue: string[] = [];
|
const queue = new Map<string, boolean>();
|
||||||
|
|
||||||
const followings = await this.followingsRepository.find({
|
const followings = await this.followingsRepository.find({
|
||||||
where: [
|
where: [
|
||||||
|
|
@ -102,12 +116,12 @@ export class UserSuspendService {
|
||||||
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
|
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
|
||||||
|
|
||||||
for (const inbox of inboxes) {
|
for (const inbox of inboxes) {
|
||||||
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
|
if (inbox != null) {
|
||||||
|
queue.set(inbox, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const inbox of queue) {
|
await this.queueService.deliverMany(user, content, queue);
|
||||||
this.queueService.deliver(user, content, inbox, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,7 +133,7 @@ export class UserSuspendService {
|
||||||
// 知り得る全SharedInboxにUndo Delete配信
|
// 知り得る全SharedInboxにUndo Delete配信
|
||||||
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user));
|
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user));
|
||||||
|
|
||||||
const queue: string[] = [];
|
const queue = new Map<string, boolean>();
|
||||||
|
|
||||||
const followings = await this.followingsRepository.find({
|
const followings = await this.followingsRepository.find({
|
||||||
where: [
|
where: [
|
||||||
|
|
@ -132,23 +146,19 @@ export class UserSuspendService {
|
||||||
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
|
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
|
||||||
|
|
||||||
for (const inbox of inboxes) {
|
for (const inbox of inboxes) {
|
||||||
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
|
if (inbox != null) {
|
||||||
|
queue.set(inbox, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const inbox of queue) {
|
await this.queueService.deliverMany(user, content, queue);
|
||||||
this.queueService.deliver(user as any, content, inbox, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async unFollowAll(follower: MiUser) {
|
private async unFollowAll(follower: MiUser) {
|
||||||
const followings = await this.followingsRepository.find({
|
const followings = await this.cacheService.userFollowingsCache.fetch(follower.id)
|
||||||
where: {
|
.then(fs => Array.from(fs.values()).filter(f => f.followeeHost != null));
|
||||||
followerId: follower.id,
|
|
||||||
followeeId: Not(IsNull()),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const jobs: RelationshipJobData[] = [];
|
const jobs: RelationshipJobData[] = [];
|
||||||
for (const following of followings) {
|
for (const following of followings) {
|
||||||
|
|
@ -162,4 +172,36 @@ export class UserSuspendService {
|
||||||
}
|
}
|
||||||
this.queueService.createUnfollowJob(jobs);
|
this.queueService.createUnfollowJob(jobs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async freezeAll(user: MiUser): Promise<void> {
|
||||||
|
// Freeze follow relations with all remote users
|
||||||
|
await this.followingsRepository
|
||||||
|
.createQueryBuilder('following')
|
||||||
|
.orWhere({
|
||||||
|
followeeId: user.id,
|
||||||
|
followerHost: Not(IsNull()),
|
||||||
|
})
|
||||||
|
.update({
|
||||||
|
isFollowerHibernated: true,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async unFreezeAll(user: MiUser): Promise<void> {
|
||||||
|
// Restore follow relations with all remote users
|
||||||
|
await this.followingsRepository
|
||||||
|
.createQueryBuilder('following')
|
||||||
|
.innerJoin(MiUser, 'follower', 'user.id = following.followerId')
|
||||||
|
.andWhere('follower.isHibernated = false') // Don't unfreeze if the follower is *actually* frozen
|
||||||
|
.andWhere({
|
||||||
|
followeeId: user.id,
|
||||||
|
followerHost: Not(IsNull()),
|
||||||
|
})
|
||||||
|
.update({
|
||||||
|
isFollowerHibernated: false,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,22 +49,49 @@ export class UtilityService {
|
||||||
return regexp.test(email);
|
return regexp.test(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isBlockedHost(host: string | null): boolean;
|
||||||
|
public isBlockedHost(blockedHosts: string[], host: string | null): boolean;
|
||||||
@bindThis
|
@bindThis
|
||||||
public isBlockedHost(blockedHosts: string[], host: string | null): boolean {
|
public isBlockedHost(blockedHostsOrHost: string[] | string | null, host?: string | null): boolean {
|
||||||
|
const blockedHosts = Array.isArray(blockedHostsOrHost) ? blockedHostsOrHost : this.meta.blockedHosts;
|
||||||
|
host = Array.isArray(blockedHostsOrHost) ? host : blockedHostsOrHost;
|
||||||
|
|
||||||
if (host == null) return false;
|
if (host == null) return false;
|
||||||
return blockedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
return blockedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isSilencedHost(host: string | null): boolean;
|
||||||
|
public isSilencedHost(silencedHosts: string[], host: string | null): boolean;
|
||||||
@bindThis
|
@bindThis
|
||||||
public isSilencedHost(silencedHosts: string[] | undefined, host: string | null): boolean {
|
public isSilencedHost(silencedHostsOrHost: string[] | string | null, host?: string | null): boolean {
|
||||||
if (!silencedHosts || host == null) return false;
|
const silencedHosts = Array.isArray(silencedHostsOrHost) ? silencedHostsOrHost : this.meta.silencedHosts;
|
||||||
|
host = Array.isArray(silencedHostsOrHost) ? host : silencedHostsOrHost;
|
||||||
|
|
||||||
|
if (host == null) return false;
|
||||||
return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isMediaSilencedHost(host: string | null): boolean;
|
||||||
|
public isMediaSilencedHost(silencedHosts: string[], host: string | null): boolean;
|
||||||
@bindThis
|
@bindThis
|
||||||
public isMediaSilencedHost(silencedHosts: string[] | undefined, host: string | null): boolean {
|
public isMediaSilencedHost(mediaSilencedHostsOrHost: string[] | string | null, host?: string | null): boolean {
|
||||||
if (!silencedHosts || host == null) return false;
|
const mediaSilencedHosts = Array.isArray(mediaSilencedHostsOrHost) ? mediaSilencedHostsOrHost : this.meta.mediaSilencedHosts;
|
||||||
return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
host = Array.isArray(mediaSilencedHostsOrHost) ? host : mediaSilencedHostsOrHost;
|
||||||
|
|
||||||
|
if (host == null) return false;
|
||||||
|
return mediaSilencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public isAllowListedHost(host: string | null): boolean {
|
||||||
|
if (host == null) return false;
|
||||||
|
return this.meta.federationHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public isBubbledHost(host: string | null): boolean {
|
||||||
|
if (host == null) return false;
|
||||||
|
return this.meta.bubbleInstances.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,41 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import FFmpeg from 'fluent-ffmpeg';
|
import FFmpeg from 'fluent-ffmpeg';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import { ImageProcessingService } from '@/core/ImageProcessingService.js';
|
import { ImageProcessingService } from '@/core/ImageProcessingService.js';
|
||||||
import type { IImage } from '@/core/ImageProcessingService.js';
|
import type { IImage } from '@/core/ImageProcessingService.js';
|
||||||
import { createTempDir } from '@/misc/create-temp.js';
|
import { createTemp, createTempDir } from '@/misc/create-temp.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { appendQuery, query } from '@/misc/prelude/url.js';
|
import { appendQuery, query } from '@/misc/prelude/url.js';
|
||||||
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
|
import type Logger from '@/logger.js';
|
||||||
|
|
||||||
|
// faststart is only supported for MP4, M4A, M4W and MOV files (the MOV family).
|
||||||
|
// WebM (and Matroska) files always support faststart-like behavior.
|
||||||
|
const supportedMimeTypes = new Map([
|
||||||
|
['video/mp4', 'mp4'],
|
||||||
|
['video/m4a', 'mp4'],
|
||||||
|
['video/m4v', 'mp4'],
|
||||||
|
['video/quicktime', 'mov'],
|
||||||
|
]);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VideoProcessingService {
|
export class VideoProcessingService {
|
||||||
|
private readonly logger: Logger;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.config)
|
@Inject(DI.config)
|
||||||
private config: Config,
|
private config: Config,
|
||||||
|
|
||||||
private imageProcessingService: ImageProcessingService,
|
private imageProcessingService: ImageProcessingService,
|
||||||
|
|
||||||
|
private loggerService: LoggerService,
|
||||||
) {
|
) {
|
||||||
|
this.logger = this.loggerService.getLogger('video-processing');
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -60,5 +77,50 @@ export class VideoProcessingService {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimize video for web playback by adding faststart flag.
|
||||||
|
* This allows the video to start playing before it is fully downloaded.
|
||||||
|
* The original file is modified in-place.
|
||||||
|
* @param source Path to the video file
|
||||||
|
* @param mimeType The MIME type of the video
|
||||||
|
* @returns Promise that resolves when optimization is complete
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async webOptimizeVideo(source: string, mimeType: string): Promise<void> {
|
||||||
|
const outputFormat = supportedMimeTypes.get(mimeType);
|
||||||
|
if (!outputFormat) {
|
||||||
|
this.logger.debug(`Skipping web optimization for unsupported MIME type: ${mimeType}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [tempPath, cleanup] = await createTemp();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
FFmpeg(source)
|
||||||
|
.format(outputFormat) // Specify output format
|
||||||
|
.addOutputOptions('-c copy') // Copy streams without re-encoding
|
||||||
|
.addOutputOptions('-movflags +faststart')
|
||||||
|
.on('error', reject)
|
||||||
|
.on('end', async () => {
|
||||||
|
try {
|
||||||
|
// Replace original file with optimized version
|
||||||
|
await fs.copyFile(tempPath, source);
|
||||||
|
this.logger.info(`Web-optimized video: ${source}`);
|
||||||
|
resolve();
|
||||||
|
} catch (copyError) {
|
||||||
|
reject(copyError);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.save(tempPath);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Failed to web-optimize video: ${source}`, { error });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import type { Config } from '@/config.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { MiUser } from '@/models/_.js';
|
import { MiUser } from '@/models/_.js';
|
||||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
|
import Logger from '@/logger.js';
|
||||||
import type {
|
import type {
|
||||||
AuthenticationResponseJSON,
|
AuthenticationResponseJSON,
|
||||||
AuthenticatorTransportFuture,
|
AuthenticatorTransportFuture,
|
||||||
|
|
@ -28,6 +30,8 @@ import type {
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WebAuthnService {
|
export class WebAuthnService {
|
||||||
|
private readonly logger: Logger;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.config)
|
@Inject(DI.config)
|
||||||
private config: Config,
|
private config: Config,
|
||||||
|
|
@ -40,7 +44,9 @@ export class WebAuthnService {
|
||||||
|
|
||||||
@Inject(DI.userSecurityKeysRepository)
|
@Inject(DI.userSecurityKeysRepository)
|
||||||
private userSecurityKeysRepository: UserSecurityKeysRepository,
|
private userSecurityKeysRepository: UserSecurityKeysRepository,
|
||||||
|
loggerService: LoggerService,
|
||||||
) {
|
) {
|
||||||
|
this.logger = loggerService.getLogger('web-authn');
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -114,8 +120,8 @@ export class WebAuthnService {
|
||||||
requireUserVerification: true,
|
requireUserVerification: true,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
this.logger.error(error as Error, 'Error authenticating webauthn');
|
||||||
throw new IdentifiableError('5c1446f8-8ca7-4d31-9f39-656afe9c5d87', 'verification failed');
|
throw new IdentifiableError('5c1446f8-8ca7-4d31-9f39-656afe9c5d87', 'verification failed', true, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { verified } = verification;
|
const { verified } = verification;
|
||||||
|
|
@ -221,7 +227,7 @@ export class WebAuthnService {
|
||||||
requireUserVerification: true,
|
requireUserVerification: true,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', `verification failed: ${error}`);
|
throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', `verification failed`, true, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { verified, authenticationInfo } = verification;
|
const { verified, authenticationInfo } = verification;
|
||||||
|
|
@ -301,8 +307,8 @@ export class WebAuthnService {
|
||||||
requireUserVerification: true,
|
requireUserVerification: true,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
this.logger.error(error as Error, 'Error authenticating webauthn');
|
||||||
throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', 'verification failed');
|
throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', 'verification failed', true, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { verified, authenticationInfo } = verification;
|
const { verified, authenticationInfo } = verification;
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@
|
||||||
|
|
||||||
import { URL } from 'node:url';
|
import { URL } from 'node:url';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { XMLParser } from 'fast-xml-parser';
|
import { load as cheerio } from 'cheerio/slim';
|
||||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
|
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||||
import { RemoteLoggerService } from './RemoteLoggerService.js';
|
import { RemoteLoggerService } from './RemoteLoggerService.js';
|
||||||
|
|
||||||
export type ILink = {
|
export type ILink = {
|
||||||
|
|
@ -100,16 +101,14 @@ export class WebfingerService {
|
||||||
private async fetchWebFingerTemplateFromHostMeta(url: string): Promise<string | null> {
|
private async fetchWebFingerTemplateFromHostMeta(url: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const res = await this.httpRequestService.getHtml(url, 'application/xrd+xml');
|
const res = await this.httpRequestService.getHtml(url, 'application/xrd+xml');
|
||||||
const options = {
|
const hostMeta = cheerio(res, {
|
||||||
ignoreAttributes: false,
|
xml: true,
|
||||||
isArray: (_name: string, jpath: string) => jpath === 'XRD.Link',
|
});
|
||||||
};
|
|
||||||
const parser = new XMLParser(options);
|
const template = hostMeta('XRD > Link[rel="lrdd"][template*="{uri}"]').attr('template');
|
||||||
const hostMeta = parser.parse(res);
|
return template ?? null;
|
||||||
const template = (hostMeta['XRD']['Link'] as Array<any>).filter(p => p['@_rel'] === 'lrdd')[0]['@_template'];
|
|
||||||
return template.indexOf('{uri}') < 0 ? null : template;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`error while request host-meta for ${url}: ${err}`);
|
this.logger.error(`error while request host-meta for ${url}: ${renderInlineError(err)}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { type WebhookEventTypes } from '@/models/Webhook.js';
|
||||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||||
import { type UserWebhookPayload, UserWebhookService } from '@/core/UserWebhookService.js';
|
import { type UserWebhookPayload, UserWebhookService } from '@/core/UserWebhookService.js';
|
||||||
import { QueueService } from '@/core/QueueService.js';
|
import { QueueService } from '@/core/QueueService.js';
|
||||||
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
||||||
|
|
||||||
const oneDayMillis = 24 * 60 * 60 * 1000;
|
const oneDayMillis = 24 * 60 * 60 * 1000;
|
||||||
|
|
@ -63,6 +64,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
|
||||||
emojis: [],
|
emojis: [],
|
||||||
score: 0,
|
score: 0,
|
||||||
host: null,
|
host: null,
|
||||||
|
instance: null,
|
||||||
inbox: null,
|
inbox: null,
|
||||||
sharedInbox: null,
|
sharedInbox: null,
|
||||||
featured: null,
|
featured: null,
|
||||||
|
|
@ -76,6 +78,8 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
|
||||||
mandatoryCW: null,
|
mandatoryCW: null,
|
||||||
rejectQuotes: false,
|
rejectQuotes: false,
|
||||||
allowUnsignedFetch: 'staff',
|
allowUnsignedFetch: 'staff',
|
||||||
|
userProfile: null,
|
||||||
|
attributionDomains: [],
|
||||||
...override,
|
...override,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -114,10 +118,13 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote {
|
||||||
channelId: null,
|
channelId: null,
|
||||||
channel: null,
|
channel: null,
|
||||||
userHost: null,
|
userHost: null,
|
||||||
|
userInstance: null,
|
||||||
replyUserId: null,
|
replyUserId: null,
|
||||||
replyUserHost: null,
|
replyUserHost: null,
|
||||||
|
replyUserInstance: null,
|
||||||
renoteUserId: null,
|
renoteUserId: null,
|
||||||
renoteUserHost: null,
|
renoteUserHost: null,
|
||||||
|
renoteUserInstance: null,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
processErrors: [],
|
processErrors: [],
|
||||||
...override,
|
...override,
|
||||||
|
|
@ -160,6 +167,7 @@ export class WebhookTestService {
|
||||||
private userWebhookService: UserWebhookService,
|
private userWebhookService: UserWebhookService,
|
||||||
private systemWebhookService: SystemWebhookService,
|
private systemWebhookService: SystemWebhookService,
|
||||||
private queueService: QueueService,
|
private queueService: QueueService,
|
||||||
|
private readonly idService: IdService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -358,8 +366,10 @@ export class WebhookTestService {
|
||||||
id: 'dummy-abuse-report1',
|
id: 'dummy-abuse-report1',
|
||||||
targetUserId: 'dummy-target-user',
|
targetUserId: 'dummy-target-user',
|
||||||
targetUser: null,
|
targetUser: null,
|
||||||
|
targetUserInstance: null,
|
||||||
reporterId: 'dummy-reporter-user',
|
reporterId: 'dummy-reporter-user',
|
||||||
reporter: null,
|
reporter: null,
|
||||||
|
reporterInstance: null,
|
||||||
assigneeId: null,
|
assigneeId: null,
|
||||||
assignee: null,
|
assignee: null,
|
||||||
resolved: false,
|
resolved: false,
|
||||||
|
|
@ -441,6 +451,8 @@ export class WebhookTestService {
|
||||||
offsetX: it.offsetX,
|
offsetX: it.offsetX,
|
||||||
offsetY: it.offsetY,
|
offsetY: it.offsetY,
|
||||||
})),
|
})),
|
||||||
|
createdAt: this.idService.parse(user.id).date.toISOString(),
|
||||||
|
description: '',
|
||||||
isBot: user.isBot,
|
isBot: user.isBot,
|
||||||
isCat: user.isCat,
|
isCat: user.isCat,
|
||||||
emojis: await this.customEmojiService.populateEmojis(user.emojis, user.host),
|
emojis: await this.customEmojiService.populateEmojis(user.emojis, user.host),
|
||||||
|
|
@ -449,6 +461,7 @@ export class WebhookTestService {
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
isModerator: false,
|
isModerator: false,
|
||||||
isSystem: false,
|
isSystem: false,
|
||||||
|
instance: undefined,
|
||||||
...override,
|
...override,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -165,18 +165,23 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async refetchPublicKeyForApId(user: MiRemoteUser): Promise<MiUserPublickey | null> {
|
public async refetchPublicKeyForApId(user: MiRemoteUser): Promise<MiUserPublickey | null> {
|
||||||
this.apLoggerService.logger.debug('Re-fetching public key for user', { userId: user.id, uri: user.uri });
|
this.apLoggerService.logger.debug(`Updating public key for user ${user.id} (${user.uri})`);
|
||||||
|
|
||||||
|
const oldKey = await this.apPersonService.findPublicKeyByUserId(user.id);
|
||||||
await this.apPersonService.updatePerson(user.uri);
|
await this.apPersonService.updatePerson(user.uri);
|
||||||
|
const newKey = await this.apPersonService.findPublicKeyByUserId(user.id);
|
||||||
|
|
||||||
const key = await this.apPersonService.findPublicKeyByUserId(user.id);
|
if (newKey) {
|
||||||
|
if (oldKey && newKey.keyPem === oldKey.keyPem) {
|
||||||
if (key) {
|
this.apLoggerService.logger.debug(`Public key is up-to-date for user ${user.id} (${user.uri})`);
|
||||||
this.apLoggerService.logger.info('Re-fetched public key for user', { userId: user.id, uri: user.uri });
|
} else {
|
||||||
|
this.apLoggerService.logger.info(`Updated public key for user ${user.id} (${user.uri})`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.apLoggerService.logger.warn('Failed to re-fetch key for user', { userId: user.id, uri: user.uri });
|
this.apLoggerService.logger.warn(`Failed to update public key for user ${user.id} (${user.uri})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return key;
|
return newKey ?? oldKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { IsNull, Not } from 'typeorm';
|
import { IsNull, Not } from 'typeorm';
|
||||||
import { UnrecoverableError } from 'bullmq';
|
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { FollowingsRepository } from '@/models/_.js';
|
import type { FollowingsRepository } from '@/models/_.js';
|
||||||
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||||
|
|
@ -14,6 +13,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import type { IActivity } from '@/core/activitypub/type.js';
|
import type { IActivity } from '@/core/activitypub/type.js';
|
||||||
import { ThinUser } from '@/queue/types.js';
|
import { ThinUser } from '@/queue/types.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
|
||||||
interface IRecipe {
|
interface IRecipe {
|
||||||
type: string;
|
type: string;
|
||||||
|
|
@ -41,23 +41,21 @@ class DeliverManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor
|
* Constructor
|
||||||
* @param userEntityService
|
|
||||||
* @param followingsRepository
|
|
||||||
* @param queueService
|
* @param queueService
|
||||||
|
* @param cacheService
|
||||||
* @param actor Actor
|
* @param actor Actor
|
||||||
* @param activity Activity to deliver
|
* @param activity Activity to deliver
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private userEntityService: UserEntityService,
|
|
||||||
private followingsRepository: FollowingsRepository,
|
|
||||||
private queueService: QueueService,
|
private queueService: QueueService,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
|
|
||||||
actor: { id: MiUser['id']; host: null; },
|
actor: { id: MiUser['id']; host: null; },
|
||||||
activity: IActivity | null,
|
activity: IActivity | null,
|
||||||
) {
|
) {
|
||||||
// 型で弾いてはいるが一応ローカルユーザーかチェック
|
// 型で弾いてはいるが一応ローカルユーザーかチェック
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
if (actor.host != null) throw new Error('actor.host must be null');
|
if (actor.host != null) throw new Error(`deliver failed for ${actor.id}: host is not null`);
|
||||||
|
|
||||||
// パフォーマンス向上のためキューに突っ込むのはidのみに絞る
|
// パフォーマンス向上のためキューに突っ込むのはidのみに絞る
|
||||||
this.actor = {
|
this.actor = {
|
||||||
|
|
@ -114,23 +112,23 @@ class DeliverManager {
|
||||||
// Process follower recipes first to avoid duplication when processing direct recipes later.
|
// Process follower recipes first to avoid duplication when processing direct recipes later.
|
||||||
if (this.recipes.some(r => isFollowers(r))) {
|
if (this.recipes.some(r => isFollowers(r))) {
|
||||||
// followers deliver
|
// followers deliver
|
||||||
// TODO: SELECT DISTINCT ON ("followerSharedInbox") "followerSharedInbox" みたいな問い合わせにすればよりパフォーマンス向上できそう
|
|
||||||
// ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう?
|
// ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう?
|
||||||
const followers = await this.followingsRepository.find({
|
const followers = await this.cacheService.userFollowersCache
|
||||||
where: {
|
.fetch(this.actor.id)
|
||||||
followeeId: this.actor.id,
|
.then(f => Array
|
||||||
followerHost: Not(IsNull()),
|
.from(f.values())
|
||||||
},
|
.filter(f => f.followerHost != null)
|
||||||
select: {
|
.map(f => ({
|
||||||
followerSharedInbox: true,
|
followerInbox: f.followerInbox,
|
||||||
followerInbox: true,
|
followerSharedInbox: f.followerSharedInbox,
|
||||||
},
|
})));
|
||||||
});
|
|
||||||
|
|
||||||
for (const following of followers) {
|
for (const following of followers) {
|
||||||
const inbox = following.followerSharedInbox ?? following.followerInbox;
|
if (following.followerSharedInbox) {
|
||||||
if (inbox === null) throw new UnrecoverableError(`inbox is null: following ${following.id}`);
|
inboxes.set(following.followerSharedInbox, true);
|
||||||
inboxes.set(inbox, following.followerSharedInbox != null);
|
} else if (following.followerInbox) {
|
||||||
|
inboxes.set(following.followerInbox, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,11 +150,8 @@ class DeliverManager {
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApDeliverManagerService {
|
export class ApDeliverManagerService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.followingsRepository)
|
|
||||||
private followingsRepository: FollowingsRepository,
|
|
||||||
|
|
||||||
private userEntityService: UserEntityService,
|
|
||||||
private queueService: QueueService,
|
private queueService: QueueService,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,9 +163,8 @@ export class ApDeliverManagerService {
|
||||||
@bindThis
|
@bindThis
|
||||||
public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity): Promise<void> {
|
public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity): Promise<void> {
|
||||||
const manager = new DeliverManager(
|
const manager = new DeliverManager(
|
||||||
this.userEntityService,
|
|
||||||
this.followingsRepository,
|
|
||||||
this.queueService,
|
this.queueService,
|
||||||
|
this.cacheService,
|
||||||
actor,
|
actor,
|
||||||
activity,
|
activity,
|
||||||
);
|
);
|
||||||
|
|
@ -187,9 +181,8 @@ export class ApDeliverManagerService {
|
||||||
@bindThis
|
@bindThis
|
||||||
public async deliverToUser(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, to: MiRemoteUser): Promise<void> {
|
public async deliverToUser(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, to: MiRemoteUser): Promise<void> {
|
||||||
const manager = new DeliverManager(
|
const manager = new DeliverManager(
|
||||||
this.userEntityService,
|
|
||||||
this.followingsRepository,
|
|
||||||
this.queueService,
|
this.queueService,
|
||||||
|
this.cacheService,
|
||||||
actor,
|
actor,
|
||||||
activity,
|
activity,
|
||||||
);
|
);
|
||||||
|
|
@ -206,9 +199,8 @@ export class ApDeliverManagerService {
|
||||||
@bindThis
|
@bindThis
|
||||||
public async deliverToUsers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, targets: MiRemoteUser[]): Promise<void> {
|
public async deliverToUsers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, targets: MiRemoteUser[]): Promise<void> {
|
||||||
const manager = new DeliverManager(
|
const manager = new DeliverManager(
|
||||||
this.userEntityService,
|
|
||||||
this.followingsRepository,
|
|
||||||
this.queueService,
|
this.queueService,
|
||||||
|
this.cacheService,
|
||||||
actor,
|
actor,
|
||||||
activity,
|
activity,
|
||||||
);
|
);
|
||||||
|
|
@ -219,9 +211,8 @@ export class ApDeliverManagerService {
|
||||||
@bindThis
|
@bindThis
|
||||||
public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager {
|
public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager {
|
||||||
return new DeliverManager(
|
return new DeliverManager(
|
||||||
this.userEntityService,
|
|
||||||
this.followingsRepository,
|
|
||||||
this.queueService,
|
this.queueService,
|
||||||
|
this.cacheService,
|
||||||
|
|
||||||
actor,
|
actor,
|
||||||
activity,
|
activity,
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,13 @@ import { AbuseReportService } from '@/core/AbuseReportService.js';
|
||||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||||
import { fromTuple } from '@/misc/from-tuple.js';
|
import { fromTuple } from '@/misc/from-tuple.js';
|
||||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
|
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||||
import InstanceChart from '@/core/chart/charts/instance.js';
|
import InstanceChart from '@/core/chart/charts/instance.js';
|
||||||
import FederationChart from '@/core/chart/charts/federation.js';
|
import FederationChart from '@/core/chart/charts/federation.js';
|
||||||
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
|
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
|
||||||
import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js';
|
import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.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, isActivity, IObjectWithId } from './type.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
import { getApHrefNullable, getApId, getApIds, getApType, getNullableApId, isAccept, isActor, isAdd, isAnnounce, isApObject, isBlock, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isDislike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost, isActivity, IObjectWithId } from './type.js';
|
||||||
import { ApNoteService } from './models/ApNoteService.js';
|
import { ApNoteService } from './models/ApNoteService.js';
|
||||||
import { ApLoggerService } from './ApLoggerService.js';
|
import { ApLoggerService } from './ApLoggerService.js';
|
||||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||||
|
|
@ -97,6 +99,7 @@ export class ApInboxService {
|
||||||
private readonly instanceChart: InstanceChart,
|
private readonly instanceChart: InstanceChart,
|
||||||
private readonly federationChart: FederationChart,
|
private readonly federationChart: FederationChart,
|
||||||
private readonly updateInstanceQueue: UpdateInstanceQueue,
|
private readonly updateInstanceQueue: UpdateInstanceQueue,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.apLoggerService.logger;
|
this.logger = this.apLoggerService.logger;
|
||||||
}
|
}
|
||||||
|
|
@ -106,25 +109,29 @@ export class ApInboxService {
|
||||||
let result = undefined as string | void;
|
let result = undefined as string | void;
|
||||||
if (isCollectionOrOrderedCollection(activity)) {
|
if (isCollectionOrOrderedCollection(activity)) {
|
||||||
const results = [] as [string, string | void][];
|
const results = [] as [string, string | void][];
|
||||||
// eslint-disable-next-line no-param-reassign
|
|
||||||
resolver ??= this.apResolverService.createResolver();
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems);
|
const items = await resolver.resolveCollectionItems(activity);
|
||||||
if (items.length >= resolver.getRecursionLimit()) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
throw new Error(`skipping activity: collection would surpass recursion limit: ${this.utilityService.extractDbHost(actor.uri)}`);
|
const act = items[i];
|
||||||
}
|
if (act.id != null) {
|
||||||
|
if (this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) {
|
||||||
for (const item of items) {
|
this.logger.warn('skipping activity: activity id mismatch');
|
||||||
const act = await resolver.resolve(item);
|
continue;
|
||||||
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');
|
} else {
|
||||||
continue;
|
// Activity ID should only be string or undefined.
|
||||||
|
act.id = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const id = getNullableApId(act) ?? `${getNullableApId(activity)}#${i}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
results.push([getApId(item), await this.performOneActivity(actor, act, resolver)]);
|
const result = await this.performOneActivity(actor, act, resolver);
|
||||||
|
results.push([id, result]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error || typeof err === 'string') {
|
if (err instanceof Error || typeof err === 'string') {
|
||||||
this.logger.error(err);
|
this.logger.error(`Unhandled error in activity ${id}:`, err);
|
||||||
} else {
|
} else {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
@ -144,7 +151,8 @@ export class ApInboxService {
|
||||||
if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
|
if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
// 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
|
// 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない
|
||||||
this.apPersonService.updatePerson(actor.uri);
|
this.apPersonService.updatePerson(actor.uri)
|
||||||
|
.catch(err => this.logger.error(`Failed to update person: ${renderInlineError(err)}`));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -217,6 +225,10 @@ export class ApInboxService {
|
||||||
const note = await this.apNoteService.resolveNote(object, { resolver });
|
const note = await this.apNoteService.resolveNote(object, { resolver });
|
||||||
if (!note) return `skip: target note not found ${targetUri}`;
|
if (!note) return `skip: target note not found ${targetUri}`;
|
||||||
|
|
||||||
|
if (note.userHost == null && note.localOnly) {
|
||||||
|
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot react to local-only note');
|
||||||
|
}
|
||||||
|
|
||||||
await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null);
|
await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -246,7 +258,7 @@ export class ApInboxService {
|
||||||
resolver ??= this.apResolverService.createResolver();
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
const object = await resolver.resolve(activity.object).catch(err => {
|
const object = await resolver.resolve(activity.object).catch(err => {
|
||||||
this.logger.error(`Resolution failed: ${err}`);
|
this.logger.error(`Resolution failed: ${renderInlineError(err)}`);
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -319,7 +331,7 @@ export class ApInboxService {
|
||||||
if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.';
|
if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.';
|
||||||
|
|
||||||
const target = await resolver.secureResolve(activityObject, uri).catch(e => {
|
const target = await resolver.secureResolve(activityObject, uri).catch(e => {
|
||||||
this.logger.error(`Resolution failed: ${e}`);
|
this.logger.error(`Resolution failed: ${renderInlineError(e)}`);
|
||||||
throw e;
|
throw e;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -350,25 +362,17 @@ export class ApInboxService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Announce対象をresolve
|
// Announce対象をresolve
|
||||||
let renote;
|
// The target ID is verified by secureResolve, so we know it shares host authority with the actor who sent it.
|
||||||
try {
|
// This means we can pass that ID to resolveNote and avoid an extra fetch, which will fail if the note is private.
|
||||||
// The target ID is verified by secureResolve, so we know it shares host authority with the actor who sent it.
|
const renote = await this.apNoteService.resolveNote(target, { resolver, sentFrom: getApId(target) });
|
||||||
// This means we can pass that ID to resolveNote and avoid an extra fetch, which will fail if the note is private.
|
if (renote == null) return 'announce target is null';
|
||||||
renote = await this.apNoteService.resolveNote(target, { resolver, sentFrom: getApId(target) });
|
|
||||||
if (renote == null) return 'announce target is null';
|
if (!await this.noteEntityService.isVisibleForMe(renote, actor.id, { me: actor })) {
|
||||||
} catch (err) {
|
return 'skip: invalid actor for this activity';
|
||||||
// 対象が4xxならスキップ
|
|
||||||
if (err instanceof StatusError) {
|
|
||||||
if (!err.isRetryable) {
|
|
||||||
return `skip: ignored announce target ${target.id} - ${err.statusCode}`;
|
|
||||||
}
|
|
||||||
return `Error in announce target ${target.id} - ${err.statusCode}`;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await this.noteEntityService.isVisibleForMe(renote, actor.id)) {
|
if (renote.userHost == null && renote.localOnly) {
|
||||||
return 'skip: invalid actor for this activity';
|
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot renote a local-only note');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.info(`Creating the (Re)Note: ${uri}`);
|
this.logger.info(`Creating the (Re)Note: ${uri}`);
|
||||||
|
|
@ -443,9 +447,11 @@ export class ApInboxService {
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
// Don't re-use the resolver, or it may throw recursion errors.
|
// Don't re-use the resolver, or it may throw recursion errors.
|
||||||
// Instead, create a new resolver with an appropriately-reduced recursion limit.
|
// Instead, create a new resolver with an appropriately-reduced recursion limit.
|
||||||
this.apPersonService.updatePerson(actor.uri, this.apResolverService.createResolver({
|
const subResolver = this.apResolverService.createResolver({
|
||||||
recursionLimit: resolver.getRecursionLimit() - resolver.getHistory().length,
|
recursionLimit: resolver.getRecursionLimit() - resolver.getHistory().length,
|
||||||
}));
|
});
|
||||||
|
this.apPersonService.updatePerson(actor.uri, subResolver)
|
||||||
|
.catch(err => this.logger.error(`Failed to update person: ${renderInlineError(err)}`));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -500,7 +506,7 @@ export class ApInboxService {
|
||||||
resolver ??= this.apResolverService.createResolver();
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
const object = await resolver.resolve(activityObject).catch(e => {
|
const object = await resolver.resolve(activityObject).catch(e => {
|
||||||
this.logger.error(`Resolution failed: ${e}`);
|
this.logger.error(`Resolution failed: ${renderInlineError(e)}`);
|
||||||
throw e;
|
throw e;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -537,12 +543,6 @@ export class ApInboxService {
|
||||||
|
|
||||||
await this.apNoteService.createNote(note, actor, resolver, silent);
|
await this.apNoteService.createNote(note, actor, resolver, silent);
|
||||||
return 'ok';
|
return 'ok';
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof StatusError && !err.isRetryable) {
|
|
||||||
return `skip: ${err.statusCode}`;
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
unlock();
|
unlock();
|
||||||
}
|
}
|
||||||
|
|
@ -675,7 +675,7 @@ export class ApInboxService {
|
||||||
resolver ??= this.apResolverService.createResolver();
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
const object = await resolver.resolve(activity.object).catch(e => {
|
const object = await resolver.resolve(activity.object).catch(e => {
|
||||||
this.logger.error(`Resolution failed: ${e}`);
|
this.logger.error(`Resolution failed: ${renderInlineError(e)}`);
|
||||||
throw e;
|
throw e;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -747,7 +747,7 @@ export class ApInboxService {
|
||||||
resolver ??= this.apResolverService.createResolver();
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
const object = await resolver.resolve(activity.object).catch(e => {
|
const object = await resolver.resolve(activity.object).catch(e => {
|
||||||
this.logger.error(`Resolution failed: ${e}`);
|
this.logger.error(`Resolution failed: ${renderInlineError(e)}`);
|
||||||
throw e;
|
throw e;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -768,12 +768,7 @@ export class ApInboxService {
|
||||||
return 'skip: follower not found';
|
return 'skip: follower not found';
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFollowing = await this.followingsRepository.exists({
|
const isFollowing = await this.cacheService.userFollowingsCache.fetch(follower.id).then(f => f.has(actor.id));
|
||||||
where: {
|
|
||||||
followerId: follower.id,
|
|
||||||
followeeId: actor.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isFollowing) {
|
if (isFollowing) {
|
||||||
await this.userFollowingService.unfollow(follower, actor);
|
await this.userFollowingService.unfollow(follower, actor);
|
||||||
|
|
@ -832,12 +827,7 @@ export class ApInboxService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const isFollowing = await this.followingsRepository.exists({
|
const isFollowing = await this.cacheService.userFollowingsCache.fetch(actor.id).then(f => f.has(followee.id));
|
||||||
where: {
|
|
||||||
followerId: actor.id,
|
|
||||||
followeeId: followee.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (requestExist) {
|
if (requestExist) {
|
||||||
await this.userFollowingService.cancelFollowRequest(followee, actor);
|
await this.userFollowingService.cancelFollowRequest(followee, actor);
|
||||||
|
|
@ -879,7 +869,7 @@ export class ApInboxService {
|
||||||
resolver ??= this.apResolverService.createResolver();
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
const object = await resolver.resolve(activity.object).catch(e => {
|
const object = await resolver.resolve(activity.object).catch(e => {
|
||||||
this.logger.error(`Resolution failed: ${e}`);
|
this.logger.error(`Resolution failed: ${renderInlineError(e)}`);
|
||||||
throw e;
|
throw e;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import * as mfm from '@transfem-org/sfm-js';
|
import * as mfm from 'mfm-js';
|
||||||
import { MfmService, Appender } from '@/core/MfmService.js';
|
import { MfmService, Appender } from '@/core/MfmService.js';
|
||||||
import type { MiNote } from '@/models/Note.js';
|
import type { MiNote } from '@/models/Note.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@
|
||||||
import { createPublicKey, randomUUID } from 'node:crypto';
|
import { createPublicKey, randomUUID } from 'node:crypto';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { In } from 'typeorm';
|
import { In } from 'typeorm';
|
||||||
import * as mfm from '@transfem-org/sfm-js';
|
import * as mfm from 'mfm-js';
|
||||||
import { UnrecoverableError } from 'bullmq';
|
import { UnrecoverableError } from 'bullmq';
|
||||||
|
import { Element, Text } from 'domhandler';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import type { MiPartialLocalUser, MiLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
import type { MiPartialLocalUser, MiLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||||
|
|
@ -31,10 +32,12 @@ import { IdService } from '@/core/IdService.js';
|
||||||
import { appendContentWarning } from '@/misc/append-content-warning.js';
|
import { appendContentWarning } from '@/misc/append-content-warning.js';
|
||||||
import { QueryService } from '@/core/QueryService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
import { UtilityService } from '@/core/UtilityService.js';
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js';
|
||||||
import { JsonLdService } from './JsonLdService.js';
|
import { JsonLdService } from './JsonLdService.js';
|
||||||
import { ApMfmService } from './ApMfmService.js';
|
import { ApMfmService } from './ApMfmService.js';
|
||||||
import { CONTEXT } from './misc/contexts.js';
|
import { CONTEXT } from './misc/contexts.js';
|
||||||
import { getApId, IOrderedCollection, IOrderedCollectionPage } from './type.js';
|
import { getApId, ILink, IOrderedCollection, IOrderedCollectionPage } 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';
|
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()
|
@Injectable()
|
||||||
|
|
@ -74,6 +77,7 @@ export class ApRendererService {
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private readonly queryService: QueryService,
|
private readonly queryService: QueryService,
|
||||||
private utilityService: UtilityService,
|
private utilityService: UtilityService,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,7 +235,7 @@ export class ApRendererService {
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async renderFollowUser(id: MiUser['id']): Promise<string> {
|
public async renderFollowUser(id: MiUser['id']): Promise<string> {
|
||||||
const user = await this.usersRepository.findOneByOrFail({ id: id }) as MiPartialLocalUser | MiPartialRemoteUser;
|
const user = await this.cacheService.findUserById(id) as MiPartialLocalUser | MiPartialRemoteUser;
|
||||||
return this.userEntityService.getUserUri(user);
|
return this.userEntityService.getUserUri(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -401,7 +405,7 @@ export class ApRendererService {
|
||||||
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
|
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
|
||||||
|
|
||||||
if (inReplyToNote != null) {
|
if (inReplyToNote != null) {
|
||||||
const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId });
|
const inReplyToUser = await this.cacheService.findUserById(inReplyToNote.userId);
|
||||||
|
|
||||||
if (inReplyToUser) {
|
if (inReplyToUser) {
|
||||||
if (inReplyToNote.uri) {
|
if (inReplyToNote.uri) {
|
||||||
|
|
@ -419,9 +423,9 @@ export class ApRendererService {
|
||||||
inReplyTo = null;
|
inReplyTo = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let quote;
|
let quote: string | undefined = undefined;
|
||||||
|
|
||||||
if (note.renoteId) {
|
if (isRenote(note) && isQuote(note)) {
|
||||||
const renote = await this.notesRepository.findOneBy({ id: note.renoteId });
|
const renote = await this.notesRepository.findOneBy({ id: note.renoteId });
|
||||||
|
|
||||||
if (renote) {
|
if (renote) {
|
||||||
|
|
@ -475,16 +479,18 @@ export class ApRendererService {
|
||||||
// the claas name `quote-inline` is used in non-misskey clients for styling quote notes.
|
// the claas name `quote-inline` is used in non-misskey clients for styling quote notes.
|
||||||
// For compatibility, the span part should be kept as possible.
|
// For compatibility, the span part should be kept as possible.
|
||||||
apAppend.push((doc, body) => {
|
apAppend.push((doc, body) => {
|
||||||
body.appendChild(doc.createElement('br'));
|
body.childNodes.push(new Element('br', {}));
|
||||||
body.appendChild(doc.createElement('br'));
|
body.childNodes.push(new Element('br', {}));
|
||||||
const span = doc.createElement('span');
|
const span = new Element('span', {
|
||||||
span.className = 'quote-inline';
|
class: 'quote-inline',
|
||||||
span.appendChild(doc.createTextNode('RE: '));
|
});
|
||||||
const link = doc.createElement('a');
|
span.childNodes.push(new Text('RE: '));
|
||||||
link.setAttribute('href', quote);
|
const link = new Element('a', {
|
||||||
link.textContent = quote;
|
href: quote,
|
||||||
span.appendChild(link);
|
});
|
||||||
body.appendChild(span);
|
link.childNodes.push(new Text(quote));
|
||||||
|
span.childNodes.push(link);
|
||||||
|
body.childNodes.push(span);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -500,12 +506,22 @@ export class ApRendererService {
|
||||||
const emojis = await this.getEmojis(note.emojis);
|
const emojis = await this.getEmojis(note.emojis);
|
||||||
const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
|
const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
|
||||||
|
|
||||||
const tag = [
|
const tag: IObject[] = [
|
||||||
...hashtagTags,
|
...hashtagTags,
|
||||||
...mentionTags,
|
...mentionTags,
|
||||||
...apemojis,
|
...apemojis,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// https://codeberg.org/fediverse/fep/src/branch/main/fep/e232/fep-e232.md
|
||||||
|
if (quote) {
|
||||||
|
tag.push({
|
||||||
|
type: 'Link',
|
||||||
|
mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||||
|
rel: 'https://misskey-hub.net/ns#_misskey_quote',
|
||||||
|
href: quote,
|
||||||
|
} satisfies ILink);
|
||||||
|
}
|
||||||
|
|
||||||
const asPoll = poll ? {
|
const asPoll = poll ? {
|
||||||
type: 'Question',
|
type: 'Question',
|
||||||
[poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt,
|
[poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt,
|
||||||
|
|
@ -529,6 +545,7 @@ export class ApRendererService {
|
||||||
attributedTo,
|
attributedTo,
|
||||||
summary: summary ?? undefined,
|
summary: summary ?? undefined,
|
||||||
content: content ?? undefined,
|
content: content ?? undefined,
|
||||||
|
updated: note.updatedAt?.toISOString() ?? undefined,
|
||||||
_misskey_content: text,
|
_misskey_content: text,
|
||||||
source: {
|
source: {
|
||||||
content: text,
|
content: text,
|
||||||
|
|
@ -537,6 +554,8 @@ export class ApRendererService {
|
||||||
_misskey_quote: quote,
|
_misskey_quote: quote,
|
||||||
quoteUrl: quote,
|
quoteUrl: quote,
|
||||||
quoteUri: quote,
|
quoteUri: quote,
|
||||||
|
// https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
|
||||||
|
quote: quote,
|
||||||
published: this.idService.parse(note.id).date.toISOString(),
|
published: this.idService.parse(note.id).date.toISOString(),
|
||||||
to,
|
to,
|
||||||
cc,
|
cc,
|
||||||
|
|
@ -613,6 +632,7 @@ export class ApRendererService {
|
||||||
enableRss: user.enableRss,
|
enableRss: user.enableRss,
|
||||||
speakAsCat: user.speakAsCat,
|
speakAsCat: user.speakAsCat,
|
||||||
attachment: attachment.length ? attachment : undefined,
|
attachment: attachment.length ? attachment : undefined,
|
||||||
|
attributionDomains: user.attributionDomains,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (user.movedToUri) {
|
if (user.movedToUri) {
|
||||||
|
|
@ -740,162 +760,6 @@ export class ApRendererService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
|
||||||
public async renderUpNote(note: MiNote, author: MiUser, dive = true): Promise<IPost> {
|
|
||||||
const getPromisedFiles = async (ids: string[]): Promise<MiDriveFile[]> => {
|
|
||||||
if (ids.length === 0) return [];
|
|
||||||
const items = await this.driveFilesRepository.findBy({ id: In(ids) });
|
|
||||||
return ids.map(id => items.find(item => item.id === id)).filter((item): item is MiDriveFile => item != null);
|
|
||||||
};
|
|
||||||
|
|
||||||
let inReplyTo;
|
|
||||||
let inReplyToNote: MiNote | null;
|
|
||||||
|
|
||||||
if (note.replyId) {
|
|
||||||
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
|
|
||||||
|
|
||||||
if (inReplyToNote != null) {
|
|
||||||
const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId });
|
|
||||||
|
|
||||||
if (inReplyToUser) {
|
|
||||||
if (inReplyToNote.uri) {
|
|
||||||
inReplyTo = inReplyToNote.uri;
|
|
||||||
} else {
|
|
||||||
if (dive) {
|
|
||||||
inReplyTo = await this.renderUpNote(inReplyToNote, inReplyToUser, false);
|
|
||||||
} else {
|
|
||||||
inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
inReplyTo = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let quote;
|
|
||||||
|
|
||||||
if (note.renoteId) {
|
|
||||||
const renote = await this.notesRepository.findOneBy({ id: note.renoteId });
|
|
||||||
|
|
||||||
if (renote) {
|
|
||||||
quote = renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const attributedTo = this.userEntityService.genLocalUserUri(note.userId);
|
|
||||||
|
|
||||||
const mentions = note.mentionedRemoteUsers ? (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri) : [];
|
|
||||||
|
|
||||||
let to: string[] = [];
|
|
||||||
let cc: string[] = [];
|
|
||||||
|
|
||||||
if (note.visibility === 'public') {
|
|
||||||
to = ['https://www.w3.org/ns/activitystreams#Public'];
|
|
||||||
cc = [`${attributedTo}/followers`].concat(mentions);
|
|
||||||
} else if (note.visibility === 'home') {
|
|
||||||
to = [`${attributedTo}/followers`];
|
|
||||||
cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions);
|
|
||||||
} else if (note.visibility === 'followers') {
|
|
||||||
to = [`${attributedTo}/followers`];
|
|
||||||
cc = mentions;
|
|
||||||
} else {
|
|
||||||
to = mentions;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mentionedUsers = note.mentions && note.mentions.length > 0 ? await this.usersRepository.findBy({
|
|
||||||
id: In(note.mentions),
|
|
||||||
}) : [];
|
|
||||||
|
|
||||||
const hashtagTags = note.tags.map(tag => this.renderHashtag(tag));
|
|
||||||
const mentionTags = mentionedUsers.map(u => this.renderMention(u as MiLocalUser | MiRemoteUser));
|
|
||||||
|
|
||||||
const files = await getPromisedFiles(note.fileIds);
|
|
||||||
|
|
||||||
const text = note.text ?? '';
|
|
||||||
let poll: MiPoll | null = null;
|
|
||||||
|
|
||||||
if (note.hasPoll) {
|
|
||||||
poll = await this.pollsRepository.findOneBy({ noteId: note.id });
|
|
||||||
}
|
|
||||||
|
|
||||||
const apAppend: Appender[] = [];
|
|
||||||
|
|
||||||
if (quote) {
|
|
||||||
// Append quote link as `<br><br><span class="quote-inline">RE: <a href="...">...</a></span>`
|
|
||||||
// the claas name `quote-inline` is used in non-misskey clients for styling quote notes.
|
|
||||||
// For compatibility, the span part should be kept as possible.
|
|
||||||
apAppend.push((doc, body) => {
|
|
||||||
body.appendChild(doc.createElement('br'));
|
|
||||||
body.appendChild(doc.createElement('br'));
|
|
||||||
const span = doc.createElement('span');
|
|
||||||
span.className = 'quote-inline';
|
|
||||||
span.appendChild(doc.createTextNode('RE: '));
|
|
||||||
const link = doc.createElement('a');
|
|
||||||
link.setAttribute('href', quote);
|
|
||||||
link.textContent = quote;
|
|
||||||
span.appendChild(link);
|
|
||||||
body.appendChild(span);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
|
|
||||||
|
|
||||||
// Apply mandatory CW, if applicable
|
|
||||||
if (author.mandatoryCW) {
|
|
||||||
summary = appendContentWarning(summary, author.mandatoryCW);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { content } = this.apMfmService.getNoteHtml(note, apAppend);
|
|
||||||
|
|
||||||
const emojis = await this.getEmojis(note.emojis);
|
|
||||||
const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
|
|
||||||
|
|
||||||
const tag = [
|
|
||||||
...hashtagTags,
|
|
||||||
...mentionTags,
|
|
||||||
...apemojis,
|
|
||||||
];
|
|
||||||
|
|
||||||
const asPoll = poll ? {
|
|
||||||
type: 'Question',
|
|
||||||
[poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt,
|
|
||||||
[poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({
|
|
||||||
type: 'Note',
|
|
||||||
name: text,
|
|
||||||
replies: {
|
|
||||||
type: 'Collection',
|
|
||||||
totalItems: poll!.votes[i],
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
} as const : {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `${this.config.url}/notes/${note.id}`,
|
|
||||||
type: 'Note',
|
|
||||||
attributedTo,
|
|
||||||
summary: summary ?? undefined,
|
|
||||||
content: content ?? undefined,
|
|
||||||
updated: note.updatedAt?.toISOString(),
|
|
||||||
_misskey_content: text,
|
|
||||||
source: {
|
|
||||||
content: text,
|
|
||||||
mediaType: 'text/x.misskeymarkdown',
|
|
||||||
},
|
|
||||||
_misskey_quote: quote,
|
|
||||||
quoteUrl: quote,
|
|
||||||
quoteUri: quote,
|
|
||||||
published: this.idService.parse(note.id).date.toISOString(),
|
|
||||||
to,
|
|
||||||
cc,
|
|
||||||
inReplyTo,
|
|
||||||
attachment: files.map(x => this.renderDocument(x)),
|
|
||||||
sensitive: note.cw != null || files.some(file => file.isSensitive),
|
|
||||||
tag,
|
|
||||||
...asPoll,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public renderVote(user: { id: MiUser['id'] }, vote: MiPollVote, note: MiNote, poll: MiPoll, pollOwner: MiRemoteUser): ICreate {
|
public renderVote(user: { id: MiUser['id'] }, vote: MiPollVote, note: MiNote, poll: MiPoll, pollOwner: MiRemoteUser): ICreate {
|
||||||
return {
|
return {
|
||||||
|
|
@ -935,9 +799,7 @@ export class ApRendererService {
|
||||||
|
|
||||||
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
||||||
|
|
||||||
const jsonLd = this.jsonLdService.use();
|
activity = await this.jsonLdService.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`);
|
||||||
jsonLd.debug = false;
|
|
||||||
activity = await jsonLd.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`);
|
|
||||||
|
|
||||||
return activity;
|
return activity;
|
||||||
}
|
}
|
||||||
|
|
@ -1051,6 +913,27 @@ export class ApRendererService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async renderNoteOrRenoteActivity(note: MiNote, user: MiUser, hint?: { renote?: MiNote | null }) {
|
||||||
|
if (note.localOnly) return null;
|
||||||
|
|
||||||
|
if (isPureRenote(note)) {
|
||||||
|
const renote = hint?.renote ?? note.renote ?? await this.notesRepository.findOneByOrFail({ id: note.renoteId });
|
||||||
|
const apAnnounce = this.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note);
|
||||||
|
return this.addContext(apAnnounce);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apNote = await this.renderNote(note, user, false);
|
||||||
|
|
||||||
|
if (note.updatedAt != null) {
|
||||||
|
const apUpdate = this.renderUpdate(apNote, user);
|
||||||
|
return this.addContext(apUpdate);
|
||||||
|
} else {
|
||||||
|
const apCreate = this.renderCreate(apNote, note);
|
||||||
|
return this.addContext(apCreate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async getEmojis(names: string[]): Promise<MiEmoji[]> {
|
private async getEmojis(names: string[]): Promise<MiEmoji[]> {
|
||||||
if (names.length === 0) return [];
|
if (names.length === 0) return [];
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
import * as crypto from 'node:crypto';
|
import * as crypto from 'node:crypto';
|
||||||
import { URL } from 'node:url';
|
import { URL } from 'node:url';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Window } from 'happy-dom';
|
import { load as cheerio } from 'cheerio/slim';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import type { MiUser } from '@/models/User.js';
|
import type { MiUser } from '@/models/User.js';
|
||||||
|
|
@ -18,6 +18,8 @@ import { bindThis } from '@/decorators.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||||
import type { IObject, IObjectWithId } from './type.js';
|
import type { IObject, IObjectWithId } from './type.js';
|
||||||
|
import type { Cheerio, CheerioAPI } from 'cheerio/slim';
|
||||||
|
import type { AnyNode } from 'domhandler';
|
||||||
|
|
||||||
type Request = {
|
type Request = {
|
||||||
url: string;
|
url: string;
|
||||||
|
|
@ -184,10 +186,11 @@ export class ApRequestService {
|
||||||
* Get AP object with http-signature
|
* Get AP object with http-signature
|
||||||
* @param user http-signature user
|
* @param user http-signature user
|
||||||
* @param url URL to fetch
|
* @param url URL to fetch
|
||||||
* @param followAlternate
|
* @param allowAnonymous If a fetched object lacks an ID, then it will be auto-generated from the final URL. (default: false)
|
||||||
|
* @param followAlternate Whether to resolve HTML responses to their referenced canonical AP endpoint. (default: true)
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<IObjectWithId> {
|
public async signedGet(url: string, user: { id: MiUser['id'] }, allowAnonymous = false, followAlternate?: boolean): Promise<IObjectWithId> {
|
||||||
this.apUtilityService.assertApUrl(url);
|
this.apUtilityService.assertApUrl(url);
|
||||||
|
|
||||||
const _followAlternate = followAlternate ?? true;
|
const _followAlternate = followAlternate ?? true;
|
||||||
|
|
@ -218,53 +221,33 @@ export class ApRequestService {
|
||||||
(contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' &&
|
(contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' &&
|
||||||
_followAlternate === true
|
_followAlternate === true
|
||||||
) {
|
) {
|
||||||
const html = await res.text();
|
let alternate: Cheerio<AnyNode> | null;
|
||||||
const { window, happyDOM } = new Window({
|
|
||||||
settings: {
|
|
||||||
disableJavaScriptEvaluation: true,
|
|
||||||
disableJavaScriptFileLoading: true,
|
|
||||||
disableCSSFileLoading: true,
|
|
||||||
disableComputedStyleRendering: true,
|
|
||||||
handleDisabledFileLoadingAsSuccess: true,
|
|
||||||
navigation: {
|
|
||||||
disableMainFrameNavigation: true,
|
|
||||||
disableChildFrameNavigation: true,
|
|
||||||
disableChildPageNavigation: true,
|
|
||||||
disableFallbackToSetURL: true,
|
|
||||||
},
|
|
||||||
timer: {
|
|
||||||
maxTimeout: 0,
|
|
||||||
maxIntervalTime: 0,
|
|
||||||
maxIntervalIterations: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const document = window.document;
|
|
||||||
try {
|
try {
|
||||||
document.documentElement.innerHTML = html;
|
const html = await res.text();
|
||||||
|
const document = cheerio(html);
|
||||||
|
|
||||||
// Search for any matching value in priority order:
|
// Search for any matching value in priority order:
|
||||||
// 1. Type=AP > Type=none > Type=anything
|
// 1. Type=AP > Type=none > Type=anything
|
||||||
// 2. Alternate > Canonical
|
// 2. Alternate > Canonical
|
||||||
// 3. Page order (fallback)
|
// 3. Page order (fallback)
|
||||||
const alternate =
|
alternate = selectFirst(document, [
|
||||||
document.querySelector('head > link[href][rel="alternate"][type="application/activity+json"]') ??
|
'head > link[href][rel="alternate"][type="application/activity+json"]',
|
||||||
document.querySelector('head > link[href][rel="canonical"][type="application/activity+json"]') ??
|
'head > link[href][rel="canonical"][type="application/activity+json"]',
|
||||||
document.querySelector('head > link[href][rel="alternate"]:not([type])') ??
|
'head > link[href][rel="alternate"]:not([type])',
|
||||||
document.querySelector('head > link[href][rel="canonical"]:not([type])') ??
|
'head > link[href][rel="canonical"]:not([type])',
|
||||||
document.querySelector('head > link[href][rel="alternate"]') ??
|
'head > link[href][rel="alternate"]',
|
||||||
document.querySelector('head > link[href][rel="canonical"]');
|
'head > link[href][rel="canonical"]',
|
||||||
|
]);
|
||||||
if (alternate) {
|
|
||||||
const href = alternate.getAttribute('href');
|
|
||||||
if (href && this.apUtilityService.haveSameAuthority(url, href)) {
|
|
||||||
return await this.signedGet(href, user, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// something went wrong parsing the HTML, ignore the whole thing
|
// something went wrong parsing the HTML, ignore the whole thing
|
||||||
} finally {
|
alternate = null;
|
||||||
happyDOM.close().catch(err => {});
|
}
|
||||||
|
|
||||||
|
if (alternate) {
|
||||||
|
const href = alternate.attr('href');
|
||||||
|
if (href && this.apUtilityService.haveSameAuthority(url, href)) {
|
||||||
|
return await this.signedGet(href, user, allowAnonymous, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
@ -275,8 +258,23 @@ export class ApRequestService {
|
||||||
|
|
||||||
// Make sure the object ID matches the final URL (which is where it actually exists).
|
// Make sure the object ID matches the final URL (which is where it actually exists).
|
||||||
// The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match.
|
// The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match.
|
||||||
this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url);
|
if (allowAnonymous && activity.id == null) {
|
||||||
|
activity.id = res.url;
|
||||||
|
} else {
|
||||||
|
this.apUtilityService.assertIdMatchesUrlAuthority(activity, res.url);
|
||||||
|
}
|
||||||
|
|
||||||
return activity as IObjectWithId;
|
return activity as IObjectWithId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectFirst($: CheerioAPI, selectors: string[]): Cheerio<AnyNode> | null {
|
||||||
|
for (const selector of selectors) {
|
||||||
|
const selection = $(selector);
|
||||||
|
if (selection.length > 0) {
|
||||||
|
return selection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { IsNull, Not } from 'typeorm';
|
import { IsNull, Not } from 'typeorm';
|
||||||
|
import promiseLimit from 'promise-limit';
|
||||||
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||||
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta, SkApFetchLog } from '@/models/_.js';
|
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta, SkApFetchLog } from '@/models/_.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
|
|
@ -19,11 +20,14 @@ import { ApLogService, calculateDurationSince, extractObjectContext } from '@/co
|
||||||
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
||||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import { getApId, getNullableApId, IObjectWithId, isCollectionOrOrderedCollection } from './type.js';
|
import { toArray } from '@/misc/prelude/array.js';
|
||||||
|
import { isPureRenote } from '@/misc/is-renote.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
import { AnyCollection, getApId, getNullableApId, IObjectWithId, isCollection, isCollectionOrOrderedCollection, isCollectionPage, isOrderedCollection, isOrderedCollectionPage } from './type.js';
|
||||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||||
import { ApRendererService } from './ApRendererService.js';
|
import { ApRendererService } from './ApRendererService.js';
|
||||||
import { ApRequestService } from './ApRequestService.js';
|
import { ApRequestService } from './ApRequestService.js';
|
||||||
import type { IObject, ICollection, IOrderedCollection, ApObject } from './type.js';
|
import type { IObject, ApObject, IAnonymousObject } from './type.js';
|
||||||
|
|
||||||
export class Resolver {
|
export class Resolver {
|
||||||
private history: Set<string>;
|
private history: Set<string>;
|
||||||
|
|
@ -47,6 +51,7 @@ export class Resolver {
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
private readonly apLogService: ApLogService,
|
private readonly apLogService: ApLogService,
|
||||||
private readonly apUtilityService: ApUtilityService,
|
private readonly apUtilityService: ApUtilityService,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
private recursionLimit = 256,
|
private recursionLimit = 256,
|
||||||
) {
|
) {
|
||||||
this.history = new Set();
|
this.history = new Set();
|
||||||
|
|
@ -63,34 +68,129 @@ export class Resolver {
|
||||||
return this.recursionLimit;
|
return this.recursionLimit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async resolveCollection(value: string | IObjectWithId, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection & IObjectWithId>;
|
||||||
|
public async resolveCollection(value: string | IObject, allowAnonymous: boolean | undefined, sentFromUri: string): Promise<AnyCollection & IObjectWithId>;
|
||||||
|
public async resolveCollection(value: string | IObject, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection>;
|
||||||
@bindThis
|
@bindThis
|
||||||
public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
|
public async resolveCollection(value: string | IObject, allowAnonymous?: boolean, sentFromUri?: string): Promise<AnyCollection> {
|
||||||
const collection = typeof value === 'string'
|
const collection = typeof value === 'string'
|
||||||
? await this.resolve(value)
|
? sentFromUri
|
||||||
: value;
|
? await this.secureResolve(value, sentFromUri, allowAnonymous)
|
||||||
|
: await this.resolve(value, allowAnonymous)
|
||||||
|
: value; // TODO try and remove this eventually, as it's a major security foot-gun
|
||||||
|
|
||||||
if (isCollectionOrOrderedCollection(collection)) {
|
if (isCollectionOrOrderedCollection(collection)) {
|
||||||
return collection;
|
return collection;
|
||||||
} else {
|
} else {
|
||||||
throw new IdentifiableError('f100eccf-f347-43fb-9b45-96a0831fb635', `unrecognized collection type: ${collection.type}`);
|
throw new IdentifiableError('f100eccf-f347-43fb-9b45-96a0831fb635', `collection ${getApId(value)} has unsupported type: ${collection.type}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async resolveCollectionItems(collection: IAnonymousObject, limit?: number | null, allowAnonymousItems?: true, concurrency?: number): Promise<IAnonymousObject[]>;
|
||||||
|
public async resolveCollectionItems(collection: string | IObjectWithId, limit?: number | null, allowAnonymousItems?: boolean, concurrency?: number): Promise<IObjectWithId[]>;
|
||||||
|
public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean, concurrency?: number): Promise<IObject[]>;
|
||||||
|
/**
|
||||||
|
* Recursively resolves items from a collection.
|
||||||
|
* Stops when reaching the resolution limit or an optional item limit - whichever is lower.
|
||||||
|
* This method supports Collection, OrderedCollection, and individual pages of either type.
|
||||||
|
* Malformed collections (mixing Ordered and un-Ordered types) are also supported.
|
||||||
|
* @param collection Collection to resolve from - can be a URL or object of any supported collection type.
|
||||||
|
* @param limit Maximum number of items to resolve. If null or undefined (default), then items will be resolved until reaching the recursion limit.
|
||||||
|
* @param allowAnonymousItems If true, collection items can be anonymous (lack an ID). If false (default), then an error is thrown when reaching an item without ID.
|
||||||
|
* @param concurrency Maximum number of items to resolve at once. (default: 4)
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async resolveCollectionItems(collection: string | IObject, limit?: number | null, allowAnonymousItems?: boolean, concurrency = 4): Promise<IObject[]> {
|
||||||
|
const resolvedItems: IObject[] = [];
|
||||||
|
|
||||||
|
// This is pulled up to avoid code duplication below
|
||||||
|
const iterate = async(items: ApObject, current: AnyCollection) => {
|
||||||
|
const sentFrom = current.id;
|
||||||
|
const itemArr = toArray(items);
|
||||||
|
const itemLimit = limit ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const allowAnonymous = allowAnonymousItems ?? false;
|
||||||
|
await this.resolveItemArray(itemArr, sentFrom, itemLimit, concurrency, allowAnonymous, resolvedItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
let current: AnyCollection | null = await this.resolveCollection(collection);
|
||||||
|
do {
|
||||||
|
// Iterate all items in the current page
|
||||||
|
if (current.items) {
|
||||||
|
await iterate(current.items, current);
|
||||||
|
}
|
||||||
|
if (current.orderedItems) {
|
||||||
|
await iterate(current.orderedItems, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.history.size >= this.recursionLimit) {
|
||||||
|
// Stop when we reach the fetch limit
|
||||||
|
current = null;
|
||||||
|
} else if (limit != null && resolvedItems.length >= limit) {
|
||||||
|
// Stop when we reach the item limit
|
||||||
|
current = null;
|
||||||
|
} else if (isCollection(current) || isOrderedCollection(current)) {
|
||||||
|
// Continue to first page
|
||||||
|
current = current.first ? await this.resolveCollection(current.first, true, current.id) : null;
|
||||||
|
} else if (isCollectionPage(current) || isOrderedCollectionPage(current)) {
|
||||||
|
// Continue to next page
|
||||||
|
current = current.next ? await this.resolveCollection(current.next, true, current.id) : null;
|
||||||
|
} else {
|
||||||
|
// Stop in all other conditions
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
} while (current != null);
|
||||||
|
|
||||||
|
return resolvedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveItemArray(source: (string | IObject)[], sentFrom: undefined, itemLimit: number, concurrency: number, allowAnonymousItems: true, destination: IAnonymousObject[]): Promise<void>;
|
||||||
|
private async resolveItemArray(source: (string | IObject)[], sentFrom: string, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObjectWithId[]): Promise<void>;
|
||||||
|
private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[]): Promise<void>;
|
||||||
|
private async resolveItemArray(source: (string | IObject)[], sentFrom: string | undefined, itemLimit: number, concurrency: number, allowAnonymousItems: boolean, destination: IObject[]): Promise<void> {
|
||||||
|
const recursionLimit = this.recursionLimit - this.history.size;
|
||||||
|
const batchLimit = Math.min(source.length, recursionLimit, itemLimit);
|
||||||
|
|
||||||
|
const limiter = promiseLimit<IObject>(concurrency);
|
||||||
|
const batch = await Promise.all(source
|
||||||
|
.slice(0, batchLimit)
|
||||||
|
.map(item => limiter(async () => {
|
||||||
|
if (sentFrom) {
|
||||||
|
// Use secureResolve to avoid re-fetching items that were included inline.
|
||||||
|
return await this.secureResolve(item, sentFrom, allowAnonymousItems);
|
||||||
|
} else if (allowAnonymousItems) {
|
||||||
|
return await this.resolveAnonymous(item);
|
||||||
|
} else {
|
||||||
|
// ID is required if we have neither sentFrom not allowAnonymousItems
|
||||||
|
const id = getApId(item);
|
||||||
|
return await this.resolve(id);
|
||||||
|
}
|
||||||
|
})));
|
||||||
|
|
||||||
|
destination.push(...batch);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Securely resolves an AP object or URL that has been sent from another instance.
|
* Securely resolves an AP object or URL that has been sent from another instance.
|
||||||
* An input object is trusted if and only if its ID matches the authority of sentFromUri.
|
* An input object is trusted if and only if its ID matches the authority of sentFromUri.
|
||||||
* In all other cases, the object is re-fetched from remote by input string or object ID.
|
* In all other cases, the object is re-fetched from remote by input string or object ID.
|
||||||
|
* @param input The input object or URL to resolve
|
||||||
|
* @param sentFromUri The URL where this object originated. This MUST be accurate - all security checks depend on this value!
|
||||||
|
* @param allowAnonymous If true, anonymous objects are allowed and will have their ID set to sentFromUri. If false (default) then anonymous objects will be rejected with an error.
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async secureResolve(input: ApObject, sentFromUri: string): Promise<IObjectWithId> {
|
public async secureResolve(input: string | IObject | [string | IObject], sentFromUri: string, allowAnonymous?: boolean): Promise<IObjectWithId> {
|
||||||
// Unpack arrays to get the value element.
|
// Unpack arrays to get the value element.
|
||||||
const value = fromTuple(input);
|
const value = fromTuple(input);
|
||||||
if (value == null) {
|
|
||||||
throw new IdentifiableError('20058164-9de1-4573-8715-425753a21c1d', 'Cannot resolve null input');
|
// If anonymous input is allowed, then any object is automatically valid if we set the ID.
|
||||||
|
// We can short-circuit here and avoid un-necessary checks.
|
||||||
|
if (allowAnonymous && typeof(value) === 'object' && value.id == null) {
|
||||||
|
value.id = sentFromUri;
|
||||||
|
return value as IObjectWithId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This will throw if the input has no ID, which is good because we can't verify an anonymous object anyway.
|
// This ensures the input has a string ID, protecting against type confusion and rejecting anonymous objects.
|
||||||
const id = getApId(value);
|
const id = getApId(value, sentFromUri);
|
||||||
|
|
||||||
// Check if we can use the provided object as-is.
|
// Check if we can use the provided object as-is.
|
||||||
// Our security requires that the object ID matches the host authority that sent it, otherwise it can't be trusted.
|
// Our security requires that the object ID matches the host authority that sent it, otherwise it can't be trusted.
|
||||||
|
|
@ -100,28 +200,52 @@ export class Resolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the checks didn't pass, then we must fetch the object and use that.
|
// If the checks didn't pass, then we must fetch the object and use that.
|
||||||
return await this.resolve(id);
|
return await this.resolve(id, allowAnonymous);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async resolve(value: string | [string]): Promise<IObjectWithId>;
|
/**
|
||||||
public async resolve(value: string | IObject | [string | IObject]): Promise<IObject>;
|
* Resolves an anonymous object.
|
||||||
|
* The returned value will not have any ID present.
|
||||||
|
* If one is provided in the response, it will be removed automatically.
|
||||||
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async resolve(value: string | IObject | [string | IObject]): Promise<IObject> {
|
public async resolveAnonymous(value: string | IObject | [string | IObject]): Promise<IAnonymousObject> {
|
||||||
value = fromTuple(value);
|
value = fromTuple(value);
|
||||||
|
|
||||||
|
const object = await this.resolve(value);
|
||||||
|
object.id = undefined;
|
||||||
|
|
||||||
|
return object as IAnonymousObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async resolve(value: string | [string], allowAnonymous?: boolean): Promise<IObjectWithId>;
|
||||||
|
public async resolve(value: string | IObjectWithId | [string | IObjectWithId], allowAnonymous?: boolean): Promise<IObjectWithId>;
|
||||||
|
public async resolve(value: string | IObject | [string | IObject], allowAnonymous?: boolean): Promise<IObject>;
|
||||||
|
/**
|
||||||
|
* Resolves a URL or object to an AP object.
|
||||||
|
* Tuples are expanded to their first element before anything else, and non-string inputs are returned as-is.
|
||||||
|
* Otherwise, the string URL is fetched and validated to represent a valid ActivityPub object.
|
||||||
|
* @param value The input value to resolve
|
||||||
|
* @param allowAnonymous Determines what to do if a response object lacks an ID field. If false (default), then an exception is thrown. If true, then the ID is populated from the final response URL.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async resolve(value: string | IObject | [string | IObject], allowAnonymous = false): Promise<IObject> {
|
||||||
|
value = fromTuple(value);
|
||||||
|
|
||||||
|
// TODO try and remove this eventually, as it's a major security foot-gun
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = this.utilityService.extractDbHost(value);
|
const host = this.utilityService.extractDbHost(value);
|
||||||
if (this.config.activityLogging.enabled && !this.utilityService.isSelfHost(host)) {
|
if (this.config.activityLogging.enabled && !this.utilityService.isSelfHost(host)) {
|
||||||
return await this._resolveLogged(value, host);
|
return await this._resolveLogged(value, host, allowAnonymous);
|
||||||
} else {
|
} else {
|
||||||
return await this._resolve(value, host);
|
return await this._resolve(value, host, allowAnonymous);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _resolveLogged(requestUri: string, host: string): Promise<IObjectWithId> {
|
private async _resolveLogged(requestUri: string, host: string, allowAnonymous: boolean): Promise<IObjectWithId> {
|
||||||
const startTime = process.hrtime.bigint();
|
const startTime = process.hrtime.bigint();
|
||||||
|
|
||||||
const log = await this.apLogService.createFetchLog({
|
const log = await this.apLogService.createFetchLog({
|
||||||
|
|
@ -130,7 +254,7 @@ export class Resolver {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this._resolve(requestUri, host, log);
|
const result = await this._resolve(requestUri, host, allowAnonymous, log);
|
||||||
|
|
||||||
log.accepted = true;
|
log.accepted = true;
|
||||||
log.result = 'ok';
|
log.result = 'ok';
|
||||||
|
|
@ -150,20 +274,20 @@ export class Resolver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _resolve(value: string, host: string, log?: SkApFetchLog): Promise<IObjectWithId> {
|
private async _resolve(value: string, host: string, allowAnonymous: boolean, log?: SkApFetchLog): Promise<IObjectWithId> {
|
||||||
if (value.includes('#')) {
|
if (value.includes('#')) {
|
||||||
// URLs with fragment parts cannot be resolved correctly because
|
// URLs with fragment parts cannot be resolved correctly because
|
||||||
// the fragment part does not get transmitted over HTTP(S).
|
// the fragment part does not get transmitted over HTTP(S).
|
||||||
// Avoid strange behaviour by not trying to resolve these at all.
|
// Avoid strange behaviour by not trying to resolve these at all.
|
||||||
throw new IdentifiableError('b94fd5b1-0e3b-4678-9df2-dad4cd515ab2', `cannot resolve URL with fragment: ${value}`);
|
throw new IdentifiableError('b94fd5b1-0e3b-4678-9df2-dad4cd515ab2', `failed to resolve ${value}: URL contains fragment`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.history.has(value)) {
|
if (this.history.has(value)) {
|
||||||
throw new IdentifiableError('0dc86cf6-7cd6-4e56-b1e6-5903d62d7ea5', `cannot resolve already resolved URL: ${value}`);
|
throw new IdentifiableError('0dc86cf6-7cd6-4e56-b1e6-5903d62d7ea5', `failed to resolve ${value}: recursive resolution blocked`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.history.size > this.recursionLimit) {
|
if (this.history.size > this.recursionLimit) {
|
||||||
throw new IdentifiableError('d592da9f-822f-4d91-83d7-4ceefabcf3d2', `hit recursion limit: ${value}`);
|
throw new IdentifiableError('d592da9f-822f-4d91-83d7-4ceefabcf3d2', `failed to resolve ${value}: hit recursion limit`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.history.add(value);
|
this.history.add(value);
|
||||||
|
|
@ -173,7 +297,7 @@ export class Resolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.utilityService.isFederationAllowedHost(host)) {
|
if (!this.utilityService.isFederationAllowedHost(host)) {
|
||||||
throw new IdentifiableError('09d79f9e-64f1-4316-9cfa-e75c4d091574', `cannot fetch AP object ${value}: blocked instance ${host}`);
|
throw new IdentifiableError('09d79f9e-64f1-4316-9cfa-e75c4d091574', `failed to resolve ${value}: instance ${host} is blocked`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.config.signToActivityPubGet && !this.user) {
|
if (this.config.signToActivityPubGet && !this.user) {
|
||||||
|
|
@ -181,8 +305,8 @@ export class Resolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
const object = (this.user
|
const object = (this.user
|
||||||
? await this.apRequestService.signedGet(value, this.user)
|
? await this.apRequestService.signedGet(value, this.user, allowAnonymous)
|
||||||
: await this.httpRequestService.getActivityJson(value));
|
: await this.httpRequestService.getActivityJson(value, false, allowAnonymous));
|
||||||
|
|
||||||
if (log) {
|
if (log) {
|
||||||
const { object: objectOnly, context, contextHash } = extractObjectContext(object);
|
const { object: objectOnly, context, contextHash } = extractObjectContext(object);
|
||||||
|
|
@ -203,12 +327,12 @@ export class Resolver {
|
||||||
!(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') :
|
!(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') :
|
||||||
object['@context'] !== 'https://www.w3.org/ns/activitystreams'
|
object['@context'] !== 'https://www.w3.org/ns/activitystreams'
|
||||||
) {
|
) {
|
||||||
throw new IdentifiableError('72180409-793c-4973-868e-5a118eb5519b', `invalid AP object ${value}: does not have ActivityStreams context`);
|
throw new IdentifiableError('72180409-793c-4973-868e-5a118eb5519b', `failed to resolve ${value}: response does not have ActivityStreams context`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The object ID is already validated to match the final URL's authority by signedGet / getActivityJson.
|
// The object ID is already validated to match the final URL's authority by signedGet / getActivityJson.
|
||||||
// We only need to validate that it also matches the original URL's authority, in case of redirects.
|
// We only need to validate that it also matches the original URL's authority, in case of redirects.
|
||||||
const objectId = getApId(object);
|
const objectId = getApId(object, value);
|
||||||
|
|
||||||
// We allow some limited cross-domain redirects, which means the host may have changed during fetch.
|
// We allow some limited cross-domain redirects, which means the host may have changed during fetch.
|
||||||
// Additional checks are needed to validate the scope of cross-domain redirects.
|
// Additional checks are needed to validate the scope of cross-domain redirects.
|
||||||
|
|
@ -219,64 +343,65 @@ export class Resolver {
|
||||||
|
|
||||||
// Check if the redirect bounce from [allowed domain] to [blocked domain].
|
// Check if the redirect bounce from [allowed domain] to [blocked domain].
|
||||||
if (!this.utilityService.isFederationAllowedHost(finalHost)) {
|
if (!this.utilityService.isFederationAllowedHost(finalHost)) {
|
||||||
throw new IdentifiableError('0a72bf24-2d9b-4f1d-886b-15aaa31adeda', `cannot fetch AP object ${value}: redirected to blocked instance ${finalHost}`);
|
throw new IdentifiableError('0a72bf24-2d9b-4f1d-886b-15aaa31adeda', `failed to resolve ${value}: redirected to blocked instance ${finalHost}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return object;
|
return object;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO try to remove this, as it creates a large attack surface
|
||||||
@bindThis
|
@bindThis
|
||||||
private resolveLocal(url: string): Promise<IObjectWithId> {
|
private resolveLocal(url: string): Promise<IObjectWithId> {
|
||||||
const parsed = this.apDbResolverService.parseUri(url);
|
const parsed = this.apDbResolverService.parseUri(url);
|
||||||
if (!parsed.local) throw new IdentifiableError('02b40cd0-fa92-4b0c-acc9-fb2ada952ab8', `resolveLocal - not a local URL: ${url}`);
|
if (!parsed.local) throw new IdentifiableError('02b40cd0-fa92-4b0c-acc9-fb2ada952ab8', `failed to resolve local ${url}: not a local URL`);
|
||||||
|
|
||||||
switch (parsed.type) {
|
switch (parsed.type) {
|
||||||
case 'notes':
|
case 'notes':
|
||||||
return this.notesRepository.findOneByOrFail({ id: parsed.id })
|
return this.notesRepository.findOneOrFail({ where: { id: parsed.id, userHost: IsNull() }, relations: { user: true, renote: true } })
|
||||||
.then(async note => {
|
.then(async note => {
|
||||||
const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
|
const author = note.user ?? await this.cacheService.findUserById(note.userId);
|
||||||
if (parsed.rest === 'activity') {
|
if (parsed.rest === 'activity') {
|
||||||
// this refers to the create activity and not the note itself
|
return await this.apRendererService.renderNoteOrRenoteActivity(note, author);
|
||||||
return this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author), note));
|
} else if (!isPureRenote(note)) {
|
||||||
|
const apNote = await this.apRendererService.renderNote(note, author);
|
||||||
|
return this.apRendererService.addContext(apNote);
|
||||||
} else {
|
} else {
|
||||||
return this.apRendererService.renderNote(note, author);
|
throw new IdentifiableError('732c2633-3395-4d51-a9b7-c7084774e3e7', `Failed to resolve local ${url}: cannot resolve a boost as note`);
|
||||||
}
|
}
|
||||||
}) as Promise<IObjectWithId>;
|
}) as Promise<IObjectWithId>;
|
||||||
case 'users':
|
case 'users':
|
||||||
return this.usersRepository.findOneByOrFail({ id: parsed.id })
|
return this.cacheService.findLocalUserById(parsed.id)
|
||||||
.then(user => this.apRendererService.renderPerson(user as MiLocalUser));
|
.then(user => this.apRendererService.renderPerson(user as MiLocalUser));
|
||||||
case 'questions':
|
case 'questions':
|
||||||
// Polls are indexed by the note they are attached to.
|
// Polls are indexed by the note they are attached to.
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
this.notesRepository.findOneByOrFail({ id: parsed.id }),
|
this.notesRepository.findOneByOrFail({ id: parsed.id, userHost: IsNull() }),
|
||||||
this.pollsRepository.findOneByOrFail({ noteId: parsed.id }),
|
this.pollsRepository.findOneByOrFail({ noteId: parsed.id, userHost: IsNull() }),
|
||||||
])
|
])
|
||||||
.then(([note, poll]) => this.apRendererService.renderQuestion({ id: note.userId }, note, poll)) as Promise<IObjectWithId>;
|
.then(([note, poll]) => this.apRendererService.renderQuestion({ id: note.userId }, note, poll)) as Promise<IObjectWithId>;
|
||||||
case 'likes':
|
case 'likes':
|
||||||
return this.noteReactionsRepository.findOneByOrFail({ id: parsed.id }).then(async reaction =>
|
return this.noteReactionsRepository.findOneOrFail({ where: { id: parsed.id }, relations: { user: true } }).then(async reaction => {
|
||||||
this.apRendererService.addContext(await this.apRendererService.renderLike(reaction, { uri: null })));
|
if (reaction.user?.host != null) {
|
||||||
|
throw new IdentifiableError('02b40cd0-fa92-4b0c-acc9-fb2ada952ab8', `failed to resolve local ${url}: not a local reaction`);
|
||||||
|
}
|
||||||
|
return this.apRendererService.addContext(await this.apRendererService.renderLike(reaction, { uri: null }));
|
||||||
|
});
|
||||||
case 'follows':
|
case 'follows':
|
||||||
return this.followRequestsRepository.findOneBy({ id: parsed.id })
|
return this.followRequestsRepository.findOneBy({ id: parsed.id })
|
||||||
.then(async followRequest => {
|
.then(async followRequest => {
|
||||||
if (followRequest == null) throw new IdentifiableError('a9d946e5-d276-47f8-95fb-f04230289bb0', `resolveLocal - invalid follow request ID ${parsed.id}: ${url}`);
|
if (followRequest == null) throw new IdentifiableError('a9d946e5-d276-47f8-95fb-f04230289bb0', `failed to resolve local ${url}: invalid follow request ID`);
|
||||||
const [follower, followee] = await Promise.all([
|
const [follower, followee] = await Promise.all([
|
||||||
this.usersRepository.findOneBy({
|
this.cacheService.findLocalUserById(followRequest.followerId),
|
||||||
id: followRequest.followerId,
|
this.cacheService.findLocalUserById(followRequest.followeeId),
|
||||||
host: IsNull(),
|
|
||||||
}),
|
|
||||||
this.usersRepository.findOneBy({
|
|
||||||
id: followRequest.followeeId,
|
|
||||||
host: Not(IsNull()),
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
if (follower == null || followee == null) {
|
if (follower == null || followee == null) {
|
||||||
throw new IdentifiableError('06ae3170-1796-4d93-a697-2611ea6d83b6', `resolveLocal - follower or followee does not exist: ${url}`);
|
throw new IdentifiableError('06ae3170-1796-4d93-a697-2611ea6d83b6', `failed to resolve local ${url}: follower or followee does not exist`);
|
||||||
}
|
}
|
||||||
return this.apRendererService.addContext(this.apRendererService.renderFollow(follower as MiLocalUser | MiRemoteUser, followee as MiLocalUser | MiRemoteUser, url));
|
return this.apRendererService.addContext(this.apRendererService.renderFollow(follower as MiLocalUser | MiRemoteUser, followee as MiLocalUser | MiRemoteUser, url));
|
||||||
});
|
});
|
||||||
default:
|
default:
|
||||||
throw new IdentifiableError('7a5d2fc0-94bc-4db6-b8b8-1bf24a2e23d0', `resolveLocal: type ${parsed.type} unhandled: ${url}`);
|
throw new IdentifiableError('7a5d2fc0-94bc-4db6-b8b8-1bf24a2e23d0', `failed to resolve local ${url}: unsupported type ${parsed.type}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -314,6 +439,7 @@ export class ApResolverService {
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
private readonly apLogService: ApLogService,
|
private readonly apLogService: ApLogService,
|
||||||
private readonly apUtilityService: ApUtilityService,
|
private readonly apUtilityService: ApUtilityService,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -339,6 +465,7 @@ export class ApResolverService {
|
||||||
this.loggerService,
|
this.loggerService,
|
||||||
this.apLogService,
|
this.apLogService,
|
||||||
this.apUtilityService,
|
this.apUtilityService,
|
||||||
|
this.cacheService,
|
||||||
opts?.recursionLimit,
|
opts?.recursionLimit,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export class ApUtilityService {
|
||||||
public assertIdMatchesUrlAuthority(object: IObject, url: string): void {
|
public assertIdMatchesUrlAuthority(object: IObject, url: string): void {
|
||||||
// This throws if the ID is missing or invalid, but that's ok.
|
// This throws if the ID is missing or invalid, but that's ok.
|
||||||
// Anonymous objects are impossible to verify, so we don't allow fetching them.
|
// Anonymous objects are impossible to verify, so we don't allow fetching them.
|
||||||
const id = getApId(object);
|
const id = getApId(object, url);
|
||||||
|
|
||||||
// Make sure the object ID matches the final URL (which is where it actually exists).
|
// Make sure the object ID matches the final URL (which is where it actually exists).
|
||||||
// The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match.
|
// The caller (ApResolverService) will verify the ID against the original / entry URL, which ensures that all three match.
|
||||||
|
|
@ -80,7 +80,6 @@ export class ApUtilityService {
|
||||||
/**
|
/**
|
||||||
* Verifies that a provided URL is in a format acceptable for federation.
|
* Verifies that a provided URL is in a format acceptable for federation.
|
||||||
* @throws {IdentifiableError} If URL cannot be parsed
|
* @throws {IdentifiableError} If URL cannot be parsed
|
||||||
* @throws {IdentifiableError} If URL contains a fragment
|
|
||||||
* @throws {IdentifiableError} If URL is not HTTPS
|
* @throws {IdentifiableError} If URL is not HTTPS
|
||||||
*/
|
*/
|
||||||
public assertApUrl(url: string | URL): void {
|
public assertApUrl(url: string | URL): void {
|
||||||
|
|
@ -93,11 +92,6 @@ export class ApUtilityService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash component breaks federation
|
|
||||||
if (url.hash) {
|
|
||||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: contains a fragment (#)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Must be HTTPS
|
// Must be HTTPS
|
||||||
if (!this.checkHttps(url)) {
|
if (!this.checkHttps(url)) {
|
||||||
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: unsupported protocol ${url.protocol}`);
|
throw new IdentifiableError('0bedd29b-e3bf-4604-af51-d3352e2518af', `invalid AP url ${url}: unsupported protocol ${url.protocol}`);
|
||||||
|
|
|
||||||
|
|
@ -8,25 +8,61 @@ import { Injectable } from '@nestjs/common';
|
||||||
import { UnrecoverableError } from 'bullmq';
|
import { UnrecoverableError } from 'bullmq';
|
||||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import Logger from '@/logger.js';
|
||||||
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
|
import { StatusError } from '@/misc/status-error.js';
|
||||||
import { CONTEXT, PRELOADED_CONTEXTS } from './misc/contexts.js';
|
import { CONTEXT, PRELOADED_CONTEXTS } from './misc/contexts.js';
|
||||||
import { validateContentTypeSetAsJsonLD } from './misc/validator.js';
|
import { validateContentTypeSetAsJsonLD } from './misc/validator.js';
|
||||||
import type { JsonLdDocument } from 'jsonld';
|
import type { ContextDefinition, JsonLdDocument } from 'jsonld';
|
||||||
import type { JsonLd as JsonLdObject, RemoteDocument } from 'jsonld/jsonld-spec.js';
|
import type { JsonLd as JsonLdObject, RemoteDocument } from 'jsonld/jsonld-spec.js';
|
||||||
|
|
||||||
|
// https://stackoverflow.com/a/66252656
|
||||||
|
type RemoveIndex<T> = {
|
||||||
|
[ K in keyof T as string extends K
|
||||||
|
? never
|
||||||
|
: number extends K
|
||||||
|
? never
|
||||||
|
: symbol extends K
|
||||||
|
? never
|
||||||
|
: K
|
||||||
|
] : T[K];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Document = RemoveIndex<JsonLdDocument>;
|
||||||
|
|
||||||
|
export type Signature = {
|
||||||
|
id?: string;
|
||||||
|
type: string;
|
||||||
|
creator: string;
|
||||||
|
domain?: string;
|
||||||
|
nonce: string;
|
||||||
|
created: string;
|
||||||
|
signatureValue: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Signed<T extends Document> = T & {
|
||||||
|
signature: Signature;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isSigned<T extends Document>(doc: T): doc is Signed<T> {
|
||||||
|
return 'signature' in doc && typeof(doc.signature) === 'object';
|
||||||
|
}
|
||||||
|
|
||||||
// RsaSignature2017 implementation is based on https://github.com/transmute-industries/RsaSignature2017
|
// RsaSignature2017 implementation is based on https://github.com/transmute-industries/RsaSignature2017
|
||||||
|
|
||||||
class JsonLd {
|
@Injectable()
|
||||||
public debug = false;
|
export class JsonLdService {
|
||||||
public preLoad = true;
|
private readonly logger: Logger;
|
||||||
public loderTimeout = 5000;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private httpRequestService: HttpRequestService,
|
private httpRequestService: HttpRequestService,
|
||||||
|
loggerService: LoggerService,
|
||||||
) {
|
) {
|
||||||
|
this.logger = loggerService.getLogger('json-ld');
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async signRsaSignature2017(data: any, privateKey: string, creator: string, domain?: string, created?: Date): Promise<any> {
|
public async signRsaSignature2017<T extends Document>(data: T, privateKey: string, creator: string, domain?: string, created?: Date): Promise<Signed<T>> {
|
||||||
const options: {
|
const options: {
|
||||||
type: string;
|
type: string;
|
||||||
creator: string;
|
creator: string;
|
||||||
|
|
@ -62,7 +98,7 @@ class JsonLd {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async verifyRsaSignature2017(data: any, publicKey: string): Promise<boolean> {
|
public async verifyRsaSignature2017(data: Signed<Document>, publicKey: string): Promise<boolean> {
|
||||||
const toBeSigned = await this.createVerifyData(data, data.signature);
|
const toBeSigned = await this.createVerifyData(data, data.signature);
|
||||||
const verifier = crypto.createVerify('sha256');
|
const verifier = crypto.createVerify('sha256');
|
||||||
verifier.update(toBeSigned);
|
verifier.update(toBeSigned);
|
||||||
|
|
@ -70,7 +106,7 @@ class JsonLd {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async createVerifyData(data: any, options: any): Promise<string> {
|
public async createVerifyData<T extends Document>(data: T, options: Partial<Signature>): Promise<string> {
|
||||||
const transformedOptions = {
|
const transformedOptions = {
|
||||||
...options,
|
...options,
|
||||||
'@context': 'https://w3id.org/identity/v1',
|
'@context': 'https://w3id.org/identity/v1',
|
||||||
|
|
@ -80,17 +116,18 @@ class JsonLd {
|
||||||
delete transformedOptions['signatureValue'];
|
delete transformedOptions['signatureValue'];
|
||||||
const canonizedOptions = await this.normalize(transformedOptions);
|
const canonizedOptions = await this.normalize(transformedOptions);
|
||||||
const optionsHash = this.sha256(canonizedOptions.toString());
|
const optionsHash = this.sha256(canonizedOptions.toString());
|
||||||
const transformedData = { ...data };
|
const transformedData = { ...data } as T & { signature?: unknown };
|
||||||
delete transformedData['signature'];
|
delete transformedData['signature'];
|
||||||
const cannonidedData = await this.normalize(transformedData);
|
const cannonidedData = await this.normalize(transformedData);
|
||||||
if (this.debug) console.debug(`cannonidedData: ${cannonidedData}`);
|
this.logger.debug('cannonidedData', cannonidedData);
|
||||||
const documentHash = this.sha256(cannonidedData.toString());
|
const documentHash = this.sha256(cannonidedData.toString());
|
||||||
const verifyData = `${optionsHash}${documentHash}`;
|
const verifyData = `${optionsHash}${documentHash}`;
|
||||||
return verifyData;
|
return verifyData;
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async compact(data: any, context: any = CONTEXT): Promise<JsonLdDocument> {
|
// TODO our default CONTEXT isn't valid for the library, is this a bug?
|
||||||
|
public async compact(data: Document, context: ContextDefinition = CONTEXT as unknown as ContextDefinition): Promise<Document> {
|
||||||
const customLoader = this.getLoader();
|
const customLoader = this.getLoader();
|
||||||
// XXX: Importing jsonld dynamically since Jest frequently fails to import it statically
|
// XXX: Importing jsonld dynamically since Jest frequently fails to import it statically
|
||||||
// https://github.com/misskey-dev/misskey/pull/9894#discussion_r1103753595
|
// https://github.com/misskey-dev/misskey/pull/9894#discussion_r1103753595
|
||||||
|
|
@ -100,7 +137,7 @@ class JsonLd {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async normalize(data: JsonLdDocument): Promise<string> {
|
public async normalize(data: Document): Promise<string> {
|
||||||
const customLoader = this.getLoader();
|
const customLoader = this.getLoader();
|
||||||
return (await import('jsonld')).default.normalize(data, {
|
return (await import('jsonld')).default.normalize(data, {
|
||||||
documentLoader: customLoader,
|
documentLoader: customLoader,
|
||||||
|
|
@ -112,9 +149,9 @@ class JsonLd {
|
||||||
return async (url: string): Promise<RemoteDocument> => {
|
return async (url: string): Promise<RemoteDocument> => {
|
||||||
if (!/^https?:\/\//.test(url)) throw new UnrecoverableError(`Invalid URL: ${url}`);
|
if (!/^https?:\/\//.test(url)) throw new UnrecoverableError(`Invalid URL: ${url}`);
|
||||||
|
|
||||||
if (this.preLoad) {
|
{
|
||||||
if (url in PRELOADED_CONTEXTS) {
|
if (url in PRELOADED_CONTEXTS) {
|
||||||
if (this.debug) console.debug(`HIT: ${url}`);
|
this.logger.debug(`Preload HIT: ${url}`);
|
||||||
return {
|
return {
|
||||||
contextUrl: undefined,
|
contextUrl: undefined,
|
||||||
document: PRELOADED_CONTEXTS[url],
|
document: PRELOADED_CONTEXTS[url],
|
||||||
|
|
@ -123,7 +160,7 @@ class JsonLd {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.debug) console.debug(`MISS: ${url}`);
|
this.logger.debug(`Preload MISS: ${url}`);
|
||||||
const document = await this.fetchDocument(url);
|
const document = await this.fetchDocument(url);
|
||||||
return {
|
return {
|
||||||
contextUrl: undefined,
|
contextUrl: undefined,
|
||||||
|
|
@ -141,7 +178,6 @@ class JsonLd {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/ld+json, application/json',
|
Accept: 'application/ld+json, application/json',
|
||||||
},
|
},
|
||||||
timeout: this.loderTimeout,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
throwErrorWhenResponseNotOk: false,
|
throwErrorWhenResponseNotOk: false,
|
||||||
|
|
@ -149,7 +185,7 @@ class JsonLd {
|
||||||
},
|
},
|
||||||
).then(res => {
|
).then(res => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`JSON-LD fetch failed with ${res.status} ${res.statusText}: ${url}`);
|
throw new StatusError(`failed to fetch JSON-LD from ${url}`, res.status, res.statusText);
|
||||||
} else {
|
} else {
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
@ -165,16 +201,3 @@ class JsonLd {
|
||||||
return hash.digest('hex');
|
return hash.digest('hex');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class JsonLdService {
|
|
||||||
constructor(
|
|
||||||
private httpRequestService: HttpRequestService,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
@bindThis
|
|
||||||
public use(): JsonLd {
|
|
||||||
return new JsonLd(this.httpRequestService);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -540,12 +540,20 @@ const extension_context_definition = {
|
||||||
quoteUrl: 'as:quoteUrl',
|
quoteUrl: 'as:quoteUrl',
|
||||||
fedibird: 'http://fedibird.com/ns#',
|
fedibird: 'http://fedibird.com/ns#',
|
||||||
quoteUri: 'fedibird:quoteUri',
|
quoteUri: 'fedibird:quoteUri',
|
||||||
|
quote: {
|
||||||
|
'@id': 'https://w3id.org/fep/044f#quote',
|
||||||
|
'@type': '@id',
|
||||||
|
},
|
||||||
// Mastodon
|
// Mastodon
|
||||||
toot: 'http://joinmastodon.org/ns#',
|
toot: 'http://joinmastodon.org/ns#',
|
||||||
Emoji: 'toot:Emoji',
|
Emoji: 'toot:Emoji',
|
||||||
featured: 'toot:featured',
|
featured: 'toot:featured',
|
||||||
discoverable: 'toot:discoverable',
|
discoverable: 'toot:discoverable',
|
||||||
indexable: 'toot:indexable',
|
indexable: 'toot:indexable',
|
||||||
|
attributionDomains: {
|
||||||
|
'@id': 'toot:attributionDomains',
|
||||||
|
'@type': '@id',
|
||||||
|
},
|
||||||
// schema
|
// schema
|
||||||
schema: 'http://schema.org#',
|
schema: 'http://schema.org#',
|
||||||
PropertyValue: 'schema:PropertyValue',
|
PropertyValue: 'schema:PropertyValue',
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,14 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import type { Response } from 'node-fetch';
|
import type { Response } from 'node-fetch';
|
||||||
|
|
||||||
// TODO throw identifiable or unrecoverable errors
|
|
||||||
|
|
||||||
export function validateContentTypeSetAsActivityPub(response: Response): void {
|
export function validateContentTypeSetAsActivityPub(response: Response): void {
|
||||||
const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
|
const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
|
||||||
|
|
||||||
if (contentType === '') {
|
if (contentType === '') {
|
||||||
throw new Error(`invalid content type of AP response - no content-type header: ${response.url}`);
|
throw new IdentifiableError('d09dc850-b76c-4f45-875a-7389339d78b8', `invalid AP response from ${response.url}: no content-type header`, true);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
contentType.startsWith('application/activity+json') ||
|
contentType.startsWith('application/activity+json') ||
|
||||||
|
|
@ -19,7 +18,7 @@ export function validateContentTypeSetAsActivityPub(response: Response): void {
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new Error(`invalid content type of AP response - content type is not application/activity+json or application/ld+json: ${response.url}`);
|
throw new IdentifiableError('dc110060-a5f2-461d-808b-39c62702ca64', `invalid AP response from ${response.url}: content type "${contentType}" is not application/activity+json or application/ld+json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const plusJsonSuffixRegex = /^\s*(application|text)\/[a-zA-Z0-9\.\-\+]+\+json\s*(;|$)/;
|
const plusJsonSuffixRegex = /^\s*(application|text)\/[a-zA-Z0-9\.\-\+]+\+json\s*(;|$)/;
|
||||||
|
|
@ -28,7 +27,7 @@ export function validateContentTypeSetAsJsonLD(response: Response): void {
|
||||||
const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
|
const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
|
||||||
|
|
||||||
if (contentType === '') {
|
if (contentType === '') {
|
||||||
throw new Error(`invalid content type of JSON LD - no content-type header: ${response.url}`);
|
throw new IdentifiableError('45793ab7-7648-4886-b503-429f8a0d0f73', `invalid AP response from ${response.url}: no content-type header`, true);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
contentType.startsWith('application/ld+json') ||
|
contentType.startsWith('application/ld+json') ||
|
||||||
|
|
@ -37,5 +36,5 @@ export function validateContentTypeSetAsJsonLD(response: Response): void {
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new Error(`invalid content type of JSON LD - content type is not application/ld+json or application/json: ${response.url}`);
|
throw new IdentifiableError('4bf8f36b-4d33-4ac9-ad76-63fa11f354e9', `invalid AP response from ${response.url}: content type "${contentType}" is not application/ld+json or application/json`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import type { Config } from '@/config.js';
|
||||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import { ApResolverService } from '../ApResolverService.js';
|
import { ApResolverService } from '../ApResolverService.js';
|
||||||
import { ApLoggerService } from '../ApLoggerService.js';
|
import { ApLoggerService } from '../ApLoggerService.js';
|
||||||
import { isDocument, type IObject } from '../type.js';
|
import { getNullableApId, isDocument, type IObject } from '../type.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApImageService {
|
export class ApImageService {
|
||||||
|
|
@ -48,7 +48,7 @@ export class ApImageService {
|
||||||
public async createImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile | null> {
|
public async createImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile | null> {
|
||||||
// 投稿者が凍結されていたらスキップ
|
// 投稿者が凍結されていたらスキップ
|
||||||
if (actor.isSuspended) {
|
if (actor.isSuspended) {
|
||||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor has been suspended: ${actor.uri}`);
|
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `failed to create image ${getNullableApId(value)}: actor ${actor.id} has been suspended`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const image = await this.apResolverService.createResolver().resolve(value);
|
const image = await this.apResolverService.createResolver().resolve(value);
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ import { bindThis } from '@/decorators.js';
|
||||||
import { checkHttps } from '@/misc/check-https.js';
|
import { checkHttps } from '@/misc/check-https.js';
|
||||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
import { isRetryableError } from '@/misc/is-retryable-error.js';
|
import { isRetryableError } from '@/misc/is-retryable-error.js';
|
||||||
import { getOneApId, getApId, validPost, isEmoji, getApType, isApObject, isDocument, IApDocument } from '../type.js';
|
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||||
|
import { getOneApId, getApId, validPost, isEmoji, getApType, isApObject, isDocument, IApDocument, isLink } from '../type.js';
|
||||||
import { ApLoggerService } from '../ApLoggerService.js';
|
import { ApLoggerService } from '../ApLoggerService.js';
|
||||||
import { ApMfmService } from '../ApMfmService.js';
|
import { ApMfmService } from '../ApMfmService.js';
|
||||||
import { ApDbResolverService } from '../ApDbResolverService.js';
|
import { ApDbResolverService } from '../ApDbResolverService.js';
|
||||||
|
|
@ -100,29 +101,29 @@ export class ApNoteService {
|
||||||
const apType = getApType(object);
|
const apType = getApType(object);
|
||||||
|
|
||||||
if (apType == null || !validPost.includes(apType)) {
|
if (apType == null || !validPost.includes(apType)) {
|
||||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: invalid object type ${apType ?? 'undefined'}`);
|
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note from ${uri}: invalid object type ${apType ?? 'undefined'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) {
|
if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) {
|
||||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`);
|
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note from ${uri}: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const actualHost = object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo));
|
const actualHost = object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo));
|
||||||
if (object.attributedTo && actualHost !== expectHost) {
|
if (object.attributedTo && actualHost !== expectHost) {
|
||||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`);
|
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note from ${uri}: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (object.published && !this.idService.isSafeT(new Date(object.published).valueOf())) {
|
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');
|
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note from ${uri}: published timestamp is malformed');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actor) {
|
if (actor) {
|
||||||
const attribution = (object.attributedTo) ? getOneApId(object.attributedTo) : actor.uri;
|
const attribution = (object.attributedTo) ? getOneApId(object.attributedTo) : actor.uri;
|
||||||
if (attribution !== 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}`);
|
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note from ${uri}: attribution does not match the actor that send it. attribution: ${attribution}, actor: ${actor.uri}`);
|
||||||
}
|
}
|
||||||
if (user && attribution !== user.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}`);
|
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note from ${uri}: updated attribution does not match original attribution. updated attribution: ${user.uri}, original attribution: ${attribution}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,7 +162,7 @@ export class ApNoteService {
|
||||||
const entryUri = getApId(value);
|
const entryUri = getApId(value);
|
||||||
const err = this.validateNote(object, entryUri, actor);
|
const err = this.validateNote(object, entryUri, actor);
|
||||||
if (err) {
|
if (err) {
|
||||||
this.logger.error(err.message, {
|
this.logger.error(`Error creating note: ${renderInlineError(err)}`, {
|
||||||
resolver: { history: resolver.getHistory() },
|
resolver: { history: resolver.getHistory() },
|
||||||
value,
|
value,
|
||||||
object,
|
object,
|
||||||
|
|
@ -174,11 +175,11 @@ export class ApNoteService {
|
||||||
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
||||||
|
|
||||||
if (note.id == null) {
|
if (note.id == null) {
|
||||||
throw new UnrecoverableError(`Refusing to create note without id: ${entryUri}`);
|
throw new UnrecoverableError(`failed to create note ${entryUri}: missing ID`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!checkHttps(note.id)) {
|
if (!checkHttps(note.id)) {
|
||||||
throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${entryUri}`);
|
throw new UnrecoverableError(`failed to create note ${entryUri}: unexpected schema`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = this.apUtilityService.findBestObjectUrl(note);
|
const url = this.apUtilityService.findBestObjectUrl(note);
|
||||||
|
|
@ -187,7 +188,7 @@ export class ApNoteService {
|
||||||
|
|
||||||
// 投稿者をフェッチ
|
// 投稿者をフェッチ
|
||||||
if (note.attributedTo == null) {
|
if (note.attributedTo == null) {
|
||||||
throw new UnrecoverableError(`invalid note.attributedTo ${note.attributedTo} in ${entryUri}`);
|
throw new UnrecoverableError(`failed to create note: ${entryUri}: missing attributedTo`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const uri = getOneApId(note.attributedTo);
|
const uri = getOneApId(note.attributedTo);
|
||||||
|
|
@ -196,7 +197,7 @@ export class ApNoteService {
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
actor ??= await this.apPersonService.fetchPerson(uri) as MiRemoteUser | undefined;
|
actor ??= await this.apPersonService.fetchPerson(uri) as MiRemoteUser | undefined;
|
||||||
if (actor && actor.isSuspended) {
|
if (actor && actor.isSuspended) {
|
||||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor ${uri} has been suspended: ${entryUri}`);
|
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `failed to create note ${entryUri}: actor ${uri} has been suspended`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
|
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
|
||||||
|
|
@ -223,7 +224,7 @@ export class ApNoteService {
|
||||||
*/
|
*/
|
||||||
const hasProhibitedWords = this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices });
|
const hasProhibitedWords = this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices });
|
||||||
if (hasProhibitedWords) {
|
if (hasProhibitedWords) {
|
||||||
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', `Note contains prohibited words: ${entryUri}`);
|
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', `failed to create note ${entryUri}: contains prohibited words`);
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
|
|
@ -232,7 +233,7 @@ export class ApNoteService {
|
||||||
|
|
||||||
// 解決した投稿者が凍結されていたらスキップ
|
// 解決した投稿者が凍結されていたらスキップ
|
||||||
if (actor.isSuspended) {
|
if (actor.isSuspended) {
|
||||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor has been suspended: ${entryUri}`);
|
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `failed to create note ${entryUri}: actor ${actor.id} has been suspended`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
|
const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
|
||||||
|
|
@ -269,15 +270,15 @@ export class ApNoteService {
|
||||||
? await this.resolveNote(note.inReplyTo, { resolver })
|
? await this.resolveNote(note.inReplyTo, { resolver })
|
||||||
.then(x => {
|
.then(x => {
|
||||||
if (x == null) {
|
if (x == null) {
|
||||||
this.logger.warn('Specified inReplyTo, but not found');
|
this.logger.warn(`Specified inReplyTo "${note.inReplyTo}", but not found`);
|
||||||
throw new Error(`could not fetch inReplyTo ${note.inReplyTo} for note ${entryUri}`);
|
throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to create note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return x;
|
return x;
|
||||||
})
|
})
|
||||||
.catch(async err => {
|
.catch(async err => {
|
||||||
this.logger.warn(`error ${err.statusCode ?? err} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`);
|
this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`);
|
||||||
throw err;
|
throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to create note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true, err);
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|
@ -285,6 +286,13 @@ export class ApNoteService {
|
||||||
const quote = await this.getQuote(note, entryUri, resolver);
|
const quote = await this.getQuote(note, entryUri, resolver);
|
||||||
const processErrors = quote === null ? ['quoteUnavailable'] : null;
|
const processErrors = quote === null ? ['quoteUnavailable'] : null;
|
||||||
|
|
||||||
|
if (reply && reply.userHost == null && reply.localOnly) {
|
||||||
|
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot reply to local-only note');
|
||||||
|
}
|
||||||
|
if (quote && quote.userHost == null && quote.localOnly) {
|
||||||
|
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot quote a local-only note');
|
||||||
|
}
|
||||||
|
|
||||||
// vote
|
// vote
|
||||||
if (reply && reply.hasPoll) {
|
if (reply && reply.hasPoll) {
|
||||||
const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id });
|
const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id });
|
||||||
|
|
@ -341,7 +349,7 @@ export class ApNoteService {
|
||||||
this.logger.info('The note is already inserted while creating itself, reading again');
|
this.logger.info('The note is already inserted while creating itself, reading again');
|
||||||
const duplicate = await this.fetchNote(value);
|
const duplicate = await this.fetchNote(value);
|
||||||
if (!duplicate) {
|
if (!duplicate) {
|
||||||
throw new Error(`The note creation failed with duplication error even when there is no duplication: ${entryUri}`);
|
throw new IdentifiableError('39c328e1-e829-458b-bfc9-65dcd513d1f8', `failed to create note ${entryUri}: the note creation failed with duplication error even when there is no duplication. This is likely a bug.`);
|
||||||
}
|
}
|
||||||
return duplicate;
|
return duplicate;
|
||||||
}
|
}
|
||||||
|
|
@ -355,45 +363,39 @@ export class ApNoteService {
|
||||||
const noteUri = getApId(value);
|
const noteUri = getApId(value);
|
||||||
|
|
||||||
// URIがこのサーバーを指しているならスキップ
|
// URIがこのサーバーを指しているならスキップ
|
||||||
if (noteUri.startsWith(this.config.url + '/')) throw new UnrecoverableError(`uri points local: ${noteUri}`);
|
if (this.utilityService.isUriLocal(noteUri)) {
|
||||||
|
throw new UnrecoverableError(`failed to update note ${noteUri}: uri is local`);
|
||||||
|
}
|
||||||
|
|
||||||
//#region このサーバーに既に登録されているか
|
//#region このサーバーに既に登録されているか
|
||||||
const updatedNote = await this.notesRepository.findOneBy({ uri: noteUri });
|
const updatedNote = await this.notesRepository.findOneBy({ uri: noteUri });
|
||||||
if (updatedNote == null) throw new Error(`Note is not registered (no note): ${noteUri}`);
|
if (updatedNote == null) throw new UnrecoverableError(`failed to update note ${noteUri}: note does not exist`);
|
||||||
|
|
||||||
const user = await this.usersRepository.findOneBy({ id: updatedNote.userId }) as MiRemoteUser | null;
|
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}`);
|
if (user == null) throw new UnrecoverableError(`failed to update note ${noteUri}: user does not exist`);
|
||||||
|
|
||||||
// eslint-disable-next-line no-param-reassign
|
resolver ??= this.apResolverService.createResolver();
|
||||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
|
||||||
|
|
||||||
const object = await resolver.resolve(value);
|
const object = await resolver.resolve(value);
|
||||||
|
|
||||||
const entryUri = getApId(value);
|
const entryUri = getApId(value);
|
||||||
const err = this.validateNote(object, entryUri, actor, user);
|
const err = this.validateNote(object, entryUri, actor, user);
|
||||||
if (err) {
|
if (err) {
|
||||||
this.logger.error(err.message, {
|
this.logger.error(`Failed to update note ${noteUri}: ${renderInlineError(err)}`);
|
||||||
resolver: { history: resolver.getHistory() },
|
|
||||||
value,
|
|
||||||
object,
|
|
||||||
});
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `validateNote` checks that the actor and user are one and the same
|
// `validateNote` checks that the actor and user are one and the same
|
||||||
// eslint-disable-next-line no-param-reassign
|
|
||||||
actor ??= user;
|
actor ??= user;
|
||||||
|
|
||||||
const note = object as IPost;
|
const note = object as IPost;
|
||||||
|
|
||||||
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
|
||||||
|
|
||||||
if (note.id == null) {
|
if (note.id == null) {
|
||||||
throw new UnrecoverableError(`Refusing to update note without id: ${noteUri}`);
|
throw new UnrecoverableError(`failed to update note ${entryUri}: missing ID`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!checkHttps(note.id)) {
|
if (!checkHttps(note.id)) {
|
||||||
throw new UnrecoverableError(`unexpected schema of note.id ${note.id} in ${noteUri}`);
|
throw new UnrecoverableError(`failed to update note ${entryUri}: unexpected schema`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = this.apUtilityService.findBestObjectUrl(note);
|
const url = this.apUtilityService.findBestObjectUrl(note);
|
||||||
|
|
@ -401,7 +403,7 @@ export class ApNoteService {
|
||||||
this.logger.info(`Creating the Note: ${note.id}`);
|
this.logger.info(`Creating the Note: ${note.id}`);
|
||||||
|
|
||||||
if (actor.isSuspended) {
|
if (actor.isSuspended) {
|
||||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `actor ${actor.id} has been suspended: ${noteUri}`);
|
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', `failed to update note ${entryUri}: actor ${actor.id} has been suspended`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
|
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
|
||||||
|
|
@ -428,7 +430,7 @@ export class ApNoteService {
|
||||||
*/
|
*/
|
||||||
const hasProhibitedWords = this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices });
|
const hasProhibitedWords = this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices });
|
||||||
if (hasProhibitedWords) {
|
if (hasProhibitedWords) {
|
||||||
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', `Note contains prohibited words: ${noteUri}`);
|
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', `failed to update note ${noteUri}: contains prohibited words`);
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
|
|
@ -466,15 +468,15 @@ export class ApNoteService {
|
||||||
? await this.resolveNote(note.inReplyTo, { resolver })
|
? await this.resolveNote(note.inReplyTo, { resolver })
|
||||||
.then(x => {
|
.then(x => {
|
||||||
if (x == null) {
|
if (x == null) {
|
||||||
this.logger.warn('Specified inReplyTo, but not found');
|
this.logger.warn(`Specified inReplyTo "${note.inReplyTo}", but not found`);
|
||||||
throw new Error(`could not fetch inReplyTo ${note.inReplyTo} for note ${entryUri}`);
|
throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to update note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return x;
|
return x;
|
||||||
})
|
})
|
||||||
.catch(async err => {
|
.catch(async err => {
|
||||||
this.logger.warn(`error ${err.statusCode ?? err} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`);
|
this.logger.warn(`error ${renderInlineError(err)} fetching inReplyTo ${note.inReplyTo} for note ${entryUri}`);
|
||||||
throw err;
|
throw new IdentifiableError('1ebf0a96-2769-4973-a6c2-3dcbad409dff', `failed to update note ${entryUri}: could not fetch inReplyTo ${note.inReplyTo}`, true, err);
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|
@ -482,6 +484,10 @@ export class ApNoteService {
|
||||||
const quote = await this.getQuote(note, entryUri, resolver);
|
const quote = await this.getQuote(note, entryUri, resolver);
|
||||||
const processErrors = quote === null ? ['quoteUnavailable'] : null;
|
const processErrors = quote === null ? ['quoteUnavailable'] : null;
|
||||||
|
|
||||||
|
if (quote && quote.userHost == null && quote.localOnly) {
|
||||||
|
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot quote a local-only note');
|
||||||
|
}
|
||||||
|
|
||||||
// vote
|
// vote
|
||||||
if (reply && reply.hasPoll) {
|
if (reply && reply.hasPoll) {
|
||||||
const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id });
|
const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id });
|
||||||
|
|
@ -538,7 +544,7 @@ export class ApNoteService {
|
||||||
this.logger.info('The note is already inserted while creating itself, reading again');
|
this.logger.info('The note is already inserted while creating itself, reading again');
|
||||||
const duplicate = await this.fetchNote(value);
|
const duplicate = await this.fetchNote(value);
|
||||||
if (!duplicate) {
|
if (!duplicate) {
|
||||||
throw new Error(`The note creation failed with duplication error even when there is no duplication: ${noteUri}`);
|
throw new IdentifiableError('39c328e1-e829-458b-bfc9-65dcd513d1f8', `failed to update note ${entryUri}: the note update failed with duplication error even when there is no duplication. This is likely a bug.`);
|
||||||
}
|
}
|
||||||
return duplicate;
|
return duplicate;
|
||||||
}
|
}
|
||||||
|
|
@ -555,8 +561,7 @@ export class ApNoteService {
|
||||||
const uri = getApId(value);
|
const uri = getApId(value);
|
||||||
|
|
||||||
if (!this.utilityService.isFederationAllowedUri(uri)) {
|
if (!this.utilityService.isFederationAllowedUri(uri)) {
|
||||||
// TODO convert to identifiable error
|
throw new IdentifiableError('04620a7e-044e-45ce-b72c-10e1bdc22e69', `failed to resolve note ${uri}: host is blocked`);
|
||||||
throw new StatusError(`blocked host: ${uri}`, 451, 'blocked host');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//#region このサーバーに既に登録されていたらそれを返す
|
//#region このサーバーに既に登録されていたらそれを返す
|
||||||
|
|
@ -566,8 +571,7 @@ export class ApNoteService {
|
||||||
|
|
||||||
// Bail if local URI doesn't exist
|
// Bail if local URI doesn't exist
|
||||||
if (this.utilityService.isUriLocal(uri)) {
|
if (this.utilityService.isUriLocal(uri)) {
|
||||||
// TODO convert to identifiable error
|
throw new IdentifiableError('cbac7358-23f2-4c70-833e-cffb4bf77913', `failed to resolve note ${uri}: URL is local and does not exist`);
|
||||||
throw new StatusError(`cannot resolve local note: ${uri}`, 400, 'cannot resolve local note');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const unlock = await this.appLockService.getApLock(uri);
|
const unlock = await this.appLockService.getApLock(uri);
|
||||||
|
|
@ -653,9 +657,29 @@ export class ApNoteService {
|
||||||
*/
|
*/
|
||||||
private async getQuote(note: IPost, entryUri: string, resolver: Resolver): Promise<MiNote | null | undefined> {
|
private async getQuote(note: IPost, entryUri: string, resolver: Resolver): Promise<MiNote | null | undefined> {
|
||||||
const quoteUris = new Set<string>();
|
const quoteUris = new Set<string>();
|
||||||
if (note._misskey_quote) quoteUris.add(note._misskey_quote);
|
if (note._misskey_quote && typeof(note._misskey_quote as unknown) === 'string') quoteUris.add(note._misskey_quote);
|
||||||
if (note.quoteUrl) quoteUris.add(note.quoteUrl);
|
if (note.quoteUrl && typeof(note.quoteUrl as unknown) === 'string') quoteUris.add(note.quoteUrl);
|
||||||
if (note.quoteUri) quoteUris.add(note.quoteUri);
|
if (note.quoteUri && typeof(note.quoteUri as unknown) === 'string') quoteUris.add(note.quoteUri);
|
||||||
|
|
||||||
|
// https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
|
||||||
|
if (note.quote && typeof(note.quote as unknown) === 'string') quoteUris.add(note.quote);
|
||||||
|
|
||||||
|
// https://codeberg.org/fediverse/fep/src/branch/main/fep/e232/fep-e232.md
|
||||||
|
const tags = toArray(note.tag).filter(tag => typeof(tag) === 'object' && isLink(tag));
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (!tag.href || typeof (tag.href as unknown) !== 'string') continue;
|
||||||
|
|
||||||
|
const mediaTypes = toArray(tag.mediaType);
|
||||||
|
if (
|
||||||
|
!mediaTypes.includes('application/ld+json; profile="https://www.w3.org/ns/activitystreams"') &&
|
||||||
|
!mediaTypes.includes('application/activity+json')
|
||||||
|
) continue;
|
||||||
|
|
||||||
|
const rels = toArray(tag.rel);
|
||||||
|
if (!rels.includes('https://misskey-hub.net/ns#_misskey_quote')) continue;
|
||||||
|
|
||||||
|
quoteUris.add(tag.href);
|
||||||
|
}
|
||||||
|
|
||||||
// No quote, return undefined
|
// No quote, return undefined
|
||||||
if (quoteUris.size < 1) return undefined;
|
if (quoteUris.size < 1) return undefined;
|
||||||
|
|
@ -674,18 +698,13 @@ export class ApNoteService {
|
||||||
const quote = await this.resolveNote(uri, { resolver });
|
const quote = await this.resolveNote(uri, { resolver });
|
||||||
|
|
||||||
if (quote == null) {
|
if (quote == null) {
|
||||||
this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": request error`);
|
this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": fetch failed`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return quote;
|
return quote;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) {
|
this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": ${renderInlineError(e)}`);
|
||||||
this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}":`, e);
|
|
||||||
} else {
|
|
||||||
this.logger.warn(`Failed to resolve quote "${uri}" for note "${entryUri}": ${e}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return isRetryableError(e);
|
return isRetryableError(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import promiseLimit from 'promise-limit';
|
import promiseLimit from 'promise-limit';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { AbortError } from 'node-fetch';
|
|
||||||
import { UnrecoverableError } from 'bullmq';
|
import { UnrecoverableError } from 'bullmq';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { FollowingsRepository, InstancesRepository, MiMeta, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js';
|
import type { FollowingsRepository, InstancesRepository, MiMeta, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js';
|
||||||
|
|
@ -44,6 +43,8 @@ import { AppLockService } from '@/core/AppLockService.js';
|
||||||
import { MemoryKVCache } from '@/misc/cache.js';
|
import { MemoryKVCache } from '@/misc/cache.js';
|
||||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||||
import { verifyFieldLinks } from '@/misc/verify-field-link.js';
|
import { verifyFieldLinks } from '@/misc/verify-field-link.js';
|
||||||
|
import { isRetryableError } from '@/misc/is-retryable-error.js';
|
||||||
|
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||||
import { getApId, getApType, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
|
import { getApId, getApType, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
|
||||||
import { extractApHashtags } from './tag.js';
|
import { extractApHashtags } from './tag.js';
|
||||||
import type { OnModuleInit } from '@nestjs/common';
|
import type { OnModuleInit } from '@nestjs/common';
|
||||||
|
|
@ -54,6 +55,7 @@ import type { ApLoggerService } from '../ApLoggerService.js';
|
||||||
|
|
||||||
import type { ApImageService } from './ApImageService.js';
|
import type { ApImageService } from './ApImageService.js';
|
||||||
import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js';
|
import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js';
|
||||||
|
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||||
|
|
||||||
const nameLength = 128;
|
const nameLength = 128;
|
||||||
const summaryLength = 2048;
|
const summaryLength = 2048;
|
||||||
|
|
@ -157,21 +159,21 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
const expectHost = this.utilityService.punyHostPSLDomain(uri);
|
const expectHost = this.utilityService.punyHostPSLDomain(uri);
|
||||||
|
|
||||||
if (!isActor(x)) {
|
if (!isActor(x)) {
|
||||||
throw new UnrecoverableError(`invalid Actor type '${x.type}' in ${uri}`);
|
throw new UnrecoverableError(`invalid Actor ${uri}: unknown type '${x.type}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(typeof x.id === 'string' && x.id.length > 0)) {
|
if (!(typeof x.id === 'string' && x.id.length > 0)) {
|
||||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong id type`);
|
throw new UnrecoverableError(`invalid Actor ${uri}: wrong id type`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) {
|
if (!(typeof x.inbox === 'string' && x.inbox.length > 0)) {
|
||||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong inbox type`);
|
throw new UnrecoverableError(`invalid Actor ${uri}: wrong inbox type`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.apUtilityService.assertApUrl(x.inbox);
|
this.apUtilityService.assertApUrl(x.inbox);
|
||||||
const inboxHost = this.utilityService.punyHostPSLDomain(x.inbox);
|
const inboxHost = this.utilityService.punyHostPSLDomain(x.inbox);
|
||||||
if (inboxHost !== expectHost) {
|
if (inboxHost !== expectHost) {
|
||||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong inbox ${inboxHost}`);
|
throw new UnrecoverableError(`invalid Actor ${uri}: wrong inbox host ${inboxHost}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined);
|
const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined);
|
||||||
|
|
@ -179,7 +181,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
const sharedInbox = getApId(sharedInboxObject);
|
const sharedInbox = getApId(sharedInboxObject);
|
||||||
this.apUtilityService.assertApUrl(sharedInbox);
|
this.apUtilityService.assertApUrl(sharedInbox);
|
||||||
if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHostPSLDomain(sharedInbox) === expectHost)) {
|
if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHostPSLDomain(sharedInbox) === expectHost)) {
|
||||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong shared inbox ${sharedInbox}`);
|
throw new UnrecoverableError(`invalid Actor ${uri}: wrong shared inbox ${sharedInbox}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,7 +192,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
if (typeof collectionUri === 'string' && collectionUri.length > 0) {
|
if (typeof collectionUri === 'string' && collectionUri.length > 0) {
|
||||||
this.apUtilityService.assertApUrl(collectionUri);
|
this.apUtilityService.assertApUrl(collectionUri);
|
||||||
if (this.utilityService.punyHostPSLDomain(collectionUri) !== expectHost) {
|
if (this.utilityService.punyHostPSLDomain(collectionUri) !== expectHost) {
|
||||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong ${collection} ${collectionUri}`);
|
throw new UnrecoverableError(`invalid Actor ${uri}: wrong ${collection} host ${collectionUri}`);
|
||||||
}
|
}
|
||||||
} else if (collectionUri != null) {
|
} else if (collectionUri != null) {
|
||||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong ${collection} type`);
|
throw new UnrecoverableError(`invalid Actor ${uri}: wrong ${collection} type`);
|
||||||
|
|
@ -199,7 +201,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) {
|
if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) {
|
||||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong username`);
|
throw new UnrecoverableError(`invalid Actor ${uri}: wrong username`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// These fields are only informational, and some AP software allows these
|
// These fields are only informational, and some AP software allows these
|
||||||
|
|
@ -207,7 +209,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
// we can at least see these users and their activities.
|
// we can at least see these users and their activities.
|
||||||
if (x.name) {
|
if (x.name) {
|
||||||
if (!(typeof x.name === 'string' && x.name.length > 0)) {
|
if (!(typeof x.name === 'string' && x.name.length > 0)) {
|
||||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong name`);
|
throw new UnrecoverableError(`invalid Actor ${uri}: wrong name`);
|
||||||
}
|
}
|
||||||
x.name = truncate(x.name, nameLength);
|
x.name = truncate(x.name, nameLength);
|
||||||
} else if (x.name === '') {
|
} else if (x.name === '') {
|
||||||
|
|
@ -216,24 +218,24 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
if (x.summary) {
|
if (x.summary) {
|
||||||
if (!(typeof x.summary === 'string' && x.summary.length > 0)) {
|
if (!(typeof x.summary === 'string' && x.summary.length > 0)) {
|
||||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong summary`);
|
throw new UnrecoverableError(`invalid Actor ${uri}: wrong summary`);
|
||||||
}
|
}
|
||||||
x.summary = truncate(x.summary, summaryLength);
|
x.summary = truncate(x.summary, summaryLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
const idHost = this.utilityService.punyHostPSLDomain(x.id);
|
const idHost = this.utilityService.punyHostPSLDomain(x.id);
|
||||||
if (idHost !== expectHost) {
|
if (idHost !== expectHost) {
|
||||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong id ${x.id}`);
|
throw new UnrecoverableError(`invalid Actor ${uri}: wrong id ${x.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (x.publicKey) {
|
if (x.publicKey) {
|
||||||
if (typeof x.publicKey.id !== 'string') {
|
if (typeof x.publicKey.id !== 'string') {
|
||||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong publicKey.id type`);
|
throw new UnrecoverableError(`invalid Actor ${uri}: wrong publicKey.id type`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicKeyIdHost = this.utilityService.punyHostPSLDomain(x.publicKey.id);
|
const publicKeyIdHost = this.utilityService.punyHostPSLDomain(x.publicKey.id);
|
||||||
if (publicKeyIdHost !== expectHost) {
|
if (publicKeyIdHost !== expectHost) {
|
||||||
throw new UnrecoverableError(`invalid Actor ${uri} - wrong publicKey.id ${x.publicKey.id}`);
|
throw new UnrecoverableError(`invalid Actor ${uri}: wrong publicKey.id ${x.publicKey.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -271,8 +273,6 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any, bgimg: any): Promise<Partial<Pick<MiRemoteUser, 'avatarId' | 'bannerId' | 'backgroundId' | 'avatarUrl' | 'bannerUrl' | 'backgroundUrl' | 'avatarBlurhash' | 'bannerBlurhash' | 'backgroundBlurhash'>>> {
|
private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any, bgimg: any): Promise<Partial<Pick<MiRemoteUser, 'avatarId' | 'bannerId' | 'backgroundId' | 'avatarUrl' | 'bannerUrl' | 'backgroundUrl' | 'avatarBlurhash' | 'bannerBlurhash' | 'backgroundBlurhash'>>> {
|
||||||
if (user == null) throw new Error('failed to create user: user is null');
|
|
||||||
|
|
||||||
const [avatar, banner, background] = await Promise.all([icon, image, bgimg].map(img => {
|
const [avatar, banner, background] = await Promise.all([icon, image, bgimg].map(img => {
|
||||||
// icon and image may be arrays
|
// icon and image may be arrays
|
||||||
// see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon
|
// see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon
|
||||||
|
|
@ -325,12 +325,11 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async createPerson(uri: string, resolver?: Resolver): Promise<MiRemoteUser> {
|
public async createPerson(uri: string, resolver?: Resolver): Promise<MiRemoteUser> {
|
||||||
if (typeof uri !== 'string') throw new UnrecoverableError(`uri is not string: ${uri}`);
|
if (typeof uri !== 'string') throw new UnrecoverableError(`failed to create user ${uri}: input is not string`);
|
||||||
|
|
||||||
const host = this.utilityService.punyHost(uri);
|
const host = this.utilityService.punyHost(uri);
|
||||||
if (host === this.utilityService.toPuny(this.config.host)) {
|
if (host === this.utilityService.toPuny(this.config.host)) {
|
||||||
// TODO convert to unrecoverable error
|
throw new UnrecoverableError(`failed to create user ${uri}: URI is local`);
|
||||||
throw new StatusError(`cannot resolve local user: ${uri}`, 400, 'cannot resolve local user');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this._createPerson(uri, resolver);
|
return await this._createPerson(uri, resolver);
|
||||||
|
|
@ -340,8 +339,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
const uri = getApId(value);
|
const uri = getApId(value);
|
||||||
const host = this.utilityService.punyHost(uri);
|
const host = this.utilityService.punyHost(uri);
|
||||||
|
|
||||||
// eslint-disable-next-line no-param-reassign
|
resolver ??= this.apResolverService.createResolver();
|
||||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
|
||||||
|
|
||||||
const object = await resolver.resolve(value);
|
const object = await resolver.resolve(value);
|
||||||
const person = this.validateActor(object, uri);
|
const person = this.validateActor(object, uri);
|
||||||
|
|
@ -356,14 +354,16 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
|
|
||||||
const [followingVisibility, followersVisibility] = await Promise.all(
|
const [followingVisibility, followersVisibility] = await Promise.all(
|
||||||
[
|
[
|
||||||
this.isPublicCollection(person.following, resolver),
|
this.isPublicCollection(person.following, resolver, uri),
|
||||||
this.isPublicCollection(person.followers, resolver),
|
this.isPublicCollection(person.followers, resolver, uri),
|
||||||
].map((p): Promise<'public' | 'private'> => p
|
].map((p): Promise<'public' | 'private'> => p
|
||||||
.then(isPublic => isPublic ? 'public' : 'private')
|
.then(isPublic => isPublic ? 'public' : 'private')
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
if (!(err instanceof StatusError) || err.isRetryable) {
|
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||||
this.logger.error('error occurred while fetching following/followers collection', { stack: err });
|
if (isRetryableError(err)) {
|
||||||
|
this.logger.error(`error occurred while fetching following/followers collection: ${renderInlineError(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'private';
|
return 'private';
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
@ -372,7 +372,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
|
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
|
||||||
|
|
||||||
if (person.id == null) {
|
if (person.id == null) {
|
||||||
throw new UnrecoverableError(`Refusing to create person without id: ${uri}`);
|
throw new UnrecoverableError(`failed to create user ${uri}: missing ID`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = this.apUtilityService.findBestObjectUrl(person);
|
const url = this.apUtilityService.findBestObjectUrl(person);
|
||||||
|
|
@ -387,16 +387,27 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host)
|
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host)
|
||||||
.then(_emojis => _emojis.map(emoji => emoji.name))
|
.then(_emojis => _emojis.map(emoji => emoji.name))
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
this.logger.error('error occurred while fetching user emojis', { stack: err });
|
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||||
|
if (isRetryableError(err)) {
|
||||||
|
this.logger.error(`error occurred while fetching user emojis: ${renderInlineError(err)}`);
|
||||||
|
}
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
//#region resolve counts
|
//#region resolve counts
|
||||||
const _resolver = resolver ?? this.apResolverService.createResolver();
|
const outboxCollection = person.outbox
|
||||||
const outboxcollection = await _resolver.resolveCollection(person.outbox).catch(() => { return null; });
|
? await resolver.resolveCollection(person.outbox, true, uri).catch(() => { return null; })
|
||||||
const followerscollection = await _resolver.resolveCollection(person.followers!).catch(() => { return null; });
|
: null;
|
||||||
const followingcollection = await _resolver.resolveCollection(person.following!).catch(() => { return null; });
|
const followersCollection = person.followers
|
||||||
|
? await resolver.resolveCollection(person.followers, true, uri).catch(() => { return null; })
|
||||||
|
: null;
|
||||||
|
const followingCollection = person.following
|
||||||
|
? await resolver.resolveCollection(person.following, true, uri).catch(() => { return null; })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Register the instance first, to avoid FK errors
|
||||||
|
await this.federatedInstanceService.fetchOrRegister(host);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Start transaction
|
// Start transaction
|
||||||
|
|
@ -423,9 +434,9 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
host,
|
host,
|
||||||
inbox: person.inbox,
|
inbox: person.inbox,
|
||||||
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
|
sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
|
||||||
notesCount: outboxcollection?.totalItems ?? 0,
|
notesCount: outboxCollection?.totalItems ?? 0,
|
||||||
followersCount: followerscollection?.totalItems ?? 0,
|
followersCount: followersCollection?.totalItems ?? 0,
|
||||||
followingCount: followingcollection?.totalItems ?? 0,
|
followingCount: followingCollection?.totalItems ?? 0,
|
||||||
followersUri: person.followers ? getApId(person.followers) : undefined,
|
followersUri: person.followers ? getApId(person.followers) : undefined,
|
||||||
featured: person.featured ? getApId(person.featured) : undefined,
|
featured: person.featured ? getApId(person.featured) : undefined,
|
||||||
uri: person.id,
|
uri: person.id,
|
||||||
|
|
@ -437,6 +448,11 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null,
|
makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null,
|
||||||
makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null,
|
makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null,
|
||||||
emojis,
|
emojis,
|
||||||
|
attributionDomains: Array.isArray(person.attributionDomains)
|
||||||
|
? person.attributionDomains
|
||||||
|
.filter((a: unknown) => typeof(a) === 'string' && a.length > 0 && a.length <= 128)
|
||||||
|
.slice(0, 32)
|
||||||
|
: [],
|
||||||
})) as MiRemoteUser;
|
})) as MiRemoteUser;
|
||||||
|
|
||||||
let _description: string | null = null;
|
let _description: string | null = null;
|
||||||
|
|
@ -480,7 +496,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
user = u as MiRemoteUser;
|
user = u as MiRemoteUser;
|
||||||
publicKey = await this.userPublickeysRepository.findOneBy({ userId: user.id });
|
publicKey = await this.userPublickeysRepository.findOneBy({ userId: user.id });
|
||||||
} else {
|
} else {
|
||||||
this.logger.error(e instanceof Error ? e : new Error(e as string));
|
this.logger.error('Error creating Person:', e instanceof Error ? e : new Error(e as string));
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -520,11 +536,19 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
// Register to the cache
|
// Register to the cache
|
||||||
this.cacheService.uriPersonCache.set(user.uri, user);
|
this.cacheService.uriPersonCache.set(user.uri, user);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error('error occurred while fetching user avatar/banner', { stack: err });
|
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||||
|
if (isRetryableError(err)) {
|
||||||
|
this.logger.error(`error occurred while fetching user avatar/banner: ${renderInlineError(err)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
await this.updateFeatured(user.id, resolver).catch(err => this.logger.error(err));
|
await this.updateFeatured(user.id, resolver).catch(err => {
|
||||||
|
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||||
|
if (isRetryableError(err)) {
|
||||||
|
this.logger.error(`Error updating featured notes: ${renderInlineError(err)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
@ -541,7 +565,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise<string | void> {
|
public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise<string | void> {
|
||||||
if (typeof uri !== 'string') throw new UnrecoverableError('uri is not string');
|
if (typeof uri !== 'string') throw new UnrecoverableError(`failed to update user ${uri}: input is not string`);
|
||||||
|
|
||||||
// URIがこのサーバーを指しているならスキップ
|
// URIがこのサーバーを指しているならスキップ
|
||||||
if (this.utilityService.isUriLocal(uri)) return;
|
if (this.utilityService.isUriLocal(uri)) return;
|
||||||
|
|
@ -561,8 +585,11 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
this.logger.info(`Updating the Person: ${person.id}`);
|
this.logger.info(`Updating the Person: ${person.id}`);
|
||||||
|
|
||||||
// カスタム絵文字取得
|
// カスタム絵文字取得
|
||||||
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], exist.host).catch(e => {
|
const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], exist.host).catch(err => {
|
||||||
this.logger.info(`extractEmojis: ${e}`);
|
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||||
|
if (isRetryableError(err)) {
|
||||||
|
this.logger.error(`error occurred while fetching user emojis: ${renderInlineError(err)}`);
|
||||||
|
}
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -574,16 +601,18 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
|
|
||||||
const [followingVisibility, followersVisibility] = await Promise.all(
|
const [followingVisibility, followersVisibility] = await Promise.all(
|
||||||
[
|
[
|
||||||
this.isPublicCollection(person.following, resolver),
|
this.isPublicCollection(person.following, resolver, exist.uri),
|
||||||
this.isPublicCollection(person.followers, resolver),
|
this.isPublicCollection(person.followers, resolver, exist.uri),
|
||||||
].map((p): Promise<'public' | 'private' | undefined> => p
|
].map((p): Promise<'public' | 'private' | undefined> => p
|
||||||
.then(isPublic => isPublic ? 'public' : 'private')
|
.then(isPublic => isPublic ? 'public' : 'private')
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
if (!(err instanceof StatusError) || err.isRetryable) {
|
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||||
this.logger.error('error occurred while fetching following/followers collection', { stack: err });
|
if (isRetryableError(err)) {
|
||||||
|
this.logger.error(`error occurred while fetching following/followers collection: ${renderInlineError(err)}`);
|
||||||
// Do not update the visibility on transient errors.
|
// Do not update the visibility on transient errors.
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'private';
|
return 'private';
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
@ -592,7 +621,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
|
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
|
||||||
|
|
||||||
if (person.id == null) {
|
if (person.id == null) {
|
||||||
throw new UnrecoverableError(`Refusing to update person without id: ${uri}`);
|
throw new UnrecoverableError(`failed to update user ${uri}: missing ID`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = this.apUtilityService.findBestObjectUrl(person);
|
const url = this.apUtilityService.findBestObjectUrl(person);
|
||||||
|
|
@ -620,7 +649,20 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
// We use "!== false" to handle incorrect types, missing / null values, and "default to true" logic.
|
// We use "!== false" to handle incorrect types, missing / null values, and "default to true" logic.
|
||||||
hideOnlineStatus: person.hideOnlineStatus !== false,
|
hideOnlineStatus: person.hideOnlineStatus !== false,
|
||||||
isExplorable: person.discoverable !== false,
|
isExplorable: person.discoverable !== false,
|
||||||
...(await this.resolveAvatarAndBanner(exist, person.icon, person.image, person.backgroundUrl).catch(() => ({}))),
|
attributionDomains: Array.isArray(person.attributionDomains)
|
||||||
|
? person.attributionDomains
|
||||||
|
.filter((a: unknown) => typeof(a) === 'string' && a.length > 0 && a.length <= 128)
|
||||||
|
.slice(0, 32)
|
||||||
|
: [],
|
||||||
|
...(await this.resolveAvatarAndBanner(exist, person.icon, person.image, person.backgroundUrl).catch(err => {
|
||||||
|
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||||
|
if (isRetryableError(err)) {
|
||||||
|
this.logger.error(`error occurred while fetching user avatar/banner: ${renderInlineError(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can't return null or destructuring operator will break
|
||||||
|
return {};
|
||||||
|
})),
|
||||||
} as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'speakAsCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
|
} as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'speakAsCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
|
||||||
|
|
||||||
const moving = ((): boolean => {
|
const moving = ((): boolean => {
|
||||||
|
|
@ -699,12 +741,24 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
this.hashtagService.updateUsertags(exist, tags);
|
this.hashtagService.updateUsertags(exist, tags);
|
||||||
|
|
||||||
// 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする
|
// 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする
|
||||||
await this.followingsRepository.update(
|
if (exist.inbox !== person.inbox || exist.sharedInbox !== (person.sharedInbox ?? person.endpoints?.sharedInbox)) {
|
||||||
{ followerId: exist.id },
|
await this.followingsRepository.update(
|
||||||
{ followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null },
|
{ followerId: exist.id },
|
||||||
);
|
{
|
||||||
|
followerInbox: person.inbox,
|
||||||
|
followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err));
|
await this.cacheService.refreshFollowRelationsFor(exist.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.updateFeatured(exist.id, resolver).catch(err => {
|
||||||
|
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||||
|
if (isRetryableError(err)) {
|
||||||
|
this.logger.error(`Error updating featured notes: ${renderInlineError(err)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const updated = { ...exist, ...updates };
|
const updated = { ...exist, ...updates };
|
||||||
|
|
||||||
|
|
@ -743,8 +797,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
const uri = getApId(value);
|
const uri = getApId(value);
|
||||||
|
|
||||||
if (!this.utilityService.isFederationAllowedUri(uri)) {
|
if (!this.utilityService.isFederationAllowedUri(uri)) {
|
||||||
// TODO convert to identifiable error
|
throw new IdentifiableError('590719b3-f51f-48a9-8e7d-6f559ad00e5d', `failed to resolve person ${uri}: host is blocked`);
|
||||||
throw new StatusError(`blocked host: ${uri}`, 451, 'blocked host');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//#region このサーバーに既に登録されていたらそれを返す
|
//#region このサーバーに既に登録されていたらそれを返す
|
||||||
|
|
@ -754,8 +807,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
|
|
||||||
// Bail if local URI doesn't exist
|
// Bail if local URI doesn't exist
|
||||||
if (this.utilityService.isUriLocal(uri)) {
|
if (this.utilityService.isUriLocal(uri)) {
|
||||||
// TODO convert to identifiable error
|
throw new IdentifiableError('efb573fd-6b9e-4912-9348-a02f5603df4f', `failed to resolve person ${uri}: URL is local and does not exist`);
|
||||||
throw new StatusError(`cannot resolve local person: ${uri}`, 400, 'cannot resolve local person');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const unlock = await this.appLockService.getApLock(uri);
|
const unlock = await this.appLockService.getApLock(uri);
|
||||||
|
|
@ -799,16 +851,17 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
const _resolver = resolver ?? this.apResolverService.createResolver();
|
const _resolver = resolver ?? this.apResolverService.createResolver();
|
||||||
|
|
||||||
// Resolve to (Ordered)Collection Object
|
// Resolve to (Ordered)Collection Object
|
||||||
const collection = await _resolver.resolveCollection(user.featured).catch(err => {
|
const collection = user.featured ? await _resolver.resolveCollection(user.featured, true, user.uri).catch(err => {
|
||||||
if (err instanceof AbortError || err instanceof StatusError) {
|
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||||
this.logger.warn(`Failed to update featured notes: ${err.name}: ${err.message}`);
|
if (isRetryableError(err)) {
|
||||||
} else {
|
this.logger.warn(`Failed to update featured notes: ${renderInlineError(err)}`);
|
||||||
this.logger.error('Failed to update featured notes:', err);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
return null;
|
||||||
|
}) : null;
|
||||||
if (!collection) return;
|
if (!collection) return;
|
||||||
|
|
||||||
if (!isCollectionOrOrderedCollection(collection)) throw new UnrecoverableError(`featured ${user.featured} is not Collection or OrderedCollection in ${user.uri}`);
|
if (!isCollectionOrOrderedCollection(collection)) throw new UnrecoverableError(`failed to update user ${user.uri}: featured ${user.featured} is not Collection or OrderedCollection`);
|
||||||
|
|
||||||
// Resolve to Object(may be Note) arrays
|
// Resolve to Object(may be Note) arrays
|
||||||
const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems;
|
const unresolvedItems = isCollection(collection) ? collection.items : collection.orderedItems;
|
||||||
|
|
@ -891,11 +944,13 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async isPublicCollection(collection: string | ICollection | IOrderedCollection | undefined, resolver: Resolver): Promise<boolean> {
|
private async isPublicCollection(collection: string | ICollection | IOrderedCollection | undefined, resolver: Resolver, sentFrom: string): Promise<boolean> {
|
||||||
if (collection) {
|
if (collection) {
|
||||||
const resolved = await resolver.resolveCollection(collection);
|
const resolved = await resolver.resolveCollection(collection, true, sentFrom).catch(() => null);
|
||||||
if (resolved.first || (resolved as ICollection).items || (resolved as IOrderedCollection).orderedItems) {
|
if (resolved) {
|
||||||
return true;
|
if (resolved.first || (resolved as ICollection).items || (resolved as IOrderedCollection).orderedItems) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,6 @@ export class ApQuestionService {
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||||
const question = await resolver.resolve(value);
|
const question = await resolver.resolve(value);
|
||||||
this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);
|
|
||||||
|
|
||||||
if (!isQuestion(question)) throw new UnrecoverableError(`object ${getApType(question)} is not a Question: ${uri}`);
|
if (!isQuestion(question)) throw new UnrecoverableError(`object ${getApType(question)} is not a Question: ${uri}`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export interface IObject {
|
||||||
mediaType?: string;
|
mediaType?: string;
|
||||||
url?: ApObject | string;
|
url?: ApObject | string;
|
||||||
href?: string;
|
href?: string;
|
||||||
|
rel?: string | string[];
|
||||||
tag?: IObject | IObject[];
|
tag?: IObject | IObject[];
|
||||||
sensitive?: boolean;
|
sensitive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -43,6 +44,28 @@ export interface IObjectWithId extends IObject {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isObjectWithId(object: IObject): object is IObjectWithId {
|
||||||
|
return typeof(object.id) === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAnonymousObject extends IObject {
|
||||||
|
id: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAnonymousObject(object: IObject): object is IAnonymousObject {
|
||||||
|
return object.id === undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ILink extends IObject {
|
||||||
|
'@context'?: string | string[] | Obj | Obj[];
|
||||||
|
type: 'Link' | 'Mention';
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isLink = (object: IObject): object is ILink =>
|
||||||
|
(getApType(object) === 'Link' || getApType(object) === 'Link') &&
|
||||||
|
typeof object.href === 'string';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get array of ActivityStreams Objects id
|
* Get array of ActivityStreams Objects id
|
||||||
*/
|
*/
|
||||||
|
|
@ -63,24 +86,34 @@ export function getOneApId(value: ApObject): string {
|
||||||
/**
|
/**
|
||||||
* Get ActivityStreams Object id
|
* Get ActivityStreams Object id
|
||||||
*/
|
*/
|
||||||
export function getApId(value: string | IObject | [string | IObject]): string {
|
export function getApId(value: string | IObject | [string | IObject], sourceForLogs?: string): string {
|
||||||
// eslint-disable-next-line no-param-reassign
|
const id = getNullableApId(value);
|
||||||
value = fromTuple(value);
|
|
||||||
|
|
||||||
if (typeof value === 'string') return value;
|
if (id == null) {
|
||||||
if (typeof value.id === 'string') return value.id;
|
const message = sourceForLogs
|
||||||
throw new IdentifiableError('ad2dc287-75c1-44c4-839d-3d2e64576675', `invalid AP object ${value}: missing id`);
|
? `invalid AP object ${value} (sent from ${sourceForLogs}): missing id`
|
||||||
|
: `invalid AP object ${value}: missing id`;
|
||||||
|
throw new IdentifiableError('ad2dc287-75c1-44c4-839d-3d2e64576675', message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get ActivityStreams Object id, or null if not present
|
* Get ActivityStreams Object id, or null if not present
|
||||||
*/
|
*/
|
||||||
export function getNullableApId(value: string | IObject | [string | IObject]): string | null {
|
export function getNullableApId(source: string | IObject | [string | IObject]): string | null {
|
||||||
// eslint-disable-next-line no-param-reassign
|
const value: unknown = fromTuple(source);
|
||||||
value = fromTuple(value);
|
|
||||||
|
if (value != null) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof (value) === 'object' && 'id' in value && typeof (value.id) === 'string') {
|
||||||
|
return value.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof value === 'string') return value;
|
|
||||||
if (typeof value.id === 'string') return value.id;
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,48 +158,46 @@ export interface IActivity extends IObject {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICollection extends IObject {
|
export interface CollectionBase extends IObject {
|
||||||
|
totalItems?: number;
|
||||||
|
first?: IObject | string;
|
||||||
|
last?: IObject | string;
|
||||||
|
current?: IObject | string;
|
||||||
|
partOf?: IObject | string;
|
||||||
|
next?: IObject | string;
|
||||||
|
prev?: IObject | string;
|
||||||
|
items?: ApObject;
|
||||||
|
orderedItems?: ApObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICollection extends CollectionBase {
|
||||||
type: 'Collection';
|
type: 'Collection';
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
first?: IObject | string;
|
|
||||||
last?: IObject | string;
|
|
||||||
current?: IObject | string;
|
|
||||||
items?: ApObject;
|
items?: ApObject;
|
||||||
|
orderedItems?: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IOrderedCollection extends IObject {
|
export interface IOrderedCollection extends CollectionBase {
|
||||||
type: 'OrderedCollection';
|
type: 'OrderedCollection';
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
first?: IObject | string;
|
items?: undefined;
|
||||||
last?: IObject | string;
|
|
||||||
current?: IObject | string;
|
|
||||||
orderedItems?: ApObject;
|
orderedItems?: ApObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICollectionPage extends IObject {
|
export interface ICollectionPage extends CollectionBase {
|
||||||
type: 'CollectionPage';
|
type: 'CollectionPage';
|
||||||
totalItems: number;
|
|
||||||
first?: IObject | string;
|
|
||||||
last?: IObject | string;
|
|
||||||
current?: IObject | string;
|
|
||||||
partOf?: IObject | string;
|
|
||||||
next?: IObject | string;
|
|
||||||
prev?: IObject | string;
|
|
||||||
items?: ApObject;
|
items?: ApObject;
|
||||||
|
orderedItems?: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IOrderedCollectionPage extends IObject {
|
export interface IOrderedCollectionPage extends CollectionBase {
|
||||||
type: 'OrderedCollectionPage';
|
type: 'OrderedCollectionPage';
|
||||||
totalItems: number;
|
items?: undefined;
|
||||||
first?: IObject | string;
|
|
||||||
last?: IObject | string;
|
|
||||||
current?: IObject | string;
|
|
||||||
partOf?: IObject | string;
|
|
||||||
next?: IObject | string;
|
|
||||||
prev?: IObject | string;
|
|
||||||
orderedItems?: ApObject;
|
orderedItems?: ApObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AnyCollection = ICollection | IOrderedCollection | ICollectionPage | IOrderedCollectionPage;
|
||||||
|
|
||||||
export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event'];
|
export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event'];
|
||||||
|
|
||||||
export const isPost = (object: IObject): object is IPost => {
|
export const isPost = (object: IObject): object is IPost => {
|
||||||
|
|
@ -184,6 +215,7 @@ export interface IPost extends IObject {
|
||||||
_misskey_content?: string;
|
_misskey_content?: string;
|
||||||
quoteUrl?: string;
|
quoteUrl?: string;
|
||||||
quoteUri?: string;
|
quoteUri?: string;
|
||||||
|
quote?: string;
|
||||||
updated?: string;
|
updated?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,6 +287,7 @@ export interface IActor extends IObject {
|
||||||
enableRss?: boolean;
|
enableRss?: boolean;
|
||||||
listenbrainz?: string;
|
listenbrainz?: string;
|
||||||
backgroundUrl?: string;
|
backgroundUrl?: string;
|
||||||
|
attributionDomains?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isCollection = (object: IObject): object is ICollection =>
|
export const isCollection = (object: IObject): object is ICollection =>
|
||||||
|
|
@ -269,7 +302,7 @@ export const isCollectionPage = (object: IObject): object is ICollectionPage =>
|
||||||
export const isOrderedCollectionPage = (object: IObject): object is IOrderedCollectionPage =>
|
export const isOrderedCollectionPage = (object: IObject): object is IOrderedCollectionPage =>
|
||||||
getApType(object) === 'OrderedCollectionPage';
|
getApType(object) === 'OrderedCollectionPage';
|
||||||
|
|
||||||
export const isCollectionOrOrderedCollection = (object: IObject): object is ICollection | IOrderedCollection =>
|
export const isCollectionOrOrderedCollection = (object: IObject): object is AnyCollection =>
|
||||||
isCollection(object) || isOrderedCollection(object) || isCollectionPage(object) || isOrderedCollectionPage(object);
|
isCollection(object) || isOrderedCollection(object) || isCollectionPage(object) || isOrderedCollectionPage(object);
|
||||||
|
|
||||||
export interface IApPropertyValue extends IObject {
|
export interface IApPropertyValue extends IObject {
|
||||||
|
|
@ -285,9 +318,8 @@ export const isPropertyValue = (object: IObject): object is IApPropertyValue =>
|
||||||
'value' in object &&
|
'value' in object &&
|
||||||
typeof object.value === 'string';
|
typeof object.value === 'string';
|
||||||
|
|
||||||
export interface IApMention extends IObject {
|
export interface IApMention extends ILink {
|
||||||
type: 'Mention';
|
type: 'Mention';
|
||||||
href: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> {
|
protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> {
|
||||||
const suspendedInstancesQuery = this.instancesRepository.createQueryBuilder('instance')
|
// TODO optimization: replace these with exists()
|
||||||
.select('instance.host')
|
|
||||||
.where('instance.suspensionState != \'none\'');
|
|
||||||
|
|
||||||
const pubsubSubQuery = this.followingsRepository.createQueryBuilder('f')
|
const pubsubSubQuery = this.followingsRepository.createQueryBuilder('f')
|
||||||
.select('f.followerHost')
|
.select('f.followerHost')
|
||||||
.where('f.followerHost IS NOT NULL');
|
.where('f.followerHost IS NOT NULL');
|
||||||
|
|
@ -64,22 +61,25 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
|
||||||
this.followingsRepository.createQueryBuilder('following')
|
this.followingsRepository.createQueryBuilder('following')
|
||||||
.select('COUNT(DISTINCT following.followeeHost)')
|
.select('COUNT(DISTINCT following.followeeHost)')
|
||||||
.where('following.followeeHost IS NOT NULL')
|
.where('following.followeeHost IS NOT NULL')
|
||||||
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || following.followeeHost) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)')
|
.innerJoin('following.followeeInstance', 'followeeInstance')
|
||||||
.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
|
.andWhere('followeeInstance.suspensionState = \'none\'')
|
||||||
|
.andWhere('followeeInstance.isBlocked = false')
|
||||||
.getRawOne()
|
.getRawOne()
|
||||||
.then(x => parseInt(x.count, 10)),
|
.then(x => parseInt(x.count, 10)),
|
||||||
this.followingsRepository.createQueryBuilder('following')
|
this.followingsRepository.createQueryBuilder('following')
|
||||||
.select('COUNT(DISTINCT following.followerHost)')
|
.select('COUNT(DISTINCT following.followerHost)')
|
||||||
.where('following.followerHost IS NOT NULL')
|
.where('following.followerHost IS NOT NULL')
|
||||||
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || following.followerHost) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)')
|
.innerJoin('following.followerInstance', 'followerInstance')
|
||||||
.andWhere(`following.followerHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
|
.andWhere('followerInstance.isBlocked = false')
|
||||||
|
.andWhere('followerInstance.suspensionState = \'none\'')
|
||||||
.getRawOne()
|
.getRawOne()
|
||||||
.then(x => parseInt(x.count, 10)),
|
.then(x => parseInt(x.count, 10)),
|
||||||
this.followingsRepository.createQueryBuilder('following')
|
this.followingsRepository.createQueryBuilder('following')
|
||||||
.select('COUNT(DISTINCT following.followeeHost)')
|
.select('COUNT(DISTINCT following.followeeHost)')
|
||||||
.where('following.followeeHost IS NOT NULL')
|
.where('following.followeeHost IS NOT NULL')
|
||||||
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || following.followeeHost) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)')
|
.innerJoin('following.followeeInstance', 'followeeInstance')
|
||||||
.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
|
.andWhere('followeeInstance.isBlocked = false')
|
||||||
|
.andWhere('followeeInstance.suspensionState = \'none\'')
|
||||||
.andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`)
|
.andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`)
|
||||||
.setParameters(pubsubSubQuery.getParameters())
|
.setParameters(pubsubSubQuery.getParameters())
|
||||||
.getRawOne()
|
.getRawOne()
|
||||||
|
|
@ -87,7 +87,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
|
||||||
this.instancesRepository.createQueryBuilder('instance')
|
this.instancesRepository.createQueryBuilder('instance')
|
||||||
.select('COUNT(instance.id)')
|
.select('COUNT(instance.id)')
|
||||||
.where(`instance.host IN (${ subInstancesQuery.getQuery() })`)
|
.where(`instance.host IN (${ subInstancesQuery.getQuery() })`)
|
||||||
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || instance.host) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)')
|
.andWhere('instance.isBlocked = false')
|
||||||
.andWhere('instance.suspensionState = \'none\'')
|
.andWhere('instance.suspensionState = \'none\'')
|
||||||
.andWhere('instance.isNotResponding = false')
|
.andWhere('instance.isNotResponding = false')
|
||||||
.getRawOne()
|
.getRawOne()
|
||||||
|
|
@ -95,7 +95,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
|
||||||
this.instancesRepository.createQueryBuilder('instance')
|
this.instancesRepository.createQueryBuilder('instance')
|
||||||
.select('COUNT(instance.id)')
|
.select('COUNT(instance.id)')
|
||||||
.where(`instance.host IN (${ pubInstancesQuery.getQuery() })`)
|
.where(`instance.host IN (${ pubInstancesQuery.getQuery() })`)
|
||||||
.andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : '(\'.\' || instance.host) NOT ILIKE ALL(select \'%.\' || x from (select unnest("blockedHosts") as x from "meta") t)')
|
.andWhere('instance.isBlocked = false')
|
||||||
.andWhere('instance.suspensionState = \'none\'')
|
.andWhere('instance.suspensionState = \'none\'')
|
||||||
.andWhere('instance.isNotResponding = false')
|
.andWhere('instance.isNotResponding = false')
|
||||||
.getRawOne()
|
.getRawOne()
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import Chart from '../core.js';
|
||||||
import { ChartLoggerService } from '../ChartLoggerService.js';
|
import { ChartLoggerService } from '../ChartLoggerService.js';
|
||||||
import { name, schema } from './entities/per-user-following.js';
|
import { name, schema } from './entities/per-user-following.js';
|
||||||
import type { KVs } from '../core.js';
|
import type { KVs } from '../core.js';
|
||||||
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ユーザーごとのフォローに関するチャート
|
* ユーザーごとのフォローに関するチャート
|
||||||
|
|
@ -31,23 +32,25 @@ export default class PerUserFollowingChart extends Chart<typeof schema> { // esl
|
||||||
private appLockService: AppLockService,
|
private appLockService: AppLockService,
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
private chartLoggerService: ChartLoggerService,
|
private chartLoggerService: ChartLoggerService,
|
||||||
|
private readonly cacheService: CacheService,
|
||||||
) {
|
) {
|
||||||
super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true);
|
super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> {
|
protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> {
|
||||||
const [
|
const [
|
||||||
localFollowingsCount,
|
followees,
|
||||||
localFollowersCount,
|
followers,
|
||||||
remoteFollowingsCount,
|
|
||||||
remoteFollowersCount,
|
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.followingsRepository.countBy({ followerId: group, followeeHost: IsNull() }),
|
this.cacheService.userFollowingsCache.fetch(group).then(fs => Array.from(fs.values())),
|
||||||
this.followingsRepository.countBy({ followeeId: group, followerHost: IsNull() }),
|
this.cacheService.userFollowersCache.fetch(group).then(fs => Array.from(fs.values())),
|
||||||
this.followingsRepository.countBy({ followerId: group, followeeHost: Not(IsNull()) }),
|
|
||||||
this.followingsRepository.countBy({ followeeId: group, followerHost: Not(IsNull()) }),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const localFollowingsCount = followees.reduce((sum, f) => sum + (f.followeeHost == null ? 1 : 0), 0);
|
||||||
|
const localFollowersCount = followers.reduce((sum, f) => sum + (f.followerHost == null ? 1 : 0), 0);
|
||||||
|
const remoteFollowingsCount = followees.reduce((sum, f) => sum + (f.followeeHost == null ? 0 : 1), 0);
|
||||||
|
const remoteFollowersCount = followers.reduce((sum, f) => sum + (f.followerHost == null ? 0 : 1), 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'local.followings.total': localFollowingsCount,
|
'local.followings.total': localFollowingsCount,
|
||||||
'local.followers.total': localFollowersCount,
|
'local.followers.total': localFollowersCount,
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,14 @@
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { AbuseUserReportsRepository } from '@/models/_.js';
|
import type { AbuseUserReportsRepository, InstancesRepository, MiInstance, MiUser } from '@/models/_.js';
|
||||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||||
import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
|
import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import type { Packed } from '@/misc/json-schema.js';
|
import type { Packed } from '@/misc/json-schema.js';
|
||||||
import { UserEntityService } from './UserEntityService.js';
|
import { UserEntityService } from './UserEntityService.js';
|
||||||
|
import { InstanceEntityService } from './InstanceEntityService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AbuseUserReportEntityService {
|
export class AbuseUserReportEntityService {
|
||||||
|
|
@ -19,6 +20,10 @@ export class AbuseUserReportEntityService {
|
||||||
@Inject(DI.abuseUserReportsRepository)
|
@Inject(DI.abuseUserReportsRepository)
|
||||||
private abuseUserReportsRepository: AbuseUserReportsRepository,
|
private abuseUserReportsRepository: AbuseUserReportsRepository,
|
||||||
|
|
||||||
|
@Inject(DI.instancesRepository)
|
||||||
|
private instancesRepository: InstancesRepository,
|
||||||
|
|
||||||
|
private readonly instanceEntityService: InstanceEntityService,
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
) {
|
) {
|
||||||
|
|
@ -30,11 +35,14 @@ export class AbuseUserReportEntityService {
|
||||||
hint?: {
|
hint?: {
|
||||||
packedReporter?: Packed<'UserDetailedNotMe'>,
|
packedReporter?: Packed<'UserDetailedNotMe'>,
|
||||||
packedTargetUser?: Packed<'UserDetailedNotMe'>,
|
packedTargetUser?: Packed<'UserDetailedNotMe'>,
|
||||||
|
packedTargetInstance?: Packed<'FederationInstance'>,
|
||||||
packedAssignee?: Packed<'UserDetailedNotMe'>,
|
packedAssignee?: Packed<'UserDetailedNotMe'>,
|
||||||
},
|
},
|
||||||
|
me?: MiUser | null,
|
||||||
) {
|
) {
|
||||||
const report = typeof src === 'object' ? src : await this.abuseUserReportsRepository.findOneByOrFail({ id: src });
|
const report = typeof src === 'object' ? src : await this.abuseUserReportsRepository.findOneByOrFail({ id: src });
|
||||||
|
|
||||||
|
// noinspection ES6MissingAwait
|
||||||
return await awaitAll({
|
return await awaitAll({
|
||||||
id: report.id,
|
id: report.id,
|
||||||
createdAt: this.idService.parse(report.id).date.toISOString(),
|
createdAt: this.idService.parse(report.id).date.toISOString(),
|
||||||
|
|
@ -43,13 +51,22 @@ export class AbuseUserReportEntityService {
|
||||||
reporterId: report.reporterId,
|
reporterId: report.reporterId,
|
||||||
targetUserId: report.targetUserId,
|
targetUserId: report.targetUserId,
|
||||||
assigneeId: report.assigneeId,
|
assigneeId: report.assigneeId,
|
||||||
reporter: hint?.packedReporter ?? this.userEntityService.pack(report.reporter ?? report.reporterId, null, {
|
reporter: hint?.packedReporter ?? this.userEntityService.pack(report.reporter ?? report.reporterId, me, {
|
||||||
schema: 'UserDetailedNotMe',
|
schema: 'UserDetailedNotMe',
|
||||||
}),
|
}),
|
||||||
targetUser: hint?.packedTargetUser ?? this.userEntityService.pack(report.targetUser ?? report.targetUserId, null, {
|
targetUser: hint?.packedTargetUser ?? this.userEntityService.pack(report.targetUser ?? report.targetUserId, me, {
|
||||||
schema: 'UserDetailedNotMe',
|
schema: 'UserDetailedNotMe',
|
||||||
}),
|
}),
|
||||||
assignee: report.assigneeId ? hint?.packedAssignee ?? this.userEntityService.pack(report.assignee ?? report.assigneeId, null, {
|
// return hint, or pack by relation, or fetch and pack by id, or null
|
||||||
|
targetInstance: hint?.packedTargetInstance ?? (
|
||||||
|
report.targetUserInstance
|
||||||
|
? this.instanceEntityService.pack(report.targetUserInstance, me)
|
||||||
|
: report.targetUserHost
|
||||||
|
? this.instancesRepository.findOneBy({ host: report.targetUserHost }).then(instance => instance
|
||||||
|
? this.instanceEntityService.pack(instance, me)
|
||||||
|
: null)
|
||||||
|
: null),
|
||||||
|
assignee: report.assigneeId ? hint?.packedAssignee ?? this.userEntityService.pack(report.assignee ?? report.assigneeId, me, {
|
||||||
schema: 'UserDetailedNotMe',
|
schema: 'UserDetailedNotMe',
|
||||||
}) : null,
|
}) : null,
|
||||||
forwarded: report.forwarded,
|
forwarded: report.forwarded,
|
||||||
|
|
@ -61,21 +78,28 @@ export class AbuseUserReportEntityService {
|
||||||
@bindThis
|
@bindThis
|
||||||
public async packMany(
|
public async packMany(
|
||||||
reports: MiAbuseUserReport[],
|
reports: MiAbuseUserReport[],
|
||||||
|
me?: MiUser | null,
|
||||||
) {
|
) {
|
||||||
const _reporters = reports.map(({ reporter, reporterId }) => reporter ?? reporterId);
|
const _reporters = reports.map(({ reporter, reporterId }) => reporter ?? reporterId);
|
||||||
const _targetUsers = reports.map(({ targetUser, targetUserId }) => targetUser ?? targetUserId);
|
const _targetUsers = reports.map(({ targetUser, targetUserId }) => targetUser ?? targetUserId);
|
||||||
const _assignees = reports.map(({ assignee, assigneeId }) => assignee ?? assigneeId).filter(x => x != null);
|
const _assignees = reports.map(({ assignee, assigneeId }) => assignee ?? assigneeId).filter(x => x != null);
|
||||||
const _userMap = await this.userEntityService.packMany(
|
const _userMap = await this.userEntityService.packMany(
|
||||||
[..._reporters, ..._targetUsers, ..._assignees],
|
[..._reporters, ..._targetUsers, ..._assignees],
|
||||||
null,
|
me,
|
||||||
{ schema: 'UserDetailedNotMe' },
|
{ schema: 'UserDetailedNotMe' },
|
||||||
).then(users => new Map(users.map(u => [u.id, u])));
|
).then(users => new Map(users.map(u => [u.id, u])));
|
||||||
|
const _targetInstances = reports
|
||||||
|
.map(({ targetUserInstance, targetUserHost }) => targetUserInstance ?? targetUserHost)
|
||||||
|
.filter((i): i is MiInstance | string => i != null);
|
||||||
|
const _instanceMap = await this.instanceEntityService.packMany(await this.instanceEntityService.fetchInstancesByHost(_targetInstances), me)
|
||||||
|
.then(instances => new Map(instances.map(i => [i.host, i])));
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
reports.map(report => {
|
reports.map(report => {
|
||||||
const packedReporter = _userMap.get(report.reporterId);
|
const packedReporter = _userMap.get(report.reporterId);
|
||||||
const packedTargetUser = _userMap.get(report.targetUserId);
|
const packedTargetUser = _userMap.get(report.targetUserId);
|
||||||
|
const packedTargetInstance = report.targetUserHost ? _instanceMap.get(report.targetUserHost) : undefined;
|
||||||
const packedAssignee = report.assigneeId != null ? _userMap.get(report.assigneeId) : undefined;
|
const packedAssignee = report.assigneeId != null ? _userMap.get(report.assigneeId) : undefined;
|
||||||
return this.pack(report, { packedReporter, packedTargetUser, packedAssignee });
|
return this.pack(report, { packedReporter, packedTargetUser, packedAssignee, packedTargetInstance }, me);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { In } from 'typeorm';
|
||||||
import type { Packed } from '@/misc/json-schema.js';
|
import type { Packed } from '@/misc/json-schema.js';
|
||||||
import type { MiInstance } from '@/models/Instance.js';
|
import type { MiInstance } from '@/models/Instance.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
|
@ -11,7 +12,7 @@ import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RoleService } from '@/core/RoleService.js';
|
||||||
import { MiUser } from '@/models/User.js';
|
import { MiUser } from '@/models/User.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { MiMeta } from '@/models/_.js';
|
import type { InstancesRepository, MiMeta } from '@/models/_.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InstanceEntityService {
|
export class InstanceEntityService {
|
||||||
|
|
@ -19,6 +20,9 @@ export class InstanceEntityService {
|
||||||
@Inject(DI.meta)
|
@Inject(DI.meta)
|
||||||
private meta: MiMeta,
|
private meta: MiMeta,
|
||||||
|
|
||||||
|
@Inject(DI.instancesRepository)
|
||||||
|
private readonly instancesRepository: InstancesRepository,
|
||||||
|
|
||||||
private roleService: RoleService,
|
private roleService: RoleService,
|
||||||
|
|
||||||
private utilityService: UtilityService,
|
private utilityService: UtilityService,
|
||||||
|
|
@ -43,7 +47,7 @@ export class InstanceEntityService {
|
||||||
isNotResponding: instance.isNotResponding,
|
isNotResponding: instance.isNotResponding,
|
||||||
isSuspended: instance.suspensionState !== 'none',
|
isSuspended: instance.suspensionState !== 'none',
|
||||||
suspensionState: instance.suspensionState,
|
suspensionState: instance.suspensionState,
|
||||||
isBlocked: this.utilityService.isBlockedHost(this.meta.blockedHosts, instance.host),
|
isBlocked: instance.isBlocked,
|
||||||
softwareName: instance.softwareName,
|
softwareName: instance.softwareName,
|
||||||
softwareVersion: instance.softwareVersion,
|
softwareVersion: instance.softwareVersion,
|
||||||
openRegistrations: instance.openRegistrations,
|
openRegistrations: instance.openRegistrations,
|
||||||
|
|
@ -51,8 +55,8 @@ export class InstanceEntityService {
|
||||||
description: instance.description,
|
description: instance.description,
|
||||||
maintainerName: instance.maintainerName,
|
maintainerName: instance.maintainerName,
|
||||||
maintainerEmail: instance.maintainerEmail,
|
maintainerEmail: instance.maintainerEmail,
|
||||||
isSilenced: this.utilityService.isSilencedHost(this.meta.silencedHosts, instance.host),
|
isSilenced: instance.isSilenced,
|
||||||
isMediaSilenced: this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, instance.host),
|
isMediaSilenced: instance.isMediaSilenced,
|
||||||
iconUrl: instance.iconUrl,
|
iconUrl: instance.iconUrl,
|
||||||
faviconUrl: instance.faviconUrl,
|
faviconUrl: instance.faviconUrl,
|
||||||
themeColor: instance.themeColor,
|
themeColor: instance.themeColor,
|
||||||
|
|
@ -62,6 +66,7 @@ export class InstanceEntityService {
|
||||||
rejectReports: instance.rejectReports,
|
rejectReports: instance.rejectReports,
|
||||||
rejectQuotes: instance.rejectQuotes,
|
rejectQuotes: instance.rejectQuotes,
|
||||||
moderationNote: iAmModerator ? instance.moderationNote : null,
|
moderationNote: iAmModerator ? instance.moderationNote : null,
|
||||||
|
isBubbled: this.utilityService.isBubbledHost(instance.host),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,5 +77,28 @@ export class InstanceEntityService {
|
||||||
) {
|
) {
|
||||||
return Promise.all(instances.map(x => this.pack(x, me)));
|
return Promise.all(instances.map(x => this.pack(x, me)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async fetchInstancesByHost(instances: (MiInstance | MiInstance['host'])[]): Promise<MiInstance[]> {
|
||||||
|
const result: MiInstance[] = [];
|
||||||
|
|
||||||
|
const toFetch: string[] = [];
|
||||||
|
for (const instance of instances) {
|
||||||
|
if (typeof(instance) === 'string') {
|
||||||
|
toFetch.push(instance);
|
||||||
|
} else {
|
||||||
|
result.push(instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toFetch.length > 0) {
|
||||||
|
const fetched = await this.instancesRepository.findBy({
|
||||||
|
host: In(toFetch),
|
||||||
|
});
|
||||||
|
result.push(...fetched);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,13 @@ import type { Packed } from '@/misc/json-schema.js';
|
||||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||||
import type { MiUser } from '@/models/User.js';
|
import type { MiUser } from '@/models/User.js';
|
||||||
import type { MiNote } from '@/models/Note.js';
|
import type { MiNote } from '@/models/Note.js';
|
||||||
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta } from '@/models/_.js';
|
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, MiPollVote, MiPoll, MiChannel, MiFollowing } from '@/models/_.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { DebounceLoader } from '@/misc/loader.js';
|
import { DebounceLoader } from '@/misc/loader.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
|
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
|
||||||
import { isPackedPureRenote } from '@/misc/is-renote.js';
|
import { isPackedPureRenote } from '@/misc/is-renote.js';
|
||||||
|
import type { Config } from '@/config.js';
|
||||||
import type { OnModuleInit } from '@nestjs/common';
|
import type { OnModuleInit } from '@nestjs/common';
|
||||||
import type { CacheService } from '../CacheService.js';
|
import type { CacheService } from '../CacheService.js';
|
||||||
import type { CustomEmojiService } from '../CustomEmojiService.js';
|
import type { CustomEmojiService } from '../CustomEmojiService.js';
|
||||||
|
|
@ -25,13 +26,13 @@ import type { UserEntityService } from './UserEntityService.js';
|
||||||
import type { DriveFileEntityService } from './DriveFileEntityService.js';
|
import type { DriveFileEntityService } from './DriveFileEntityService.js';
|
||||||
|
|
||||||
// is-renote.tsとよしなにリンク
|
// is-renote.tsとよしなにリンク
|
||||||
function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id']; renote: MiNote } {
|
function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id'] } {
|
||||||
return (
|
return (
|
||||||
note.renote != null &&
|
note.renoteId != null &&
|
||||||
note.reply == null &&
|
note.replyId == null &&
|
||||||
note.text == null &&
|
note.text == null &&
|
||||||
note.cw == null &&
|
note.cw == null &&
|
||||||
(note.fileIds == null || note.fileIds.length === 0) &&
|
note.fileIds.length === 0 &&
|
||||||
!note.hasPoll
|
!note.hasPoll
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -92,6 +93,9 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
@Inject(DI.channelsRepository)
|
@Inject(DI.channelsRepository)
|
||||||
private channelsRepository: ChannelsRepository,
|
private channelsRepository: ChannelsRepository,
|
||||||
|
|
||||||
|
@Inject(DI.config)
|
||||||
|
private readonly config: Config,
|
||||||
|
|
||||||
//private userEntityService: UserEntityService,
|
//private userEntityService: UserEntityService,
|
||||||
//private driveFileEntityService: DriveFileEntityService,
|
//private driveFileEntityService: DriveFileEntityService,
|
||||||
//private customEmojiService: CustomEmojiService,
|
//private customEmojiService: CustomEmojiService,
|
||||||
|
|
@ -128,7 +132,10 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null): Promise<void> {
|
public async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null, hint?: {
|
||||||
|
myFollowing?: ReadonlyMap<string, unknown>,
|
||||||
|
myBlockers?: ReadonlySet<string>,
|
||||||
|
}): Promise<void> {
|
||||||
if (meId === packedNote.userId) return;
|
if (meId === packedNote.userId) return;
|
||||||
|
|
||||||
// TODO: isVisibleForMe を使うようにしても良さそう(型違うけど)
|
// TODO: isVisibleForMe を使うようにしても良さそう(型違うけど)
|
||||||
|
|
@ -184,14 +191,9 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
} else if (packedNote.renote && (meId === packedNote.renote.userId)) {
|
} else if (packedNote.renote && (meId === packedNote.renote.userId)) {
|
||||||
hide = false;
|
hide = false;
|
||||||
} else {
|
} else {
|
||||||
// フォロワーかどうか
|
const isFollowing = hint?.myFollowing
|
||||||
// TODO: 当関数呼び出しごとにクエリが走るのは重そうだからなんとかする
|
? hint.myFollowing.has(packedNote.userId)
|
||||||
const isFollowing = await this.followingsRepository.exists({
|
: (await this.cacheService.userFollowingsCache.fetch(meId)).has(packedNote.userId);
|
||||||
where: {
|
|
||||||
followeeId: packedNote.userId,
|
|
||||||
followerId: meId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
hide = !isFollowing;
|
hide = !isFollowing;
|
||||||
}
|
}
|
||||||
|
|
@ -207,7 +209,8 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hide && meId && packedNote.userId !== meId) {
|
if (!hide && meId && packedNote.userId !== meId) {
|
||||||
const isBlocked = (await this.cacheService.userBlockedCache.fetch(meId)).has(packedNote.userId);
|
const blockers = hint?.myBlockers ?? await this.cacheService.userBlockedCache.fetch(meId);
|
||||||
|
const isBlocked = blockers.has(packedNote.userId);
|
||||||
|
|
||||||
if (isBlocked) hide = true;
|
if (isBlocked) hide = true;
|
||||||
}
|
}
|
||||||
|
|
@ -231,8 +234,11 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async populatePoll(note: MiNote, meId: MiUser['id'] | null) {
|
private async populatePoll(note: MiNote, meId: MiUser['id'] | null, hint?: {
|
||||||
const poll = await this.pollsRepository.findOneByOrFail({ noteId: note.id });
|
poll?: MiPoll,
|
||||||
|
myVotes?: MiPollVote[],
|
||||||
|
}) {
|
||||||
|
const poll = hint?.poll ?? await this.pollsRepository.findOneByOrFail({ noteId: note.id });
|
||||||
const choices = poll.choices.map(c => ({
|
const choices = poll.choices.map(c => ({
|
||||||
text: c,
|
text: c,
|
||||||
votes: poll.votes[poll.choices.indexOf(c)],
|
votes: poll.votes[poll.choices.indexOf(c)],
|
||||||
|
|
@ -241,7 +247,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
|
|
||||||
if (meId) {
|
if (meId) {
|
||||||
if (poll.multiple) {
|
if (poll.multiple) {
|
||||||
const votes = await this.pollVotesRepository.findBy({
|
const votes = hint?.myVotes ?? await this.pollVotesRepository.findBy({
|
||||||
userId: meId,
|
userId: meId,
|
||||||
noteId: note.id,
|
noteId: note.id,
|
||||||
});
|
});
|
||||||
|
|
@ -251,7 +257,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
choices[myChoice].isVoted = true;
|
choices[myChoice].isVoted = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const vote = await this.pollVotesRepository.findOneBy({
|
const vote = hint?.myVotes ? hint.myVotes[0] : await this.pollVotesRepository.findOneBy({
|
||||||
userId: meId,
|
userId: meId,
|
||||||
noteId: note.id,
|
noteId: note.id,
|
||||||
});
|
});
|
||||||
|
|
@ -313,7 +319,12 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async isVisibleForMe(note: MiNote, meId: MiUser['id'] | null): Promise<boolean> {
|
public async isVisibleForMe(note: MiNote, meId: MiUser['id'] | null, hint?: {
|
||||||
|
myFollowing?: ReadonlySet<string>,
|
||||||
|
myBlocking?: ReadonlySet<string>,
|
||||||
|
myBlockers?: ReadonlySet<string>,
|
||||||
|
me?: Pick<MiUser, 'host'> | null,
|
||||||
|
}): Promise<boolean> {
|
||||||
// This code must always be synchronized with the checks in generateVisibilityQuery.
|
// This code must always be synchronized with the checks in generateVisibilityQuery.
|
||||||
// visibility が specified かつ自分が指定されていなかったら非表示
|
// visibility が specified かつ自分が指定されていなかったら非表示
|
||||||
if (note.visibility === 'specified') {
|
if (note.visibility === 'specified') {
|
||||||
|
|
@ -341,16 +352,16 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
// フォロワーかどうか
|
// フォロワーかどうか
|
||||||
const [blocked, following, user] = await Promise.all([
|
const [blocked, following, userHost] = await Promise.all([
|
||||||
this.cacheService.userBlockingCache.fetch(meId).then((ids) => ids.has(note.userId)),
|
hint?.myBlocking
|
||||||
this.followingsRepository.count({
|
? hint.myBlocking.has(note.userId)
|
||||||
where: {
|
: this.cacheService.userBlockingCache.fetch(meId).then((ids) => ids.has(note.userId)),
|
||||||
followeeId: note.userId,
|
hint?.myFollowing
|
||||||
followerId: meId,
|
? hint.myFollowing.has(note.userId)
|
||||||
},
|
: this.cacheService.userFollowingsCache.fetch(meId).then(ids => ids.has(note.userId)),
|
||||||
take: 1,
|
hint?.me !== undefined
|
||||||
}),
|
? (hint.me?.host ?? null)
|
||||||
this.usersRepository.findOneByOrFail({ id: meId }),
|
: this.cacheService.findUserById(meId).then(me => me.host),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (blocked) return false;
|
if (blocked) return false;
|
||||||
|
|
@ -362,12 +373,13 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
in which case we can never know the following. Instead we have
|
in which case we can never know the following. Instead we have
|
||||||
to assume that the users are following each other.
|
to assume that the users are following each other.
|
||||||
*/
|
*/
|
||||||
return following > 0 || (note.userHost != null && user.host != null);
|
return following || (note.userHost != null && userHost != null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meId != null) {
|
if (meId != null) {
|
||||||
const isBlocked = (await this.cacheService.userBlockedCache.fetch(meId)).has(note.userId);
|
const blockers = hint?.myBlockers ?? await this.cacheService.userBlockedCache.fetch(meId);
|
||||||
|
const isBlocked = blockers.has(note.userId);
|
||||||
|
|
||||||
if (isBlocked) return false;
|
if (isBlocked) return false;
|
||||||
}
|
}
|
||||||
|
|
@ -404,6 +416,12 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>;
|
packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>;
|
||||||
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
|
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
|
||||||
mentionHandles: Record<string, string | undefined>;
|
mentionHandles: Record<string, string | undefined>;
|
||||||
|
userFollowings: Map<string, Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>;
|
||||||
|
userBlockers: Map<string, Set<string>>;
|
||||||
|
polls: Map<string, MiPoll>;
|
||||||
|
pollVotes: Map<string, Map<string, MiPollVote[]>>;
|
||||||
|
channels: Map<string, MiChannel>;
|
||||||
|
notes: Map<string, MiNote>;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
): Promise<Packed<'Note'>> {
|
): Promise<Packed<'Note'>> {
|
||||||
|
|
@ -433,9 +451,7 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = note.channelId
|
const channel = note.channelId
|
||||||
? note.channel
|
? (opts._hint_?.channels.get(note.channelId) ?? note.channel ?? await this.channelsRepository.findOneBy({ id: note.channelId }))
|
||||||
? note.channel
|
|
||||||
: await this.channelsRepository.findOneBy({ id: note.channelId })
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const reactionEmojiNames = Object.keys(reactions)
|
const reactionEmojiNames = Object.keys(reactions)
|
||||||
|
|
@ -481,7 +497,10 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
mentionHandles: note.mentions.length > 0 ? this.getUserHandles(note.mentions, options?._hint_?.mentionHandles) : undefined,
|
mentionHandles: note.mentions.length > 0 ? this.getUserHandles(note.mentions, options?._hint_?.mentionHandles) : undefined,
|
||||||
uri: note.uri ?? undefined,
|
uri: note.uri ?? undefined,
|
||||||
url: note.url ?? undefined,
|
url: note.url ?? undefined,
|
||||||
poll: note.hasPoll ? this.populatePoll(note, meId) : undefined,
|
poll: note.hasPoll ? this.populatePoll(note, meId, {
|
||||||
|
poll: opts._hint_?.polls.get(note.id),
|
||||||
|
myVotes: opts._hint_?.pollVotes.get(note.id)?.get(note.userId),
|
||||||
|
}) : undefined,
|
||||||
|
|
||||||
...(meId && Object.keys(reactions).length > 0 ? {
|
...(meId && Object.keys(reactions).length > 0 ? {
|
||||||
myReaction: this.populateMyReaction({
|
myReaction: this.populateMyReaction({
|
||||||
|
|
@ -495,14 +514,14 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
clippedCount: note.clippedCount,
|
clippedCount: note.clippedCount,
|
||||||
processErrors: note.processErrors,
|
processErrors: note.processErrors,
|
||||||
|
|
||||||
reply: note.replyId ? this.pack(note.reply ?? note.replyId, me, {
|
reply: note.replyId ? this.pack(note.reply ?? opts._hint_?.notes.get(note.replyId) ?? note.replyId, me, {
|
||||||
detail: false,
|
detail: false,
|
||||||
skipHide: opts.skipHide,
|
skipHide: opts.skipHide,
|
||||||
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
|
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
|
||||||
_hint_: options?._hint_,
|
_hint_: options?._hint_,
|
||||||
}) : undefined,
|
}) : undefined,
|
||||||
|
|
||||||
renote: note.renoteId ? this.pack(note.renote ?? note.renoteId, me, {
|
renote: note.renoteId ? this.pack(note.renote ?? opts._hint_?.notes.get(note.renoteId) ?? note.renoteId, me, {
|
||||||
detail: true,
|
detail: true,
|
||||||
skipHide: opts.skipHide,
|
skipHide: opts.skipHide,
|
||||||
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
|
withReactionAndUserPairCache: opts.withReactionAndUserPairCache,
|
||||||
|
|
@ -514,7 +533,10 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
this.treatVisibility(packed);
|
this.treatVisibility(packed);
|
||||||
|
|
||||||
if (!opts.skipHide) {
|
if (!opts.skipHide) {
|
||||||
await this.hideNote(packed, meId);
|
await this.hideNote(packed, meId, meId == null ? undefined : {
|
||||||
|
myFollowing: opts._hint_?.userFollowings.get(meId),
|
||||||
|
myBlockers: opts._hint_?.userBlockers.get(meId),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return packed;
|
return packed;
|
||||||
|
|
@ -531,79 +553,139 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
) {
|
) {
|
||||||
if (notes.length === 0) return [];
|
if (notes.length === 0) return [];
|
||||||
|
|
||||||
const targetNotes: MiNote[] = [];
|
const targetNotesMap = new Map<string, MiNote>();
|
||||||
|
const targetNotesToFetch : string[] = [];
|
||||||
for (const note of notes) {
|
for (const note of notes) {
|
||||||
if (isPureRenote(note)) {
|
if (isPureRenote(note)) {
|
||||||
// we may need to fetch 'my reaction' for renote target.
|
// we may need to fetch 'my reaction' for renote target.
|
||||||
targetNotes.push(note.renote);
|
if (note.renote) {
|
||||||
if (note.renote.reply) {
|
targetNotesMap.set(note.renote.id, note.renote);
|
||||||
// idem if the renote is also a reply.
|
if (note.renote.reply) {
|
||||||
targetNotes.push(note.renote.reply);
|
// idem if the renote is also a reply.
|
||||||
|
targetNotesMap.set(note.renote.reply.id, note.renote.reply);
|
||||||
|
}
|
||||||
|
} else if (options?.detail) {
|
||||||
|
targetNotesToFetch.push(note.renoteId);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (note.reply) {
|
if (note.reply) {
|
||||||
// idem for OP of a regular reply.
|
// idem for OP of a regular reply.
|
||||||
targetNotes.push(note.reply);
|
targetNotesMap.set(note.reply.id, note.reply);
|
||||||
|
} else if (note.replyId && options?.detail) {
|
||||||
|
targetNotesToFetch.push(note.replyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
targetNotes.push(note);
|
targetNotesMap.set(note.id, note);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null;
|
// Don't fetch notes that were added by ID and then found inline in another note.
|
||||||
|
for (let i = targetNotesToFetch.length - 1; i >= 0; i--) {
|
||||||
|
if (targetNotesMap.has(targetNotesToFetch[i])) {
|
||||||
|
targetNotesToFetch.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const meId = me ? me.id : null;
|
// Populate any relations that weren't included in the source
|
||||||
const myReactionsMap = new Map<MiNote['id'], string | null>();
|
if (targetNotesToFetch.length > 0) {
|
||||||
if (meId) {
|
const newNotes = await this.notesRepository.find({
|
||||||
const idsNeedFetchMyReaction = new Set<MiNote['id']>();
|
where: {
|
||||||
|
id: In(targetNotesToFetch),
|
||||||
|
},
|
||||||
|
relations: {
|
||||||
|
user: {
|
||||||
|
userProfile: true,
|
||||||
|
},
|
||||||
|
reply: {
|
||||||
|
user: {
|
||||||
|
userProfile: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
renote: {
|
||||||
|
user: {
|
||||||
|
userProfile: true,
|
||||||
|
},
|
||||||
|
reply: {
|
||||||
|
user: {
|
||||||
|
userProfile: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channel: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
for (const note of targetNotes) {
|
for (const note of newNotes) {
|
||||||
const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.get(note.id)?.deltas ?? {})).reduce((a, b) => a + b, 0);
|
targetNotesMap.set(note.id, note);
|
||||||
if (reactionsCount === 0) {
|
}
|
||||||
myReactionsMap.set(note.id, null);
|
}
|
||||||
} else if (reactionsCount <= note.reactionAndUserPairCache.length + (bufferedReactions?.get(note.id)?.pairs.length ?? 0)) {
|
|
||||||
const pairInBuffer = bufferedReactions?.get(note.id)?.pairs.find(p => p[0] === meId);
|
const targetNotes = Array.from(targetNotesMap.values());
|
||||||
if (pairInBuffer) {
|
const noteIds = Array.from(targetNotesMap.keys());
|
||||||
myReactionsMap.set(note.id, pairInBuffer[1]);
|
|
||||||
} else {
|
const usersMap = new Map<string, MiUser | string>();
|
||||||
const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId));
|
const allUsers = notes.flatMap(note => [
|
||||||
myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null);
|
note.user ?? note.userId,
|
||||||
|
note.reply?.user ?? note.replyUserId,
|
||||||
|
note.renote?.user ?? note.renoteUserId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const user of allUsers) {
|
||||||
|
if (!user) continue;
|
||||||
|
|
||||||
|
if (typeof(user) === 'object') {
|
||||||
|
// ID -> Entity
|
||||||
|
usersMap.set(user.id, user);
|
||||||
|
} else if (!usersMap.has(user)) {
|
||||||
|
// ID -> ID
|
||||||
|
usersMap.set(user, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = Array.from(usersMap.values());
|
||||||
|
const userIds = Array.from(usersMap.keys());
|
||||||
|
|
||||||
|
const fileIds = new Set(targetNotes.flatMap(n => n.fileIds));
|
||||||
|
const mentionedUsers = new Set(targetNotes.flatMap(note => note.mentions));
|
||||||
|
|
||||||
|
const [{ bufferedReactions, myReactionsMap }, packedFiles, packedUsers, mentionHandles, userFollowings, userBlockers, polls, pollVotes, channels] = await Promise.all([
|
||||||
|
// bufferedReactions & myReactionsMap
|
||||||
|
this.getReactions(targetNotes, me),
|
||||||
|
// packedFiles
|
||||||
|
this.driveFileEntityService.packManyByIdsMap(Array.from(fileIds)),
|
||||||
|
// packedUsers
|
||||||
|
this.userEntityService.packMany(users, me)
|
||||||
|
.then(users => new Map(users.map(u => [u.id, u]))),
|
||||||
|
// mentionHandles
|
||||||
|
this.getUserHandles(Array.from(mentionedUsers)),
|
||||||
|
// userFollowings
|
||||||
|
this.cacheService.userFollowingsCache.fetchMany(userIds).then(fs => new Map(fs)),
|
||||||
|
// userBlockers
|
||||||
|
this.cacheService.userBlockedCache.fetchMany(userIds).then(bs => new Map(bs)),
|
||||||
|
// polls
|
||||||
|
this.pollsRepository.findBy({ noteId: In(noteIds) })
|
||||||
|
.then(polls => new Map(polls.map(p => [p.noteId, p]))),
|
||||||
|
// pollVotes
|
||||||
|
this.pollVotesRepository.findBy({ noteId: In(noteIds), userId: In(userIds) })
|
||||||
|
.then(votes => votes.reduce((noteMap, vote) => {
|
||||||
|
let userMap = noteMap.get(vote.noteId);
|
||||||
|
if (!userMap) {
|
||||||
|
userMap = new Map<string, MiPollVote[]>();
|
||||||
|
noteMap.set(vote.noteId, userMap);
|
||||||
}
|
}
|
||||||
} else {
|
let voteList = userMap.get(vote.userId);
|
||||||
idsNeedFetchMyReaction.add(note.id);
|
if (!voteList) {
|
||||||
}
|
voteList = [];
|
||||||
}
|
userMap.set(vote.userId, voteList);
|
||||||
|
}
|
||||||
const myReactions = idsNeedFetchMyReaction.size > 0 ? await this.noteReactionsRepository.findBy({
|
voteList.push(vote);
|
||||||
userId: meId,
|
return noteMap;
|
||||||
noteId: In(Array.from(idsNeedFetchMyReaction)),
|
}, new Map<string, Map<string, MiPollVote[]>>)),
|
||||||
}) : [];
|
// channels
|
||||||
|
this.getChannels(targetNotes),
|
||||||
for (const id of idsNeedFetchMyReaction) {
|
// (not returned)
|
||||||
myReactionsMap.set(id, myReactions.find(reaction => reaction.noteId === id)?.reaction ?? null);
|
this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes)),
|
||||||
}
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes));
|
|
||||||
// TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく
|
|
||||||
const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(x => x != null);
|
|
||||||
const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map();
|
|
||||||
const users = [
|
|
||||||
...notes.map(({ user, userId }) => user ?? userId),
|
|
||||||
...notes.map(({ replyUserId }) => replyUserId).filter(x => x != null),
|
|
||||||
...notes.map(({ renoteUserId }) => renoteUserId).filter(x => x != null),
|
|
||||||
];
|
|
||||||
const packedUsers = await this.userEntityService.packMany(users, me)
|
|
||||||
.then(users => new Map(users.map(u => [u.id, u])));
|
|
||||||
|
|
||||||
// Recursively add all mentioned users from all notes + replies + renotes
|
|
||||||
const allMentionedUsers = targetNotes.reduce((users, note) => {
|
|
||||||
for (const user of note.mentions) {
|
|
||||||
users.add(user);
|
|
||||||
}
|
|
||||||
return users;
|
|
||||||
}, new Set<string>());
|
|
||||||
const mentionHandles = await this.getUserHandles(Array.from(allMentionedUsers));
|
|
||||||
|
|
||||||
return await Promise.all(notes.map(n => this.pack(n, me, {
|
return await Promise.all(notes.map(n => this.pack(n, me, {
|
||||||
...options,
|
...options,
|
||||||
|
|
@ -613,6 +695,12 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
packedFiles,
|
packedFiles,
|
||||||
packedUsers,
|
packedUsers,
|
||||||
mentionHandles,
|
mentionHandles,
|
||||||
|
userFollowings,
|
||||||
|
userBlockers,
|
||||||
|
polls,
|
||||||
|
pollVotes,
|
||||||
|
channels,
|
||||||
|
notes: new Map(targetNotes.map(n => [n.id, n])),
|
||||||
},
|
},
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
@ -680,4 +768,71 @@ export class NoteEntityService implements OnModuleInit {
|
||||||
return map;
|
return map;
|
||||||
}, {} as Record<string, string | undefined>);
|
}, {} as Record<string, string | undefined>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getChannels(notes: MiNote[]): Promise<Map<string, MiChannel>> {
|
||||||
|
const channels = new Map<string, MiChannel>();
|
||||||
|
const channelsToFetch = new Set<string>();
|
||||||
|
|
||||||
|
for (const note of notes) {
|
||||||
|
if (note.channel) {
|
||||||
|
channels.set(note.channel.id, note.channel);
|
||||||
|
} else if (note.channelId) {
|
||||||
|
channelsToFetch.add(note.channelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channelsToFetch.size > 0) {
|
||||||
|
const newChannels = await this.channelsRepository.findBy({
|
||||||
|
id: In(Array.from(channelsToFetch)),
|
||||||
|
});
|
||||||
|
for (const channel of newChannels) {
|
||||||
|
channels.set(channel.id, channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getReactions(notes: MiNote[], me: { id: string } | null | undefined) {
|
||||||
|
const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null;
|
||||||
|
|
||||||
|
const meId = me ? me.id : null;
|
||||||
|
const myReactionsMap = new Map<MiNote['id'], string | null>();
|
||||||
|
if (meId) {
|
||||||
|
const idsNeedFetchMyReaction = new Set<MiNote['id']>();
|
||||||
|
|
||||||
|
for (const note of notes) {
|
||||||
|
const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.get(note.id)?.deltas ?? {})).reduce((a, b) => a + b, 0);
|
||||||
|
if (reactionsCount === 0) {
|
||||||
|
myReactionsMap.set(note.id, null);
|
||||||
|
} else if (reactionsCount <= note.reactionAndUserPairCache.length + (bufferedReactions?.get(note.id)?.pairs.length ?? 0)) {
|
||||||
|
const pairInBuffer = bufferedReactions?.get(note.id)?.pairs.find(p => p[0] === meId);
|
||||||
|
if (pairInBuffer) {
|
||||||
|
myReactionsMap.set(note.id, pairInBuffer[1]);
|
||||||
|
} else {
|
||||||
|
const pair = note.reactionAndUserPairCache.find(p => p.startsWith(meId));
|
||||||
|
myReactionsMap.set(note.id, pair ? pair.split('/')[1] : null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
idsNeedFetchMyReaction.add(note.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const myReactions = idsNeedFetchMyReaction.size > 0 ? await this.noteReactionsRepository.findBy({
|
||||||
|
userId: meId,
|
||||||
|
noteId: In(Array.from(idsNeedFetchMyReaction)),
|
||||||
|
}) : [];
|
||||||
|
|
||||||
|
for (const id of idsNeedFetchMyReaction) {
|
||||||
|
myReactionsMap.set(id, myReactions.find(reaction => reaction.noteId === id)?.reaction ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bufferedReactions, myReactionsMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public genLocalNoteUri(noteId: string): string {
|
||||||
|
return `${this.config.url}/notes/${noteId}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import type {
|
||||||
FollowingsRepository,
|
FollowingsRepository,
|
||||||
FollowRequestsRepository,
|
FollowRequestsRepository,
|
||||||
MiFollowing,
|
MiFollowing,
|
||||||
|
MiInstance,
|
||||||
MiMeta,
|
MiMeta,
|
||||||
MiUserNotePining,
|
MiUserNotePining,
|
||||||
MiUserProfile,
|
MiUserProfile,
|
||||||
|
|
@ -42,7 +43,7 @@ import type {
|
||||||
UsersRepository,
|
UsersRepository,
|
||||||
} from '@/models/_.js';
|
} from '@/models/_.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { RoleService } from '@/core/RoleService.js';
|
import { RolePolicies, RoleService } from '@/core/RoleService.js';
|
||||||
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
|
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
|
||||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
|
@ -52,6 +53,7 @@ import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
|
||||||
import { ChatService } from '@/core/ChatService.js';
|
import { ChatService } from '@/core/ChatService.js';
|
||||||
import { isSystemAccount } from '@/misc/is-system-account.js';
|
import { isSystemAccount } from '@/misc/is-system-account.js';
|
||||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||||
|
import type { CacheService } from '@/core/CacheService.js';
|
||||||
import type { OnModuleInit } from '@nestjs/common';
|
import type { OnModuleInit } from '@nestjs/common';
|
||||||
import type { NoteEntityService } from './NoteEntityService.js';
|
import type { NoteEntityService } from './NoteEntityService.js';
|
||||||
import type { PageEntityService } from './PageEntityService.js';
|
import type { PageEntityService } from './PageEntityService.js';
|
||||||
|
|
@ -77,7 +79,7 @@ function isRemoteUser(user: MiUser | { host: MiUser['host'] }): boolean {
|
||||||
|
|
||||||
export type UserRelation = {
|
export type UserRelation = {
|
||||||
id: MiUser['id']
|
id: MiUser['id']
|
||||||
following: MiFollowing | null,
|
following: Omit<MiFollowing, 'isFollowerHibernated'> | null,
|
||||||
isFollowing: boolean
|
isFollowing: boolean
|
||||||
isFollowed: boolean
|
isFollowed: boolean
|
||||||
hasPendingFollowRequestFromYou: boolean
|
hasPendingFollowRequestFromYou: boolean
|
||||||
|
|
@ -103,6 +105,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
private idService: IdService;
|
private idService: IdService;
|
||||||
private avatarDecorationService: AvatarDecorationService;
|
private avatarDecorationService: AvatarDecorationService;
|
||||||
private chatService: ChatService;
|
private chatService: ChatService;
|
||||||
|
private cacheService: CacheService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private moduleRef: ModuleRef,
|
private moduleRef: ModuleRef,
|
||||||
|
|
@ -163,6 +166,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
this.idService = this.moduleRef.get('IdService');
|
this.idService = this.moduleRef.get('IdService');
|
||||||
this.avatarDecorationService = this.moduleRef.get('AvatarDecorationService');
|
this.avatarDecorationService = this.moduleRef.get('AvatarDecorationService');
|
||||||
this.chatService = this.moduleRef.get('ChatService');
|
this.chatService = this.moduleRef.get('ChatService');
|
||||||
|
this.cacheService = this.moduleRef.get('CacheService');
|
||||||
}
|
}
|
||||||
|
|
||||||
//#region Validators
|
//#region Validators
|
||||||
|
|
@ -193,16 +197,8 @@ export class UserEntityService implements OnModuleInit {
|
||||||
memo,
|
memo,
|
||||||
mutedInstances,
|
mutedInstances,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.followingsRepository.findOneBy({
|
this.cacheService.userFollowingsCache.fetch(me).then(f => f.get(target) ?? null),
|
||||||
followerId: me,
|
this.cacheService.userFollowingsCache.fetch(target).then(f => f.has(me)),
|
||||||
followeeId: target,
|
|
||||||
}),
|
|
||||||
this.followingsRepository.exists({
|
|
||||||
where: {
|
|
||||||
followerId: target,
|
|
||||||
followeeId: me,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
this.followRequestsRepository.exists({
|
this.followRequestsRepository.exists({
|
||||||
where: {
|
where: {
|
||||||
followerId: me,
|
followerId: me,
|
||||||
|
|
@ -215,45 +211,22 @@ export class UserEntityService implements OnModuleInit {
|
||||||
followeeId: me,
|
followeeId: me,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.blockingsRepository.exists({
|
this.cacheService.userBlockingCache.fetch(me)
|
||||||
where: {
|
.then(blockees => blockees.has(target)),
|
||||||
blockerId: me,
|
this.cacheService.userBlockedCache.fetch(me)
|
||||||
blockeeId: target,
|
.then(blockers => blockers.has(target)),
|
||||||
},
|
this.cacheService.userMutingsCache.fetch(me)
|
||||||
}),
|
.then(mutings => mutings.has(target)),
|
||||||
this.blockingsRepository.exists({
|
this.cacheService.renoteMutingsCache.fetch(me)
|
||||||
where: {
|
.then(mutings => mutings.has(target)),
|
||||||
blockerId: target,
|
this.cacheService.findUserById(target).then(u => u.host),
|
||||||
blockeeId: me,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
this.mutingsRepository.exists({
|
|
||||||
where: {
|
|
||||||
muterId: me,
|
|
||||||
muteeId: target,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
this.renoteMutingsRepository.exists({
|
|
||||||
where: {
|
|
||||||
muterId: me,
|
|
||||||
muteeId: target,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
this.usersRepository.createQueryBuilder('u')
|
|
||||||
.select('u.host')
|
|
||||||
.where({ id: target })
|
|
||||||
.getRawOne<{ u_host: string }>()
|
|
||||||
.then(it => it?.u_host ?? null),
|
|
||||||
this.userMemosRepository.createQueryBuilder('m')
|
this.userMemosRepository.createQueryBuilder('m')
|
||||||
.select('m.memo')
|
.select('m.memo')
|
||||||
.where({ userId: me, targetUserId: target })
|
.where({ userId: me, targetUserId: target })
|
||||||
.getRawOne<{ m_memo: string | null }>()
|
.getRawOne<{ m_memo: string | null }>()
|
||||||
.then(it => it?.m_memo ?? null),
|
.then(it => it?.m_memo ?? null),
|
||||||
this.userProfilesRepository.createQueryBuilder('p')
|
this.cacheService.userProfileCache.fetch(me)
|
||||||
.select('p.mutedInstances')
|
.then(profile => profile.mutedInstances),
|
||||||
.where({ userId: me })
|
|
||||||
.getRawOne<{ p_mutedInstances: string[] }>()
|
|
||||||
.then(it => it?.p_mutedInstances ?? []),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isInstanceMuted = !!host && mutedInstances.includes(host);
|
const isInstanceMuted = !!host && mutedInstances.includes(host);
|
||||||
|
|
@ -277,8 +250,8 @@ export class UserEntityService implements OnModuleInit {
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getRelations(me: MiUser['id'], targets: MiUser['id'][]): Promise<Map<MiUser['id'], UserRelation>> {
|
public async getRelations(me: MiUser['id'], targets: MiUser['id'][]): Promise<Map<MiUser['id'], UserRelation>> {
|
||||||
const [
|
const [
|
||||||
followers,
|
myFollowing,
|
||||||
followees,
|
myFollowers,
|
||||||
followersRequests,
|
followersRequests,
|
||||||
followeesRequests,
|
followeesRequests,
|
||||||
blockers,
|
blockers,
|
||||||
|
|
@ -289,13 +262,8 @@ export class UserEntityService implements OnModuleInit {
|
||||||
memos,
|
memos,
|
||||||
mutedInstances,
|
mutedInstances,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.followingsRepository.findBy({ followerId: me })
|
this.cacheService.userFollowingsCache.fetch(me),
|
||||||
.then(f => new Map(f.map(it => [it.followeeId, it]))),
|
this.cacheService.userFollowersCache.fetch(me),
|
||||||
this.followingsRepository.createQueryBuilder('f')
|
|
||||||
.select('f.followerId')
|
|
||||||
.where('f.followeeId = :me', { me })
|
|
||||||
.getRawMany<{ f_followerId: string }>()
|
|
||||||
.then(it => it.map(it => it.f_followerId)),
|
|
||||||
this.followRequestsRepository.createQueryBuilder('f')
|
this.followRequestsRepository.createQueryBuilder('f')
|
||||||
.select('f.followeeId')
|
.select('f.followeeId')
|
||||||
.where('f.followerId = :me', { me })
|
.where('f.followerId = :me', { me })
|
||||||
|
|
@ -306,34 +274,18 @@ export class UserEntityService implements OnModuleInit {
|
||||||
.where('f.followeeId = :me', { me })
|
.where('f.followeeId = :me', { me })
|
||||||
.getRawMany<{ f_followerId: string }>()
|
.getRawMany<{ f_followerId: string }>()
|
||||||
.then(it => it.map(it => it.f_followerId)),
|
.then(it => it.map(it => it.f_followerId)),
|
||||||
this.blockingsRepository.createQueryBuilder('b')
|
this.cacheService.userBlockedCache.fetch(me),
|
||||||
.select('b.blockeeId')
|
this.cacheService.userBlockingCache.fetch(me),
|
||||||
.where('b.blockerId = :me', { me })
|
this.cacheService.userMutingsCache.fetch(me),
|
||||||
.getRawMany<{ b_blockeeId: string }>()
|
this.cacheService.renoteMutingsCache.fetch(me),
|
||||||
.then(it => it.map(it => it.b_blockeeId)),
|
this.cacheService.getUsers(targets)
|
||||||
this.blockingsRepository.createQueryBuilder('b')
|
.then(users => {
|
||||||
.select('b.blockerId')
|
const record: Record<string, string | null> = {};
|
||||||
.where('b.blockeeId = :me', { me })
|
for (const [id, user] of users) {
|
||||||
.getRawMany<{ b_blockerId: string }>()
|
record[id] = user.host;
|
||||||
.then(it => it.map(it => it.b_blockerId)),
|
}
|
||||||
this.mutingsRepository.createQueryBuilder('m')
|
return record;
|
||||||
.select('m.muteeId')
|
}),
|
||||||
.where('m.muterId = :me', { me })
|
|
||||||
.getRawMany<{ m_muteeId: string }>()
|
|
||||||
.then(it => it.map(it => it.m_muteeId)),
|
|
||||||
this.renoteMutingsRepository.createQueryBuilder('m')
|
|
||||||
.select('m.muteeId')
|
|
||||||
.where('m.muterId = :me', { me })
|
|
||||||
.getRawMany<{ m_muteeId: string }>()
|
|
||||||
.then(it => it.map(it => it.m_muteeId)),
|
|
||||||
this.usersRepository.createQueryBuilder('u')
|
|
||||||
.select(['u.id', 'u.host'])
|
|
||||||
.where({ id: In(targets) } )
|
|
||||||
.getRawMany<{ m_id: string, m_host: string }>()
|
|
||||||
.then(it => it.reduce((map, it) => {
|
|
||||||
map[it.m_id] = it.m_host;
|
|
||||||
return map;
|
|
||||||
}, {} as Record<string, string>)),
|
|
||||||
this.userMemosRepository.createQueryBuilder('m')
|
this.userMemosRepository.createQueryBuilder('m')
|
||||||
.select(['m.targetUserId', 'm.memo'])
|
.select(['m.targetUserId', 'm.memo'])
|
||||||
.where({ userId: me, targetUserId: In(targets) })
|
.where({ userId: me, targetUserId: In(targets) })
|
||||||
|
|
@ -342,16 +294,13 @@ export class UserEntityService implements OnModuleInit {
|
||||||
map[it.m_targetUserId] = it.m_memo;
|
map[it.m_targetUserId] = it.m_memo;
|
||||||
return map;
|
return map;
|
||||||
}, {} as Record<string, string | null>)),
|
}, {} as Record<string, string | null>)),
|
||||||
this.userProfilesRepository.createQueryBuilder('p')
|
this.cacheService.userProfileCache.fetch(me)
|
||||||
.select('p.mutedInstances')
|
.then(p => p.mutedInstances),
|
||||||
.where({ userId: me })
|
|
||||||
.getRawOne<{ p_mutedInstances: string[] }>()
|
|
||||||
.then(it => it?.p_mutedInstances ?? []),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return new Map(
|
return new Map(
|
||||||
targets.map(target => {
|
targets.map(target => {
|
||||||
const following = followers.get(target) ?? null;
|
const following = myFollowing.get(target) ?? null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
target,
|
target,
|
||||||
|
|
@ -359,14 +308,14 @@ export class UserEntityService implements OnModuleInit {
|
||||||
id: target,
|
id: target,
|
||||||
following: following,
|
following: following,
|
||||||
isFollowing: following != null,
|
isFollowing: following != null,
|
||||||
isFollowed: followees.includes(target),
|
isFollowed: myFollowers.has(target),
|
||||||
hasPendingFollowRequestFromYou: followersRequests.includes(target),
|
hasPendingFollowRequestFromYou: followersRequests.includes(target),
|
||||||
hasPendingFollowRequestToYou: followeesRequests.includes(target),
|
hasPendingFollowRequestToYou: followeesRequests.includes(target),
|
||||||
isBlocking: blockers.includes(target),
|
isBlocking: blockees.has(target),
|
||||||
isBlocked: blockees.includes(target),
|
isBlocked: blockers.has(target),
|
||||||
isMuted: muters.includes(target),
|
isMuted: muters.has(target),
|
||||||
isRenoteMuted: renoteMuters.includes(target),
|
isRenoteMuted: renoteMuters.has(target),
|
||||||
isInstanceMuted: mutedInstances.includes(hosts[target]),
|
isInstanceMuted: hosts[target] != null && mutedInstances.includes(hosts[target]),
|
||||||
memo: memos[target] ?? null,
|
memo: memos[target] ?? null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -391,6 +340,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
return false; // TODO
|
return false; // TODO
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO optimization: make redis calls in MULTI
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getNotificationsInfo(userId: MiUser['id']): Promise<{
|
public async getNotificationsInfo(userId: MiUser['id']): Promise<{
|
||||||
hasUnread: boolean;
|
hasUnread: boolean;
|
||||||
|
|
@ -424,16 +374,14 @@ export class UserEntityService implements OnModuleInit {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getHasPendingReceivedFollowRequest(userId: MiUser['id']): Promise<boolean> {
|
public async getHasPendingReceivedFollowRequest(userId: MiUser['id']): Promise<boolean> {
|
||||||
const count = await this.followRequestsRepository.countBy({
|
return await this.followRequestsRepository.existsBy({
|
||||||
followeeId: userId,
|
followeeId: userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return count > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getHasPendingSentFollowRequest(userId: MiUser['id']): Promise<boolean> {
|
public async getHasPendingSentFollowRequest(userId: MiUser['id']): Promise<boolean> {
|
||||||
return this.followRequestsRepository.existsBy({
|
return await this.followRequestsRepository.existsBy({
|
||||||
followerId: userId,
|
followerId: userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -480,6 +428,10 @@ export class UserEntityService implements OnModuleInit {
|
||||||
userRelations?: Map<MiUser['id'], UserRelation>,
|
userRelations?: Map<MiUser['id'], UserRelation>,
|
||||||
userMemos?: Map<MiUser['id'], string | null>,
|
userMemos?: Map<MiUser['id'], string | null>,
|
||||||
pinNotes?: Map<MiUser['id'], MiUserNotePining[]>,
|
pinNotes?: Map<MiUser['id'], MiUserNotePining[]>,
|
||||||
|
iAmModerator?: boolean,
|
||||||
|
userIdsByUri?: Map<string, string>,
|
||||||
|
instances?: Map<string, MiInstance | null>,
|
||||||
|
securityKeyCounts?: Map<string, number>,
|
||||||
},
|
},
|
||||||
): Promise<Packed<S>> {
|
): Promise<Packed<S>> {
|
||||||
const opts = Object.assign({
|
const opts = Object.assign({
|
||||||
|
|
@ -487,7 +439,10 @@ export class UserEntityService implements OnModuleInit {
|
||||||
includeSecrets: false,
|
includeSecrets: false,
|
||||||
}, options);
|
}, options);
|
||||||
|
|
||||||
const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src });
|
const user = typeof src === 'object' ? src : await this.usersRepository.findOneOrFail({
|
||||||
|
where: { id: src },
|
||||||
|
relations: { userProfile: true },
|
||||||
|
});
|
||||||
|
|
||||||
// migration
|
// migration
|
||||||
if (user.avatarId != null && user.avatarUrl === null) {
|
if (user.avatarId != null && user.avatarUrl === null) {
|
||||||
|
|
@ -518,10 +473,10 @@ export class UserEntityService implements OnModuleInit {
|
||||||
const isDetailed = opts.schema !== 'UserLite';
|
const isDetailed = opts.schema !== 'UserLite';
|
||||||
const meId = me ? me.id : null;
|
const meId = me ? me.id : null;
|
||||||
const isMe = meId === user.id;
|
const isMe = meId === user.id;
|
||||||
const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false;
|
const iAmModerator = opts.iAmModerator ?? (me ? await this.roleService.isModerator(me as MiUser) : false);
|
||||||
|
|
||||||
const profile = isDetailed
|
const profile = isDetailed
|
||||||
? (opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }))
|
? (opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
let relation: UserRelation | null = null;
|
let relation: UserRelation | null = null;
|
||||||
|
|
@ -556,7 +511,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mastoapi = !isDetailed ? opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null;
|
const mastoapi = !isDetailed ? opts.userProfile ?? user.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null;
|
||||||
|
|
||||||
const followingCount = profile == null ? null :
|
const followingCount = profile == null ? null :
|
||||||
(profile.followingVisibility === 'public') || isMe || iAmModerator ? user.followingCount :
|
(profile.followingVisibility === 'public') || isMe || iAmModerator ? user.followingCount :
|
||||||
|
|
@ -579,6 +534,9 @@ export class UserEntityService implements OnModuleInit {
|
||||||
const checkHost = user.host == null ? this.config.host : user.host;
|
const checkHost = user.host == null ? this.config.host : user.host;
|
||||||
const notificationsInfo = isMe && isDetailed ? await this.getNotificationsInfo(user.id) : null;
|
const notificationsInfo = isMe && isDetailed ? await this.getNotificationsInfo(user.id) : null;
|
||||||
|
|
||||||
|
let fetchPoliciesPromise: Promise<RolePolicies> | null = null;
|
||||||
|
const fetchPolicies = () => fetchPoliciesPromise ??= this.roleService.getUserPolicies(user);
|
||||||
|
|
||||||
const packed = {
|
const packed = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
|
|
@ -603,19 +561,21 @@ export class UserEntityService implements OnModuleInit {
|
||||||
enableRss: user.enableRss,
|
enableRss: user.enableRss,
|
||||||
mandatoryCW: user.mandatoryCW,
|
mandatoryCW: user.mandatoryCW,
|
||||||
rejectQuotes: user.rejectQuotes,
|
rejectQuotes: user.rejectQuotes,
|
||||||
isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
|
attributionDomains: user.attributionDomains,
|
||||||
|
isSilenced: user.isSilenced || fetchPolicies().then(r => !r.canPublicNote),
|
||||||
speakAsCat: user.speakAsCat ?? false,
|
speakAsCat: user.speakAsCat ?? false,
|
||||||
approved: user.approved,
|
approved: user.approved,
|
||||||
requireSigninToViewContents: user.requireSigninToViewContents === false ? undefined : true,
|
requireSigninToViewContents: user.requireSigninToViewContents === false ? undefined : true,
|
||||||
makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore ?? undefined,
|
makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore ?? undefined,
|
||||||
makeNotesHiddenBefore: user.makeNotesHiddenBefore ?? undefined,
|
makeNotesHiddenBefore: user.makeNotesHiddenBefore ?? undefined,
|
||||||
instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? {
|
instance: user.host ? Promise.resolve(opts.instances?.has(user.host) ? opts.instances.get(user.host) : this.federatedInstanceService.fetch(user.host)).then(instance => instance ? {
|
||||||
name: instance.name,
|
name: instance.name,
|
||||||
softwareName: instance.softwareName,
|
softwareName: instance.softwareName,
|
||||||
softwareVersion: instance.softwareVersion,
|
softwareVersion: instance.softwareVersion,
|
||||||
iconUrl: instance.iconUrl,
|
iconUrl: instance.iconUrl,
|
||||||
faviconUrl: instance.faviconUrl,
|
faviconUrl: instance.faviconUrl,
|
||||||
themeColor: instance.themeColor,
|
themeColor: instance.themeColor,
|
||||||
|
isSilenced: instance.isSilenced,
|
||||||
} : undefined) : undefined,
|
} : undefined) : undefined,
|
||||||
followersCount: followersCount ?? 0,
|
followersCount: followersCount ?? 0,
|
||||||
followingCount: followingCount ?? 0,
|
followingCount: followingCount ?? 0,
|
||||||
|
|
@ -623,7 +583,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
emojis: this.customEmojiService.populateEmojis(user.emojis, checkHost),
|
emojis: this.customEmojiService.populateEmojis(user.emojis, checkHost),
|
||||||
onlineStatus: this.getOnlineStatus(user),
|
onlineStatus: this.getOnlineStatus(user),
|
||||||
// パフォーマンス上の理由でローカルユーザーのみ
|
// パフォーマンス上の理由でローカルユーザーのみ
|
||||||
badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then((rs) => rs
|
badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user).then((rs) => rs
|
||||||
.filter((r) => r.isPublic || iAmModerator)
|
.filter((r) => r.isPublic || iAmModerator)
|
||||||
.sort((a, b) => b.displayOrder - a.displayOrder)
|
.sort((a, b) => b.displayOrder - a.displayOrder)
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
|
|
@ -636,9 +596,9 @@ export class UserEntityService implements OnModuleInit {
|
||||||
...(isDetailed ? {
|
...(isDetailed ? {
|
||||||
url: profile!.url,
|
url: profile!.url,
|
||||||
uri: user.uri,
|
uri: user.uri,
|
||||||
movedTo: user.movedToUri ? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null) : null,
|
movedTo: user.movedToUri ? Promise.resolve(opts.userIdsByUri?.get(user.movedToUri) ?? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null)) : null,
|
||||||
alsoKnownAs: user.alsoKnownAs
|
alsoKnownAs: user.alsoKnownAs
|
||||||
? Promise.all(user.alsoKnownAs.map(uri => this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null)))
|
? Promise.all(user.alsoKnownAs.map(uri => Promise.resolve(opts.userIdsByUri?.get(uri) ?? this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null))))
|
||||||
.then(xs => xs.length === 0 ? null : xs.filter(x => x != null))
|
.then(xs => xs.length === 0 ? null : xs.filter(x => x != null))
|
||||||
: null,
|
: null,
|
||||||
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
|
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
|
||||||
|
|
@ -665,8 +625,8 @@ export class UserEntityService implements OnModuleInit {
|
||||||
followersVisibility: profile!.followersVisibility,
|
followersVisibility: profile!.followersVisibility,
|
||||||
followingVisibility: profile!.followingVisibility,
|
followingVisibility: profile!.followingVisibility,
|
||||||
chatScope: user.chatScope,
|
chatScope: user.chatScope,
|
||||||
canChat: this.roleService.getUserPolicies(user.id).then(r => r.chatAvailability === 'available'),
|
canChat: fetchPolicies().then(r => r.chatAvailability === 'available'),
|
||||||
roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({
|
roles: this.roleService.getUserRoles(user).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({
|
||||||
id: role.id,
|
id: role.id,
|
||||||
name: role.name,
|
name: role.name,
|
||||||
color: role.color,
|
color: role.color,
|
||||||
|
|
@ -684,7 +644,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
twoFactorEnabled: profile!.twoFactorEnabled,
|
twoFactorEnabled: profile!.twoFactorEnabled,
|
||||||
usePasswordLessLogin: profile!.usePasswordLessLogin,
|
usePasswordLessLogin: profile!.usePasswordLessLogin,
|
||||||
securityKeys: profile!.twoFactorEnabled
|
securityKeys: profile!.twoFactorEnabled
|
||||||
? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1)
|
? Promise.resolve(opts.securityKeyCounts?.get(user.id) ?? this.userSecurityKeysRepository.countBy({ userId: user.id })).then(result => result >= 1)
|
||||||
: false,
|
: false,
|
||||||
} : {}),
|
} : {}),
|
||||||
|
|
||||||
|
|
@ -728,7 +688,7 @@ export class UserEntityService implements OnModuleInit {
|
||||||
emailNotificationTypes: profile!.emailNotificationTypes,
|
emailNotificationTypes: profile!.emailNotificationTypes,
|
||||||
achievements: profile!.achievements,
|
achievements: profile!.achievements,
|
||||||
loggedInDays: profile!.loggedInDates.length,
|
loggedInDays: profile!.loggedInDates.length,
|
||||||
policies: this.roleService.getUserPolicies(user.id),
|
policies: fetchPolicies(),
|
||||||
defaultCW: profile!.defaultCW,
|
defaultCW: profile!.defaultCW,
|
||||||
defaultCWPriority: profile!.defaultCWPriority,
|
defaultCWPriority: profile!.defaultCWPriority,
|
||||||
allowUnsignedFetch: user.allowUnsignedFetch,
|
allowUnsignedFetch: user.allowUnsignedFetch,
|
||||||
|
|
@ -778,57 +738,103 @@ export class UserEntityService implements OnModuleInit {
|
||||||
includeSecrets?: boolean,
|
includeSecrets?: boolean,
|
||||||
},
|
},
|
||||||
): Promise<Packed<S>[]> {
|
): Promise<Packed<S>[]> {
|
||||||
|
if (users.length === 0) return [];
|
||||||
|
|
||||||
// -- IDのみの要素を補完して完全なエンティティ一覧を作る
|
// -- IDのみの要素を補完して完全なエンティティ一覧を作る
|
||||||
|
|
||||||
const _users = users.filter((user): user is MiUser => typeof user !== 'string');
|
const _users = users.filter((user): user is MiUser => typeof user !== 'string');
|
||||||
if (_users.length !== users.length) {
|
if (_users.length !== users.length) {
|
||||||
_users.push(
|
_users.push(
|
||||||
...await this.usersRepository.findBy({
|
...await this.usersRepository.find({
|
||||||
id: In(users.filter((user): user is string => typeof user === 'string')),
|
where: {
|
||||||
|
id: In(users.filter((user): user is string => typeof user === 'string')),
|
||||||
|
},
|
||||||
|
relations: {
|
||||||
|
userProfile: true,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const _userIds = _users.map(u => u.id);
|
const _userIds = _users.map(u => u.id);
|
||||||
|
|
||||||
// -- 実行者の有無や指定スキーマの種別によって要否が異なる値群を取得
|
const iAmModerator = await this.roleService.isModerator(me as MiUser);
|
||||||
|
const meId = me ? me.id : null;
|
||||||
|
const isDetailed = options && options.schema !== 'UserLite';
|
||||||
|
const isDetailedAndMod = isDetailed && iAmModerator;
|
||||||
|
|
||||||
let profilesMap: Map<MiUser['id'], MiUserProfile> = new Map();
|
const userUris = new Set(_users
|
||||||
let userRelations: Map<MiUser['id'], UserRelation> = new Map();
|
.flatMap(user => [user.uri, user.movedToUri])
|
||||||
let userMemos: Map<MiUser['id'], string | null> = new Map();
|
.filter((uri): uri is string => uri != null));
|
||||||
let pinNotes: Map<MiUser['id'], MiUserNotePining[]> = new Map();
|
|
||||||
|
|
||||||
if (options?.schema !== 'UserLite') {
|
const userHosts = new Set(_users
|
||||||
profilesMap = await this.userProfilesRepository.findBy({ userId: In(_userIds) })
|
.map(user => user.host)
|
||||||
.then(profiles => new Map(profiles.map(p => [p.userId, p])));
|
.filter((host): host is string => host != null));
|
||||||
|
|
||||||
const meId = me ? me.id : null;
|
const _profilesFromUsers: [string, MiUserProfile][] = [];
|
||||||
if (meId) {
|
const _profilesToFetch: string[] = [];
|
||||||
userMemos = await this.userMemosRepository.findBy({ userId: meId })
|
for (const user of _users) {
|
||||||
.then(memos => new Map(memos.map(memo => [memo.targetUserId, memo.memo])));
|
if (user.userProfile) {
|
||||||
|
_profilesFromUsers.push([user.id, user.userProfile]);
|
||||||
if (_userIds.length > 0) {
|
} else {
|
||||||
userRelations = await this.getRelations(meId, _userIds);
|
_profilesToFetch.push(user.id);
|
||||||
pinNotes = await this.userNotePiningsRepository.createQueryBuilder('pin')
|
|
||||||
.where('pin.userId IN (:...userIds)', { userIds: _userIds })
|
|
||||||
.innerJoinAndSelect('pin.note', 'note')
|
|
||||||
.getMany()
|
|
||||||
.then(pinsNotes => {
|
|
||||||
const map = new Map<MiUser['id'], MiUserNotePining[]>();
|
|
||||||
for (const note of pinsNotes) {
|
|
||||||
const notes = map.get(note.userId) ?? [];
|
|
||||||
notes.push(note);
|
|
||||||
map.set(note.userId, notes);
|
|
||||||
}
|
|
||||||
for (const [, notes] of map.entries()) {
|
|
||||||
// pack側ではDESCで取得しているので、それに合わせて降順に並び替えておく
|
|
||||||
notes.sort((a, b) => b.id.localeCompare(a.id));
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- 実行者の有無や指定スキーマの種別によって要否が異なる値群を取得
|
||||||
|
|
||||||
|
const [profilesMap, userMemos, userRelations, pinNotes, userIdsByUri, instances, securityKeyCounts] = await Promise.all([
|
||||||
|
// profilesMap
|
||||||
|
this.cacheService.userProfileCache.fetchMany(_profilesToFetch).then(profiles => new Map(profiles.concat(_profilesFromUsers))),
|
||||||
|
// userMemos
|
||||||
|
isDetailed && meId ? this.userMemosRepository.findBy({ userId: meId })
|
||||||
|
.then(memos => new Map(memos.map(memo => [memo.targetUserId, memo.memo]))) : new Map(),
|
||||||
|
// userRelations
|
||||||
|
isDetailed && meId ? this.getRelations(meId, _userIds) : new Map(),
|
||||||
|
// pinNotes
|
||||||
|
isDetailed ? this.userNotePiningsRepository.createQueryBuilder('pin')
|
||||||
|
.where('pin.userId IN (:...userIds)', { userIds: _userIds })
|
||||||
|
.innerJoinAndSelect('pin.note', 'note')
|
||||||
|
.getMany()
|
||||||
|
.then(pinsNotes => {
|
||||||
|
const map = new Map<MiUser['id'], MiUserNotePining[]>();
|
||||||
|
for (const note of pinsNotes) {
|
||||||
|
const notes = map.get(note.userId) ?? [];
|
||||||
|
notes.push(note);
|
||||||
|
map.set(note.userId, notes);
|
||||||
|
}
|
||||||
|
for (const [, notes] of map.entries()) {
|
||||||
|
// pack側ではDESCで取得しているので、それに合わせて降順に並び替えておく
|
||||||
|
notes.sort((a, b) => b.id.localeCompare(a.id));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}) : new Map(),
|
||||||
|
// userIdsByUrl
|
||||||
|
isDetailed ? this.usersRepository.createQueryBuilder('user')
|
||||||
|
.select([
|
||||||
|
'user.id',
|
||||||
|
'user.uri',
|
||||||
|
])
|
||||||
|
.where({
|
||||||
|
uri: In(Array.from(userUris)),
|
||||||
|
})
|
||||||
|
.getRawMany<{ user_uri: string, user_id: string }>()
|
||||||
|
.then(users => new Map(users.map(u => [u.user_uri, u.user_id]))) : new Map(),
|
||||||
|
// instances
|
||||||
|
Promise.all(Array.from(userHosts).map(async host => [host, await this.federatedInstanceService.fetch(host)] as const))
|
||||||
|
.then(hosts => new Map(hosts)),
|
||||||
|
// securityKeyCounts
|
||||||
|
isDetailedAndMod ? this.userSecurityKeysRepository.createQueryBuilder('key')
|
||||||
|
.select('key.userId', 'userId')
|
||||||
|
.addSelect('count(key.id)', 'userCount')
|
||||||
|
.where({
|
||||||
|
userId: In(_userIds),
|
||||||
|
})
|
||||||
|
.groupBy('key.userId')
|
||||||
|
.getRawMany<{ userId: string, userCount: number }>()
|
||||||
|
.then(counts => new Map(counts.map(c => [c.userId, c.userCount])))
|
||||||
|
: undefined, // .pack will fetch the keys for the requesting user if it's in the _userIds
|
||||||
|
]);
|
||||||
|
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
_users.map(u => this.pack(
|
_users.map(u => this.pack(
|
||||||
u,
|
u,
|
||||||
|
|
@ -839,6 +845,10 @@ export class UserEntityService implements OnModuleInit {
|
||||||
userRelations: userRelations,
|
userRelations: userRelations,
|
||||||
userMemos: userMemos,
|
userMemos: userMemos,
|
||||||
pinNotes: pinNotes,
|
pinNotes: pinNotes,
|
||||||
|
iAmModerator,
|
||||||
|
userIdsByUri,
|
||||||
|
instances,
|
||||||
|
securityKeyCounts,
|
||||||
},
|
},
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ const envOption = {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
withLogTime: false,
|
withLogTime: false,
|
||||||
quiet: false,
|
quiet: false,
|
||||||
|
hideWorkerId: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) {
|
for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,14 @@ export type DataElement = DataObject | Error | string | null;
|
||||||
// https://stackoverflow.com/questions/61148466/typescript-type-that-matches-any-object-but-not-arrays
|
// https://stackoverflow.com/questions/61148466/typescript-type-that-matches-any-object-but-not-arrays
|
||||||
export type DataObject = Record<string, unknown> | (object & { length?: never; });
|
export type DataObject = Record<string, unknown> | (object & { length?: never; });
|
||||||
|
|
||||||
|
const levelFuncs = {
|
||||||
|
error: 'error',
|
||||||
|
warning: 'warn',
|
||||||
|
success: 'info',
|
||||||
|
info: 'log',
|
||||||
|
debug: 'debug',
|
||||||
|
} as const satisfies Record<Level, keyof typeof console>;
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-default-export
|
// eslint-disable-next-line import/no-default-export
|
||||||
export default class Logger {
|
export default class Logger {
|
||||||
private context: Context;
|
private context: Context;
|
||||||
|
|
@ -71,7 +79,9 @@ export default class Logger {
|
||||||
level === 'info' ? message :
|
level === 'info' ? message :
|
||||||
null;
|
null;
|
||||||
|
|
||||||
let log = `${l} ${worker}\t[${contexts.join(' ')}]\t${m}`;
|
let log = envOption.hideWorkerId
|
||||||
|
? `${l}\t[${contexts.join(' ')}]\t\t${m}`
|
||||||
|
: `${l} ${worker}\t[${contexts.join(' ')}]\t\t${m}`;
|
||||||
if (envOption.withLogTime) log = chalk.gray(time) + ' ' + log;
|
if (envOption.withLogTime) log = chalk.gray(time) + ' ' + log;
|
||||||
|
|
||||||
const args: unknown[] = [important ? chalk.bold(log) : log];
|
const args: unknown[] = [important ? chalk.bold(log) : log];
|
||||||
|
|
@ -84,7 +94,7 @@ export default class Logger {
|
||||||
} else if (data != null) {
|
} else if (data != null) {
|
||||||
args.push(data);
|
args.push(data);
|
||||||
}
|
}
|
||||||
console.log(...args);
|
console[levelFuncs[level]](...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export class FileWriterStream extends WritableStream<Uint8Array> {
|
||||||
write: async (chunk, controller) => {
|
write: async (chunk, controller) => {
|
||||||
if (file === null) {
|
if (file === null) {
|
||||||
controller.error();
|
controller.error();
|
||||||
throw new Error();
|
throw new Error('file is null');
|
||||||
}
|
}
|
||||||
|
|
||||||
await file.write(chunk);
|
await file.write(chunk);
|
||||||
|
|
|
||||||
385
packages/backend/src/misc/QuantumKVCache.ts
Normal file
385
packages/backend/src/misc/QuantumKVCache.ts
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { InternalEventService } from '@/core/InternalEventService.js';
|
||||||
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { InternalEventTypes } from '@/core/GlobalEventService.js';
|
||||||
|
import { MemoryKVCache } from '@/misc/cache.js';
|
||||||
|
|
||||||
|
export interface QuantumKVOpts<T> {
|
||||||
|
/**
|
||||||
|
* Memory cache lifetime in milliseconds.
|
||||||
|
*/
|
||||||
|
lifetime: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback to fetch the value for a key that wasn't found in the cache.
|
||||||
|
* May be synchronous or async.
|
||||||
|
*/
|
||||||
|
fetcher: (key: string, cache: QuantumKVCache<T>) => T | Promise<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional callback to fetch the value for multiple keys that weren't found in the cache.
|
||||||
|
* May be synchronous or async.
|
||||||
|
* If not provided, then the implementation will fall back on repeated calls to fetcher().
|
||||||
|
*/
|
||||||
|
bulkFetcher?: (keys: string[], cache: QuantumKVCache<T>) => Iterable<[key: string, value: T]> | Promise<Iterable<[key: string, value: T]>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional callback when one or more values are changed (created, updated, or deleted) in the cache, either locally or elsewhere in the cluster.
|
||||||
|
* This is called *after* the cache state is updated.
|
||||||
|
* Implementations may be synchronous or async.
|
||||||
|
*/
|
||||||
|
onChanged?: (keys: string[], cache: QuantumKVCache<T>) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QuantumKVCache is a lifetime-bounded memory cache (like MemoryKVCache) with automatic cross-cluster synchronization via Redis.
|
||||||
|
* All nodes in the cluster are guaranteed to have a *subset* view of the current accurate state, though individual processes may have different items in their local cache.
|
||||||
|
* This ensures that a call to get() will never return stale data.
|
||||||
|
*/
|
||||||
|
export class QuantumKVCache<T> implements Iterable<[key: string, value: T]> {
|
||||||
|
private readonly memoryCache: MemoryKVCache<T>;
|
||||||
|
|
||||||
|
public readonly fetcher: QuantumKVOpts<T>['fetcher'];
|
||||||
|
public readonly bulkFetcher: QuantumKVOpts<T>['bulkFetcher'];
|
||||||
|
public readonly onChanged: QuantumKVOpts<T>['onChanged'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param internalEventService Service bus to synchronize events.
|
||||||
|
* @param name Unique name of the cache - must be the same in all processes.
|
||||||
|
* @param opts Cache options
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private readonly internalEventService: InternalEventService,
|
||||||
|
private readonly name: string,
|
||||||
|
opts: QuantumKVOpts<T>,
|
||||||
|
) {
|
||||||
|
this.memoryCache = new MemoryKVCache(opts.lifetime);
|
||||||
|
this.fetcher = opts.fetcher;
|
||||||
|
this.bulkFetcher = opts.bulkFetcher;
|
||||||
|
this.onChanged = opts.onChanged;
|
||||||
|
|
||||||
|
this.internalEventService.on('quantumCacheUpdated', this.onQuantumCacheUpdated, {
|
||||||
|
// Ignore our own events, otherwise we'll immediately erase any set value.
|
||||||
|
ignoreLocal: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of items currently in memory.
|
||||||
|
* This applies to the local subset view, not the cross-cluster cache state.
|
||||||
|
*/
|
||||||
|
public get size() {
|
||||||
|
return this.memoryCache.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterates all [key, value] pairs in memory.
|
||||||
|
* This applies to the local subset view, not the cross-cluster cache state.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public *entries(): Generator<[key: string, value: T]> {
|
||||||
|
for (const entry of this.memoryCache.entries) {
|
||||||
|
yield [entry[0], entry[1].value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterates all keys in memory.
|
||||||
|
* This applies to the local subset view, not the cross-cluster cache state.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public *keys() {
|
||||||
|
for (const entry of this.memoryCache.entries) {
|
||||||
|
yield entry[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterates all values pairs in memory.
|
||||||
|
* This applies to the local subset view, not the cross-cluster cache state.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public *values() {
|
||||||
|
for (const entry of this.memoryCache.entries) {
|
||||||
|
yield entry[1].value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates or updates a value in the cache, and erases any stale caches across the cluster.
|
||||||
|
* Fires an onSet event after the cache has been updated in all processes.
|
||||||
|
* Skips if the value is unchanged.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async set(key: string, value: T): Promise<void> {
|
||||||
|
if (this.memoryCache.get(key) === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.memoryCache.set(key, value);
|
||||||
|
|
||||||
|
await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, keys: [key] });
|
||||||
|
|
||||||
|
if (this.onChanged) {
|
||||||
|
await this.onChanged([key], this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates or updates multiple value in the cache, and erases any stale caches across the cluster.
|
||||||
|
* Fires an onSet for each changed item event after the cache has been updated in all processes.
|
||||||
|
* Skips if all values are unchanged.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async setMany(items: Iterable<[key: string, value: T]>): Promise<void> {
|
||||||
|
const changedKeys: string[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (this.memoryCache.get(item[0]) !== item[1]) {
|
||||||
|
changedKeys.push(item[0]);
|
||||||
|
this.memoryCache.set(item[0], item[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedKeys.length > 0) {
|
||||||
|
await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, keys: changedKeys });
|
||||||
|
|
||||||
|
if (this.onChanged) {
|
||||||
|
await this.onChanged(changedKeys, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a value to the local memory cache without notifying other process.
|
||||||
|
* Neither a Redis event nor onSet callback will be fired, as the value has not actually changed.
|
||||||
|
* This should only be used when the value is known to be current, like after fetching from the database.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public add(key: string, value: T): void {
|
||||||
|
this.memoryCache.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds multiple values to the local memory cache without notifying other process.
|
||||||
|
* Neither a Redis event nor onSet callback will be fired, as the value has not actually changed.
|
||||||
|
* This should only be used when the value is known to be current, like after fetching from the database.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public addMany(items: Iterable<[key: string, value: T]>): void {
|
||||||
|
for (const [key, value] of items) {
|
||||||
|
this.memoryCache.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a value from the local memory cache, or returns undefined if not found.
|
||||||
|
* Returns cached data only - does not make any fetches.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public get(key: string): T | undefined {
|
||||||
|
return this.memoryCache.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets multiple values from the local memory cache; returning undefined for any missing keys.
|
||||||
|
* Returns cached data only - does not make any fetches.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public getMany(keys: Iterable<string>): [key: string, value: T | undefined][] {
|
||||||
|
const results: [key: string, value: T | undefined][] = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
results.push([key, this.get(key)]);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets or fetches a value from the cache.
|
||||||
|
* Fires an onSet event, but does not emit an update event to other processes.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async fetch(key: string): Promise<T> {
|
||||||
|
let value = this.memoryCache.get(key);
|
||||||
|
if (value === undefined) {
|
||||||
|
value = await this.fetcher(key, this);
|
||||||
|
this.memoryCache.set(key, value);
|
||||||
|
|
||||||
|
if (this.onChanged) {
|
||||||
|
await this.onChanged([key], this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets or fetches multiple values from the cache.
|
||||||
|
* Fires onSet events, but does not emit any update events to other processes.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async fetchMany(keys: Iterable<string>): Promise<[key: string, value: T][]> {
|
||||||
|
const results: [key: string, value: T][] = [];
|
||||||
|
const toFetch: string[] = [];
|
||||||
|
|
||||||
|
// Spliterate into cached results / uncached keys.
|
||||||
|
for (const key of keys) {
|
||||||
|
const fromCache = this.get(key);
|
||||||
|
if (fromCache) {
|
||||||
|
results.push([key, fromCache]);
|
||||||
|
} else {
|
||||||
|
toFetch.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch any uncached keys
|
||||||
|
if (toFetch.length > 0) {
|
||||||
|
const fetched = await this.bulkFetch(toFetch);
|
||||||
|
|
||||||
|
// Add to cache and return set
|
||||||
|
this.addMany(fetched);
|
||||||
|
results.push(...fetched);
|
||||||
|
|
||||||
|
// Emit event
|
||||||
|
if (this.onChanged) {
|
||||||
|
await this.onChanged(toFetch, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true is a key exists in memory.
|
||||||
|
* This applies to the local subset view, not the cross-cluster cache state.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public has(key: string): boolean {
|
||||||
|
return this.memoryCache.get(key) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a value from the cache, and erases any stale caches across the cluster.
|
||||||
|
* Fires an onDelete event after the cache has been updated in all processes.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async delete(key: string): Promise<void> {
|
||||||
|
this.memoryCache.delete(key);
|
||||||
|
|
||||||
|
await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, keys: [key] });
|
||||||
|
|
||||||
|
if (this.onChanged) {
|
||||||
|
await this.onChanged([key], this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Deletes multiple values from the cache, and erases any stale caches across the cluster.
|
||||||
|
* Fires an onDelete event for each key after the cache has been updated in all processes.
|
||||||
|
* Skips if the input is empty.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async deleteMany(keys: Iterable<string>): Promise<void> {
|
||||||
|
const deleted: string[] = [];
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
this.memoryCache.delete(key);
|
||||||
|
deleted.push(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleted.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, keys: deleted });
|
||||||
|
|
||||||
|
if (this.onChanged) {
|
||||||
|
await this.onChanged(deleted, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes the value of a key from the fetcher, and erases any stale caches across the cluster.
|
||||||
|
* Fires an onSet event after the cache has been updated in all processes.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public async refresh(key: string): Promise<T> {
|
||||||
|
const value = await this.fetcher(key, this);
|
||||||
|
await this.set(key, value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async refreshMany(keys: Iterable<string>): Promise<[key: string, value: T][]> {
|
||||||
|
const values = await this.bulkFetch(keys);
|
||||||
|
await this.setMany(values);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erases all entries from the local memory cache.
|
||||||
|
* Does not send any events or update other processes.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public clear() {
|
||||||
|
this.memoryCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes expired cache entries from the local view.
|
||||||
|
* Does not send any events or update other processes.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public gc() {
|
||||||
|
this.memoryCache.gc();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erases all data and disconnects from the cluster.
|
||||||
|
* This *must* be called when shutting down to prevent memory leaks!
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public dispose() {
|
||||||
|
this.internalEventService.off('quantumCacheUpdated', this.onQuantumCacheUpdated);
|
||||||
|
|
||||||
|
this.memoryCache.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async bulkFetch(keys: Iterable<string>): Promise<[key: string, value: T][]> {
|
||||||
|
if (this.bulkFetcher) {
|
||||||
|
const results = await this.bulkFetcher(Array.from(keys), this);
|
||||||
|
return Array.from(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: [key: string, value: T][] = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = await this.fetcher(key, this);
|
||||||
|
results.push([key, value]);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async onQuantumCacheUpdated(data: InternalEventTypes['quantumCacheUpdated']): Promise<void> {
|
||||||
|
if (data.name === this.name) {
|
||||||
|
for (const key of data.keys) {
|
||||||
|
this.memoryCache.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.onChanged) {
|
||||||
|
await this.onChanged(data.keys, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterates all [key, value] pairs in memory.
|
||||||
|
* This applies to the local subset view, not the cross-cluster cache state.
|
||||||
|
*/
|
||||||
|
[Symbol.iterator](): Iterator<[key: string, value: T]> {
|
||||||
|
return this.entries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,9 +9,9 @@ import { bindThis } from '@/decorators.js';
|
||||||
export class RedisKVCache<T> {
|
export class RedisKVCache<T> {
|
||||||
private readonly lifetime: number;
|
private readonly lifetime: number;
|
||||||
private readonly memoryCache: MemoryKVCache<T>;
|
private readonly memoryCache: MemoryKVCache<T>;
|
||||||
private readonly fetcher: (key: string) => Promise<T>;
|
public readonly fetcher: (key: string) => Promise<T>;
|
||||||
private readonly toRedisConverter: (value: T) => string;
|
public readonly toRedisConverter: (value: T) => string;
|
||||||
private readonly fromRedisConverter: (value: string) => T | undefined;
|
public readonly fromRedisConverter: (value: string) => T | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private redisClient: Redis.Redis,
|
private redisClient: Redis.Redis,
|
||||||
|
|
@ -99,6 +99,11 @@ export class RedisKVCache<T> {
|
||||||
// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする
|
// TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public clear() {
|
||||||
|
this.memoryCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public gc() {
|
public gc() {
|
||||||
this.memoryCache.gc();
|
this.memoryCache.gc();
|
||||||
|
|
@ -113,9 +118,9 @@ export class RedisKVCache<T> {
|
||||||
export class RedisSingleCache<T> {
|
export class RedisSingleCache<T> {
|
||||||
private readonly lifetime: number;
|
private readonly lifetime: number;
|
||||||
private readonly memoryCache: MemorySingleCache<T>;
|
private readonly memoryCache: MemorySingleCache<T>;
|
||||||
private readonly fetcher: () => Promise<T>;
|
public readonly fetcher: () => Promise<T>;
|
||||||
private readonly toRedisConverter: (value: T) => string;
|
public readonly toRedisConverter: (value: T) => string;
|
||||||
private readonly fromRedisConverter: (value: string) => T | undefined;
|
public readonly fromRedisConverter: (value: string) => T | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private redisClient: Redis.Redis,
|
private redisClient: Redis.Redis,
|
||||||
|
|
@ -123,16 +128,17 @@ export class RedisSingleCache<T> {
|
||||||
opts: {
|
opts: {
|
||||||
lifetime: number;
|
lifetime: number;
|
||||||
memoryCacheLifetime: number;
|
memoryCacheLifetime: number;
|
||||||
fetcher: RedisSingleCache<T>['fetcher'];
|
fetcher?: RedisSingleCache<T>['fetcher'];
|
||||||
toRedisConverter: RedisSingleCache<T>['toRedisConverter'];
|
toRedisConverter?: RedisSingleCache<T>['toRedisConverter'];
|
||||||
fromRedisConverter: RedisSingleCache<T>['fromRedisConverter'];
|
fromRedisConverter?: RedisSingleCache<T>['fromRedisConverter'];
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
this.lifetime = opts.lifetime;
|
this.lifetime = opts.lifetime;
|
||||||
this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime);
|
this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime);
|
||||||
this.fetcher = opts.fetcher;
|
|
||||||
this.toRedisConverter = opts.toRedisConverter;
|
this.fetcher = opts.fetcher ?? (() => { throw new Error('fetch not supported - use get/set directly'); });
|
||||||
this.fromRedisConverter = opts.fromRedisConverter;
|
this.toRedisConverter = opts.toRedisConverter ?? ((value) => JSON.stringify(value));
|
||||||
|
this.fromRedisConverter = opts.fromRedisConverter ?? ((value) => JSON.parse(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
|
|
@ -237,6 +243,16 @@ export class MemoryKVCache<T> {
|
||||||
return cached.value;
|
return cached.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public has(key: string): boolean {
|
||||||
|
const cached = this.cache.get(key);
|
||||||
|
if (cached == null) return false;
|
||||||
|
if ((Date.now() - cached.date) > this.lifetime) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public delete(key: string): void {
|
public delete(key: string): void {
|
||||||
this.cache.delete(key);
|
this.cache.delete(key);
|
||||||
|
|
@ -308,11 +324,24 @@ export class MemoryKVCache<T> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all entries from the cache, but does not dispose it.
|
||||||
|
*/
|
||||||
|
@bindThis
|
||||||
|
public clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
|
this.clear();
|
||||||
clearInterval(this.gcIntervalHandle);
|
clearInterval(this.gcIntervalHandle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get size() {
|
||||||
|
return this.cache.size;
|
||||||
|
}
|
||||||
|
|
||||||
public get entries() {
|
public get entries() {
|
||||||
return this.cache.entries();
|
return this.cache.entries();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue