Merge branch 'develop' into mahjong

This commit is contained in:
syuilo
2024-07-02 10:10:01 +09:00
840 changed files with 44657 additions and 21435 deletions

View File

@@ -19,5 +19,6 @@
},
"target": "es2022"
},
"minify": false
"minify": false,
"sourceMaps": "inline"
}

View File

@@ -19,6 +19,6 @@
</head>
<body>
<redoc spec-url="/api.json" expand-responses="200" expand-single-schema-field="true"></redoc>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
<script src="https://cdn.redoc.ly/redoc/v2.1.3/bundles/redoc.standalone.js" integrity="sha256-u4DgqzYXoArvNF/Ymw3puKexfOC6lYfw0sfmeliBJ1I=" crossorigin="anonymous"></script>
</body>
</html>

View File

@@ -1,8 +0,0 @@
import { loadConfig } from './built/config.js'
import { genOpenapiSpec } from './built/server/api/openapi/gen-spec.js'
import { writeFileSync } from "node:fs";
const config = loadConfig();
const spec = genOpenapiSpec(config, true);
writeFileSync('./built/api.json', JSON.stringify(spec), 'utf-8');

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class UserBlacklistAnntena1689325027964 {
name = 'UserBlacklistAnntena1689325027964'

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class FixRenoteMuting1690417561185 {
name = 'FixRenoteMuting1690417561185'

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class ChangeCacheRemoteFilesDefault1690417561186 {
name = 'ChangeCacheRemoteFilesDefault1690417561186'

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class Fix1690417561187 {
name = 'Fix1690417561187'

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class User2faBackupCodes1690569881926 {
name = 'User2faBackupCodes1690569881926'

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class RefineAnnouncement1691649257651 {
name = 'RefineAnnouncement1691649257651'

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class RefineAnnouncement21691657412740 {
name = 'RefineAnnouncement21691657412740'

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class VerifiedLinks1695260774117 {
name = 'VerifiedLinks1695260774117'

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class FollowingNotify1695288787870 {
name = 'FollowingNotify1695288787870'

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class ShortName1695440131671 {
name = 'ShortName1695440131671'

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class MutingNotificationTypes1695605508898 {
name = 'MutingNotificationTypes1695605508898'

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class NoteUpdatedAt1695901659683 {
name = 'NoteUpdatedAt1695901659683'

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class UserListMembership1696323464251 {
name = 'UserListMembership1696323464251'

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class Hibernation1696331570827 {
name = 'Hibernation1696331570827'

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class Clean1696332072038 {
name = 'Clean1696332072038'

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class HardMute1700383825690 {
name = 'HardMute1700383825690'

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class MakeRepositoryUrlNullable1707808106310 {
name = 'MakeRepositoryUrlNullable1707808106310'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "repositoryUrl" DROP NOT NULL`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "repositoryUrl" SET NOT NULL`);
}
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class RepositoryUrlFromSyuiloToMisskeyDev1708266695091 {
name = 'RepositoryUrlFromSyuiloToMisskeyDev1708266695091'
async up(queryRunner) {
await queryRunner.query(`UPDATE "meta" SET "repositoryUrl" = 'https://github.com/misskey-dev/misskey' WHERE "repositoryUrl" = 'https://github.com/syuilo/misskey'`);
}
async down(queryRunner) {
// no valid down migration
}
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class PerInstanceModNote1708399372194 {
name = 'PerInstanceModNote1708399372194'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "instance" ADD "moderationNote" character varying(16384) NOT NULL DEFAULT ''`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "moderationNote"`);
}
}

View File

@@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class UrlPreviewMeta1710512074000 {
name = 'UrlPreviewMeta1710512074000'
async up(queryRunner) {
await queryRunner.query(`
alter table meta
rename column "summalyProxy" to "urlPreviewSummaryProxyUrl";
alter table meta
add "urlPreviewEnabled" boolean default true not null;
alter table meta
add "urlPreviewTimeout" integer default 10000 not null;
alter table meta
add "urlPreviewMaximumContentLength" bigint default 10485760 not null;
alter table meta
add "urlPreviewRequireContentLength" boolean default false not null;
alter table meta
add "urlPreviewUserAgent" varchar(1024) default null;
`);
}
async down(queryRunner) {
await queryRunner.query(`
alter table meta
rename column "urlPreviewSummaryProxyUrl" to "summalyProxy";
alter table meta
drop column "urlPreviewEnabled";
alter table meta
drop column "urlPreviewTimeout";
alter table meta
drop column "urlPreviewMaximumContentLength";
alter table meta
drop column "urlPreviewRequireContentLength";
alter table meta
drop column "urlPreviewUserAgent";
`);
}
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AntennaExcludeBots1710919614510 {
name = 'AntennaExcludeBots1710919614510'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "antenna" ADD "excludeBots" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "excludeBots"`);
}
}

View File

@@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AbuseReportNotification1713656541000 {
name = 'AbuseReportNotification1713656541000'
async up(queryRunner) {
await queryRunner.query(`
CREATE TABLE "system_webhook" (
"id" varchar(32) NOT NULL,
"isActive" boolean NOT NULL DEFAULT true,
"updatedAt" timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
"latestSentAt" timestamp with time zone NULL DEFAULT NULL,
"latestStatus" integer NULL DEFAULT NULL,
"name" varchar(255) NOT NULL,
"on" varchar(128) [] NOT NULL DEFAULT '{}'::character varying[],
"url" varchar(1024) NOT NULL,
"secret" varchar(1024) NOT NULL,
CONSTRAINT "PK_system_webhook_id" PRIMARY KEY ("id")
);
CREATE INDEX "IDX_system_webhook_isActive" ON "system_webhook" ("isActive");
CREATE INDEX "IDX_system_webhook_on" ON "system_webhook" USING gin ("on");
CREATE TABLE "abuse_report_notification_recipient" (
"id" varchar(32) NOT NULL,
"isActive" boolean NOT NULL DEFAULT true,
"updatedAt" timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
"name" varchar(255) NOT NULL,
"method" varchar(64) NOT NULL,
"userId" varchar(32) NULL DEFAULT NULL,
"systemWebhookId" varchar(32) NULL DEFAULT NULL,
CONSTRAINT "PK_abuse_report_notification_recipient_id" PRIMARY KEY ("id"),
CONSTRAINT "FK_abuse_report_notification_recipient_userId1" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_abuse_report_notification_recipient_userId2" FOREIGN KEY ("userId") REFERENCES "user_profile"("userId") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_abuse_report_notification_recipient_systemWebhookId" FOREIGN KEY ("systemWebhookId") REFERENCES "system_webhook"("id") ON DELETE CASCADE ON UPDATE NO ACTION
);
CREATE INDEX "IDX_abuse_report_notification_recipient_isActive" ON "abuse_report_notification_recipient" ("isActive");
CREATE INDEX "IDX_abuse_report_notification_recipient_method" ON "abuse_report_notification_recipient" ("method");
CREATE INDEX "IDX_abuse_report_notification_recipient_userId" ON "abuse_report_notification_recipient" ("userId");
CREATE INDEX "IDX_abuse_report_notification_recipient_systemWebhookId" ON "abuse_report_notification_recipient" ("systemWebhookId");
`);
}
async down(queryRunner) {
await queryRunner.query(`
ALTER TABLE "abuse_report_notification_recipient" DROP CONSTRAINT "FK_abuse_report_notification_recipient_userId1";
ALTER TABLE "abuse_report_notification_recipient" DROP CONSTRAINT "FK_abuse_report_notification_recipient_userId2";
ALTER TABLE "abuse_report_notification_recipient" DROP CONSTRAINT "FK_abuse_report_notification_recipient_systemWebhookId";
DROP INDEX "IDX_abuse_report_notification_recipient_isActive";
DROP INDEX "IDX_abuse_report_notification_recipient_method";
DROP INDEX "IDX_abuse_report_notification_recipient_userId";
DROP INDEX "IDX_abuse_report_notification_recipient_systemWebhookId";
DROP TABLE "abuse_report_notification_recipient";
DROP INDEX "IDX_system_webhook_isActive";
DROP INDEX "IDX_system_webhook_on";
DROP TABLE "system_webhook";
`);
}
}

View File

@@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class ChannelIdDenormalizedForMiPoll1716129964060 {
name = 'ChannelIdDenormalizedForMiPoll1716129964060'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "poll" ADD "channelId" character varying(32)`);
await queryRunner.query(`COMMENT ON COLUMN "poll"."channelId" IS '[Denormalized]'`);
await queryRunner.query(`CREATE INDEX "IDX_c1240fcc9675946ea5d6c2860e" ON "poll" ("channelId") `);
await queryRunner.query(`UPDATE "poll" SET "channelId" = "note"."channelId" FROM "note" WHERE "poll"."noteId" = "note"."id"`);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_c1240fcc9675946ea5d6c2860e"`);
await queryRunner.query(`COMMENT ON COLUMN "poll"."channelId" IS '[Denormalized]'`);
await queryRunner.query(`ALTER TABLE "poll" DROP COLUMN "channelId"`);
}
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class NotRespondingSince1716345015347 {
name = 'NotRespondingSince1716345015347'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "instance" ADD "notRespondingSince" TIMESTAMP WITH TIME ZONE`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "notRespondingSince"`);
}
}

View File

@@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class SuspensionStateInsteadOfIsSspended1716345771510 {
name = 'SuspensionStateInsteadOfIsSspended1716345771510'
async up(queryRunner) {
await queryRunner.query(`CREATE TYPE "public"."instance_suspensionstate_enum" AS ENUM('none', 'manuallySuspended', 'goneSuspended', 'autoSuspendedForNotResponding')`);
await queryRunner.query(`DROP INDEX "public"."IDX_34500da2e38ac393f7bb6b299c"`);
await queryRunner.query(`ALTER TABLE "instance" RENAME COLUMN "isSuspended" TO "suspensionState"`);
await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "suspensionState" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "suspensionState" TYPE "public"."instance_suspensionstate_enum" USING (
CASE "suspensionState"
WHEN TRUE THEN 'manuallySuspended'::instance_suspensionstate_enum
ELSE 'none'::instance_suspensionstate_enum
END
)`);
await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "suspensionState" SET DEFAULT 'none'`);
await queryRunner.query(`CREATE INDEX "IDX_3ede46f507c87ad698051d56a8" ON "instance" ("suspensionState") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_3ede46f507c87ad698051d56a8"`);
await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "suspensionState" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "suspensionState" TYPE boolean USING (
CASE "suspensionState"
WHEN 'none'::instance_suspensionstate_enum THEN FALSE
ELSE TRUE
END
)`);
await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "suspensionState" SET DEFAULT false`);
await queryRunner.query(`ALTER TABLE "instance" RENAME COLUMN "suspensionState" TO "isSuspended"`);
await queryRunner.query(`CREATE INDEX "IDX_34500da2e38ac393f7bb6b299c" ON "instance" ("isSuspended") `);
await queryRunner.query(`DROP TYPE "public"."instance_suspensionstate_enum"`);
}
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class RemoveAntennaNotify1716450883149 {
name = 'RemoveAntennaNotify1716450883149'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "notify"`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "antenna" ADD "notify" boolean NOT NULL`);
}
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class InquiryUrl1717117195275 {
name = 'InquiryUrl1717117195275'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "inquiryUrl" character varying(1024)`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "inquiryUrl"`);
}
}

View File

@@ -4,22 +4,22 @@
"private": true,
"type": "module",
"engines": {
"node": ">=20.10.0"
"node": "^20.10.0 || ^22.0.0"
},
"scripts": {
"start": "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",
"revert": "pnpm typeorm migration:revert -d ormconfig.js",
"check:connect": "node ./check_connect.js",
"build": "swc src -d built -D",
"build:test": "swc test-server -d built-test -D --config-file test-server/.swcrc",
"watch:swc": "swc src -d built -D -w",
"check:connect": "node ./scripts/check_connect.js",
"build": "swc src -d built -D --strip-leading-paths",
"build:test": "swc test-server -d built-test -D --config-file test-server/.swcrc --strip-leading-paths",
"watch:swc": "swc src -d built -D -w --strip-leading-paths",
"build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
"watch": "node watch.mjs",
"watch": "node ./scripts/watch.mjs",
"restart": "pnpm build && pnpm start",
"dev": "nodemon -w src -e ts,js,mjs,cjs,json --exec \"cross-env NODE_ENV=development pnpm run restart\"",
"typecheck": "tsc --noEmit",
"dev": "node ./scripts/dev.mjs",
"typecheck": "tsc --noEmit && tsc -p test --noEmit",
"eslint": "eslint --quiet \"src/**/*.ts\"",
"lint": "pnpm typecheck && pnpm eslint",
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs",
@@ -31,7 +31,7 @@
"test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e",
"test-and-coverage": "pnpm jest-and-coverage",
"test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e",
"generate-api-json": "pnpm build && node ./generate_api_json.js"
"generate-api-json": "pnpm build && node ./scripts/generate_api_json.js"
},
"optionalDependencies": {
"@swc/core-android-arm64": "1.3.11",
@@ -67,38 +67,41 @@
"dependencies": {
"@aws-sdk/client-s3": "3.412.0",
"@aws-sdk/lib-storage": "3.412.0",
"@bull-board/api": "5.14.0",
"@bull-board/fastify": "5.14.0",
"@bull-board/ui": "5.14.0",
"@discordapp/twemoji": "15.0.2",
"@bull-board/api": "5.17.0",
"@bull-board/fastify": "5.17.0",
"@bull-board/ui": "5.17.0",
"@discordapp/twemoji": "15.0.3",
"@fastify/accepts": "4.3.0",
"@fastify/cookie": "9.3.1",
"@fastify/cors": "8.5.0",
"@fastify/express": "2.3.0",
"@fastify/http-proxy": "9.3.0",
"@fastify/multipart": "8.1.0",
"@fastify/static": "6.12.0",
"@fastify/view": "8.2.0",
"@misskey-dev/sharp-read-bmp": "^1.1.1",
"@misskey-dev/summaly": "^5.0.3",
"@nestjs/common": "10.2.10",
"@nestjs/core": "10.2.10",
"@nestjs/testing": "10.2.10",
"@fastify/cors": "9.0.1",
"@fastify/express": "3.0.0",
"@fastify/http-proxy": "9.5.0",
"@fastify/multipart": "8.2.0",
"@fastify/static": "7.0.3",
"@fastify/view": "9.1.0",
"@misskey-dev/sharp-read-bmp": "1.2.0",
"@misskey-dev/summaly": "5.1.0",
"@napi-rs/canvas": "^0.1.52",
"@nestjs/common": "10.3.8",
"@nestjs/core": "10.3.8",
"@nestjs/testing": "10.3.8",
"@peertube/http-signature": "1.7.0",
"@simplewebauthn/server": "9.0.2",
"@sentry/node": "^8.5.0",
"@sentry/profiling-node": "^8.5.0",
"@simplewebauthn/server": "10.0.0",
"@sinonjs/fake-timers": "11.2.2",
"@smithy/node-http-handler": "2.1.10",
"@swc/cli": "0.1.63",
"@swc/core": "1.3.107",
"@twemoji/parser": "15.0.0",
"@smithy/node-http-handler": "2.5.0",
"@swc/cli": "0.3.12",
"@swc/core": "1.4.17",
"@twemoji/parser": "15.1.1",
"accepts": "1.3.8",
"ajv": "8.12.0",
"archiver": "6.0.1",
"async-mutex": "0.4.1",
"ajv": "8.13.0",
"archiver": "7.0.1",
"async-mutex": "0.5.0",
"bcryptjs": "2.4.3",
"blurhash": "2.0.5",
"body-parser": "1.20.2",
"bullmq": "5.1.9",
"bullmq": "5.7.8",
"cacheable-lookup": "7.0.0",
"cbor": "9.0.2",
"chalk": "5.3.0",
@@ -109,26 +112,27 @@
"content-disposition": "0.5.4",
"date-fns": "2.30.0",
"deep-email-validator": "0.1.21",
"fastify": "4.25.2",
"fastify": "4.26.2",
"fastify-raw-body": "4.3.0",
"feed": "4.2.2",
"file-type": "19.0.0",
"fluent-ffmpeg": "2.1.2",
"form-data": "4.0.0",
"got": "14.1.0",
"got": "14.2.1",
"happy-dom": "10.0.3",
"hpagent": "1.2.0",
"http-link-header": "1.1.1",
"ioredis": "5.3.2",
"htmlescape": "1.1.1",
"http-link-header": "1.1.3",
"ioredis": "5.4.1",
"ip-cidr": "3.1.0",
"ipaddr.js": "2.1.0",
"ipaddr.js": "2.2.0",
"is-svg": "5.0.0",
"js-yaml": "4.1.0",
"jsdom": "23.2.0",
"jsdom": "24.0.0",
"json5": "2.2.3",
"jsonld": "8.3.2",
"jsrsasign": "11.0.0",
"meilisearch": "0.37.0",
"jsrsasign": "11.1.0",
"meilisearch": "0.38.0",
"mfm-js": "0.24.0",
"microformats-parser": "2.0.2",
"mime-types": "2.1.35",
@@ -136,90 +140,87 @@
"misskey-reversi": "workspace:*",
"misskey-mahjong": "workspace:*",
"ms": "3.0.0-canary.1",
"nanoid": "5.0.4",
"nanoid": "5.0.7",
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
"nodemailer": "6.9.8",
"nodemailer": "6.9.13",
"nsfwjs": "2.4.2",
"oauth": "0.10.0",
"oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2",
"os-utils": "0.0.14",
"otpauth": "9.2.2",
"otpauth": "9.2.3",
"parse5": "7.1.2",
"pg": "8.11.3",
"pg": "8.11.5",
"pkce-challenge": "4.1.0",
"probe-image-size": "7.2.3",
"promise-limit": "2.7.0",
"pug": "3.0.2",
"punycode": "2.3.1",
"pureimage": "0.3.17",
"qrcode": "1.5.3",
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
"re2": "1.20.9",
"re2": "1.21.2",
"redis-lock": "0.1.4",
"reflect-metadata": "0.1.14",
"reflect-metadata": "0.2.2",
"rename": "1.0.4",
"rss-parser": "3.13.0",
"rxjs": "7.8.1",
"sanitize-html": "2.11.0",
"sanitize-html": "2.13.0",
"secure-json-parse": "2.7.0",
"sharp": "0.32.6",
"sharp": "0.33.3",
"slacc": "0.0.10",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"systeminformation": "5.21.24",
"systeminformation": "5.22.7",
"tinycolor2": "1.6.0",
"tmp": "0.2.1",
"tmp": "0.2.3",
"tsc-alias": "1.8.8",
"tsconfig-paths": "4.2.0",
"typeorm": "0.3.20",
"typescript": "5.3.3",
"typescript": "5.5.2",
"ulid": "2.3.0",
"vary": "1.1.2",
"web-push": "3.6.7",
"ws": "8.16.0",
"ws": "8.17.0",
"xev": "3.0.2"
},
"devDependencies": {
"@jest/globals": "29.7.0",
"@misskey-dev/eslint-plugin": "1.0.0",
"@nestjs/platform-express": "10.3.1",
"@simplewebauthn/typescript-types": "8.3.4",
"@swc/jest": "0.2.31",
"@nestjs/platform-express": "10.3.8",
"@simplewebauthn/types": "10.0.0",
"@swc/jest": "0.2.36",
"@types/accepts": "1.3.7",
"@types/archiver": "6.0.2",
"@types/bcryptjs": "2.4.6",
"@types/body-parser": "1.19.5",
"@types/cbor": "6.0.0",
"@types/color-convert": "2.0.3",
"@types/content-disposition": "0.5.8",
"@types/fluent-ffmpeg": "2.1.24",
"@types/htmlescape": "^1.1.3",
"@types/http-link-header": "1.0.5",
"@types/jest": "29.5.11",
"@types/jest": "29.5.12",
"@types/js-yaml": "4.0.9",
"@types/jsdom": "21.1.6",
"@types/jsonld": "1.5.13",
"@types/jsrsasign": "10.5.12",
"@types/jsrsasign": "10.5.14",
"@types/mime-types": "2.1.4",
"@types/ms": "0.7.34",
"@types/node": "20.11.17",
"@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.14",
"@types/node": "20.12.7",
"@types/nodemailer": "6.4.15",
"@types/oauth": "0.9.4",
"@types/oauth2orize": "1.11.3",
"@types/oauth2orize": "1.11.5",
"@types/oauth2orize-pkce": "0.1.2",
"@types/pg": "8.11.0",
"@types/pg": "8.11.5",
"@types/pug": "2.0.10",
"@types/punycode": "2.1.3",
"@types/punycode": "2.1.4",
"@types/qrcode": "1.5.5",
"@types/random-seed": "0.3.5",
"@types/ratelimiter": "3.4.6",
"@types/rename": "1.0.7",
"@types/sanitize-html": "2.9.5",
"@types/semver": "7.5.6",
"@types/sharp": "0.32.0",
"@types/sanitize-html": "2.11.0",
"@types/semver": "7.5.8",
"@types/simple-oauth2": "5.0.7",
"@types/sinonjs__fake-timers": "8.1.5",
"@types/tinycolor2": "1.4.6",
@@ -227,17 +228,17 @@
"@types/vary": "1.1.3",
"@types/web-push": "3.6.3",
"@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "6.18.1",
"@typescript-eslint/parser": "6.18.1",
"@typescript-eslint/eslint-plugin": "7.7.1",
"@typescript-eslint/parser": "7.7.1",
"aws-sdk-client-mock": "3.0.1",
"cross-env": "7.0.3",
"eslint": "8.56.0",
"eslint": "8.57.0",
"eslint-plugin-import": "2.29.1",
"execa": "8.0.1",
"fkill": "^9.0.0",
"jest": "29.7.0",
"jest-mock": "29.7.0",
"nodemon": "3.0.3",
"nodemon": "3.1.0",
"pid-port": "1.0.0",
"simple-oauth2": "5.0.0"
}

View File

@@ -4,7 +4,7 @@
*/
import Redis from 'ioredis';
import { loadConfig } from './built/config.js';
import { loadConfig } from '../built/config.js';
const config = loadConfig();
const redis = new Redis(config.redis);

View File

@@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { execa, execaNode } from 'execa';
/** @type {import('execa').ExecaChildProcess | undefined} */
let backendProcess;
async function execBuildAssets() {
await execa('pnpm', ['run', 'build-assets'], {
cwd: '../../',
stdout: process.stdout,
stderr: process.stderr,
})
}
function execStart() {
// pnpm run start を呼び出したいが、windowsだとプロセスグループ単位でのkillが出来ずゾンビプロセス化するので
// 上記と同等の動きをするコマンドで子・孫プロセスを作らないようにしたい
backendProcess = execaNode('./built/boot/entry.js', [], {
stdout: process.stdout,
stderr: process.stderr,
env: {
'NODE_ENV': 'development',
},
});
}
async function killProc() {
if (backendProcess) {
backendProcess.kill();
await new Promise(resolve => backendProcess.on('exit', resolve));
backendProcess = undefined;
}
}
(async () => {
execaNode(
'./node_modules/nodemon/bin/nodemon.js',
[
'-w', 'src',
'-e', 'ts,js,mjs,cjs,json',
'--exec', 'pnpm', 'run', 'build',
],
{
stdio: [process.stdin, process.stdout, process.stderr, 'ipc'],
})
.on('message', async (message) => {
if (message.type === 'exit') {
// かならずbuild->build-assetsの順番で呼び出したいので、
// 少々トリッキーだがnodemonからのexitイベントを利用してbuild-assets->startを行う。
// pnpm restartをbuildが終わる前にbuild-assetsが動いてしまうので、バラバラに呼び出す必要がある
await killProc();
await execBuildAssets();
execStart();
}
})
})();

View File

@@ -0,0 +1,13 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { loadConfig } from '../built/config.js'
import { genOpenapiSpec } from '../built/server/api/openapi/gen-spec.js'
import { writeFileSync } from "node:fs";
const config = loadConfig();
const spec = genOpenapiSpec(config, true);
writeFileSync('./built/api.json', JSON.stringify(spec), 'utf-8');

View File

@@ -7,7 +7,7 @@ import { LoggerService } from '@nestjs/common';
import Logger from '@/logger.js';
const logger = new Logger('core', 'cyan');
const nestLogger = logger.createSubLogger('nest', 'green', false);
const nestLogger = logger.createSubLogger('nest', 'green');
export class NestLogger implements LoggerService {
/**

View File

@@ -15,6 +15,7 @@ import Logger from '@/logger.js';
import { envOption } from '../env.js';
import { masterMain } from './master.js';
import { workerMain } from './worker.js';
import { readyRef } from './ready.js';
import 'reflect-metadata';
@@ -24,7 +25,7 @@ Error.stackTraceLimit = Infinity;
EventEmitter.defaultMaxListeners = 128;
const logger = new Logger('core', 'cyan');
const clusterLogger = logger.createSubLogger('cluster', 'orange', false);
const clusterLogger = logger.createSubLogger('cluster', 'orange');
const ev = new Xev();
//#region Events
@@ -79,6 +80,8 @@ if (cluster.isWorker || envOption.disableClustering) {
await workerMain();
}
readyRef.value = true;
// ユニットテスト時にMisskeyが子プロセスで起動された時のため
// それ以外のときは process.send は使えないので弾く
if (process.send) {

View File

@@ -10,6 +10,8 @@ import * as os from 'node:os';
import cluster from 'node:cluster';
import chalk from 'chalk';
import chalkTemplate from 'chalk-template';
import * as Sentry from '@sentry/node';
import { nodeProfilingIntegration } from '@sentry/profiling-node';
import Logger from '@/logger.js';
import { loadConfig } from '@/config.js';
import type { Config } from '@/config.js';
@@ -23,7 +25,7 @@ const _dirname = dirname(_filename);
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/meta.json`, 'utf-8'));
const logger = new Logger('core', 'cyan');
const bootLogger = logger.createSubLogger('boot', 'magenta', false);
const bootLogger = logger.createSubLogger('boot', 'magenta');
const themeColor = chalk.hex('#86b300');
@@ -71,6 +73,24 @@ export async function masterMain() {
bootLogger.succ('Misskey initialized');
if (config.sentryForBackend) {
Sentry.init({
integrations: [
...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []),
],
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
profilesSampleRate: 1.0,
maxBreadcrumbs: 0,
...config.sentryForBackend.options,
});
}
if (envOption.disableClustering) {
if (envOption.onlyServer) {
await server();

View File

@@ -3,4 +3,4 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const fallback = Symbol('fallback');
export const readyRef = { value: false };

View File

@@ -4,13 +4,36 @@
*/
import cluster from 'node:cluster';
import * as Sentry from '@sentry/node';
import { nodeProfilingIntegration } from '@sentry/profiling-node';
import { envOption } from '@/env.js';
import { loadConfig } from '@/config.js';
import { jobQueue, server } from './common.js';
/**
* Init worker process
*/
export async function workerMain() {
const config = loadConfig();
if (config.sentryForBackend) {
Sentry.init({
integrations: [
...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []),
],
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
profilesSampleRate: 1.0,
maxBreadcrumbs: 0,
...config.sentryForBackend.options,
});
}
if (envOption.onlyServer) {
await server();
} else if (envOption.onlyQueue) {

View File

@@ -7,6 +7,7 @@ import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import * as yaml from 'js-yaml';
import * as Sentry from '@sentry/node';
import type { RedisOptions } from 'ioredis';
type RedisOptionsSource = Partial<RedisOptions> & {
@@ -56,6 +57,10 @@ type Source = {
index: string;
scope?: 'local' | 'global' | string[];
};
sentryForBackend?: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; };
sentryForFrontend?: { options: Partial<Sentry.NodeOptions> };
publishTarballInsteadOfProvideRepositoryUrl?: boolean;
proxy?: string;
proxySmtp?: string;
@@ -145,6 +150,7 @@ export type Config = {
signToActivityPubGet: boolean | undefined;
version: string;
publishTarballInsteadOfProvideRepositoryUrl: boolean;
host: string;
hostname: string;
scheme: string;
@@ -163,6 +169,8 @@ export type Config = {
redisForPubsub: RedisOptions & RedisOptionsSource;
redisForJobQueue: RedisOptions & RedisOptionsSource;
redisForTimelines: RedisOptions & RedisOptionsSource;
sentryForBackend: { options: Partial<Sentry.NodeOptions>; enableNodeProfiling: boolean; } | undefined;
sentryForFrontend: { options: Partial<Sentry.NodeOptions> } | undefined;
perChannelMaxNoteCacheCount: number;
perUserNotificationsMaxCount: number;
deactivateAntennaThreshold: number;
@@ -209,6 +217,7 @@ export function loadConfig(): Config {
return {
version,
publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl,
url: url.origin,
port: config.port ?? parseInt(process.env.PORT ?? '', 10),
socket: config.socket,
@@ -230,6 +239,8 @@ export function loadConfig(): Config {
redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis,
redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis,
redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis,
sentryForBackend: config.sentryForBackend,
sentryForFrontend: config.sentryForFrontend,
id: config.id,
proxy: config.proxy,
proxySmtp: config.proxySmtp,

View File

@@ -0,0 +1,405 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, type OnApplicationShutdown } from '@nestjs/common';
import { Brackets, In, IsNull, Not } from 'typeorm';
import * as Redis from 'ioredis';
import sanitizeHtml from 'sanitize-html';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
import type {
AbuseReportNotificationRecipientRepository,
MiAbuseReportNotificationRecipient,
MiAbuseUserReport,
MiUser,
} from '@/models/_.js';
import { EmailService } from '@/core/EmailService.js';
import { MetaService } from '@/core/MetaService.js';
import { RoleService } from '@/core/RoleService.js';
import { RecipientMethod } from '@/models/AbuseReportNotificationRecipient.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { IdService } from './IdService.js';
@Injectable()
export class AbuseReportNotificationService implements OnApplicationShutdown {
constructor(
@Inject(DI.abuseReportNotificationRecipientRepository)
private abuseReportNotificationRecipientRepository: AbuseReportNotificationRecipientRepository,
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
private idService: IdService,
private roleService: RoleService,
private systemWebhookService: SystemWebhookService,
private emailService: EmailService,
private metaService: MetaService,
private moderationLogService: ModerationLogService,
private globalEventService: GlobalEventService,
) {
this.redisForSub.on('message', this.onMessage);
}
/**
* 管理者用Redisイベントを用いて{@link abuseReports}の内容を管理者各位に通知する.
* 通知先ユーザは{@link RoleService.getModeratorIds}の取得結果に依る.
*
* @see RoleService.getModeratorIds
* @see GlobalEventService.publishAdminStream
*/
@bindThis
public async notifyAdminStream(abuseReports: MiAbuseUserReport[]) {
if (abuseReports.length <= 0) {
return;
}
const moderatorIds = await this.roleService.getModeratorIds(true, true);
for (const moderatorId of moderatorIds) {
for (const abuseReport of abuseReports) {
this.globalEventService.publishAdminStream(
moderatorId,
'newAbuseUserReport',
{
id: abuseReport.id,
targetUserId: abuseReport.targetUserId,
reporterId: abuseReport.reporterId,
comment: abuseReport.comment,
},
);
}
}
}
/**
* Mailを用いて{@link abuseReports}の内容を管理者各位に通知する.
* メールアドレスの送信先は以下の通り.
* - モデレータ権限所有者ユーザ(設定画面からメールアドレスの設定を行っているユーザに限る)
* - metaテーブルに設定されているメールアドレス
*
* @see EmailService.sendEmail
*/
@bindThis
public async notifyMail(abuseReports: MiAbuseUserReport[]) {
if (abuseReports.length <= 0) {
return;
}
const recipientEMailAddresses = await this.fetchEMailRecipients().then(it => it
.filter(it => it.isActive && it.userProfile?.emailVerified)
.map(it => it.userProfile?.email)
.filter(x => x != null),
);
// 送信先の鮮度を保つため、毎回取得する
const meta = await this.metaService.fetch(true);
recipientEMailAddresses.push(
...(meta.email ? [meta.email] : []),
);
if (recipientEMailAddresses.length <= 0) {
return;
}
for (const mailAddress of recipientEMailAddresses) {
await Promise.all(
abuseReports.map(it => {
// TODO: 送信処理はJobQueue化したい
return this.emailService.sendEmail(
mailAddress,
'New Abuse Report',
sanitizeHtml(it.comment),
sanitizeHtml(it.comment),
);
}),
);
}
}
/**
* SystemWebhookを用いて{@link abuseReports}の内容を管理者各位に通知する.
* ここではJobQueueへのエンキューのみを行うため、即時実行されない.
*
* @see SystemWebhookService.enqueueSystemWebhook
*/
@bindThis
public async notifySystemWebhook(
abuseReports: MiAbuseUserReport[],
type: 'abuseReport' | 'abuseReportResolved',
) {
if (abuseReports.length <= 0) {
return;
}
const recipientWebhookIds = await this.fetchWebhookRecipients()
.then(it => it
.filter(it => it.isActive && it.systemWebhookId && it.method === 'webhook')
.map(it => it.systemWebhookId)
.filter(x => x != null));
for (const webhookId of recipientWebhookIds) {
await Promise.all(
abuseReports.map(it => {
return this.systemWebhookService.enqueueSystemWebhook(
webhookId,
type,
it,
);
}),
);
}
}
/**
* 通報の通知先一覧を取得する.
*
* @param {Object} [params] クエリの取得条件
* @param {Object} [params.method] 取得する通知先の通知方法
* @param {Object} [opts] 動作時の詳細なオプション
* @param {boolean} [opts.removeUnauthorized] 副作用としてモデレータ権限を持たない送信先ユーザをDBから削除するかどうか(default: true)
* @param {boolean} [opts.joinUser] 通知先のユーザ情報をJOINするかどうか(default: false)
* @param {boolean} [opts.joinSystemWebhook] 通知先のSystemWebhook情報をJOINするかどうか(default: false)
* @see removeUnauthorizedRecipientUsers
*/
@bindThis
public async fetchRecipients(
params?: {
ids?: MiAbuseReportNotificationRecipient['id'][],
method?: RecipientMethod[],
},
opts?: {
removeUnauthorized?: boolean,
joinUser?: boolean,
joinSystemWebhook?: boolean,
},
): Promise<MiAbuseReportNotificationRecipient[]> {
const query = this.abuseReportNotificationRecipientRepository.createQueryBuilder('recipient');
if (opts?.joinUser) {
query.innerJoinAndSelect('user', 'user', 'recipient.userId = user.id');
query.innerJoinAndSelect('recipient.userProfile', 'userProfile');
}
if (opts?.joinSystemWebhook) {
query.innerJoinAndSelect('recipient.systemWebhook', 'systemWebhook');
}
if (params?.ids) {
query.andWhere({ id: In(params.ids) });
}
if (params?.method) {
query.andWhere(new Brackets(qb => {
if (params.method?.includes('email')) {
qb.orWhere({ method: 'email', userId: Not(IsNull()) });
}
if (params.method?.includes('webhook')) {
qb.orWhere({ method: 'webhook', userId: IsNull() });
}
}));
}
const recipients = await query.getMany();
if (recipients.length <= 0) {
return [];
}
// アサイン有効期限切れはイベントで拾えないので、このタイミングでチェック及び削除(オプション)
return (opts?.removeUnauthorized ?? true)
? await this.removeUnauthorizedRecipientUsers(recipients)
: recipients;
}
/**
* EMailの通知先一覧を取得する.
* リレーション先の{@link MiUser}および{@link MiUserProfile}も同時に取得する.
*
* @param {Object} [opts]
* @param {boolean} [opts.removeUnauthorized] 副作用としてモデレータ権限を持たない送信先ユーザをDBから削除するかどうか(default: true)
* @see removeUnauthorizedRecipientUsers
*/
@bindThis
public async fetchEMailRecipients(opts?: {
removeUnauthorized?: boolean
}): Promise<MiAbuseReportNotificationRecipient[]> {
return this.fetchRecipients({ method: ['email'] }, { joinUser: true, ...opts });
}
/**
* Webhookの通知先一覧を取得する.
* リレーション先の{@link MiSystemWebhook}も同時に取得する.
*/
@bindThis
public fetchWebhookRecipients(): Promise<MiAbuseReportNotificationRecipient[]> {
return this.fetchRecipients({ method: ['webhook'] }, { joinSystemWebhook: true });
}
/**
* 通知先を作成する.
*/
@bindThis
public async createRecipient(
params: {
isActive: MiAbuseReportNotificationRecipient['isActive'];
name: MiAbuseReportNotificationRecipient['name'];
method: MiAbuseReportNotificationRecipient['method'];
userId: MiAbuseReportNotificationRecipient['userId'];
systemWebhookId: MiAbuseReportNotificationRecipient['systemWebhookId'];
},
updater: MiUser,
): Promise<MiAbuseReportNotificationRecipient> {
const id = this.idService.gen();
await this.abuseReportNotificationRecipientRepository.insert({
...params,
id,
});
const created = await this.abuseReportNotificationRecipientRepository.findOneByOrFail({ id: id });
this.moderationLogService
.log(updater, 'createAbuseReportNotificationRecipient', {
recipientId: id,
recipient: created,
})
.then();
return created;
}
/**
* 通知先を更新する.
*/
@bindThis
public async updateRecipient(
params: {
id: MiAbuseReportNotificationRecipient['id'];
isActive: MiAbuseReportNotificationRecipient['isActive'];
name: MiAbuseReportNotificationRecipient['name'];
method: MiAbuseReportNotificationRecipient['method'];
userId: MiAbuseReportNotificationRecipient['userId'];
systemWebhookId: MiAbuseReportNotificationRecipient['systemWebhookId'];
},
updater: MiUser,
): Promise<MiAbuseReportNotificationRecipient> {
const beforeEntity = await this.abuseReportNotificationRecipientRepository.findOneByOrFail({ id: params.id });
await this.abuseReportNotificationRecipientRepository.update(params.id, {
isActive: params.isActive,
updatedAt: new Date(),
name: params.name,
method: params.method,
userId: params.userId,
systemWebhookId: params.systemWebhookId,
});
const afterEntity = await this.abuseReportNotificationRecipientRepository.findOneByOrFail({ id: params.id });
this.moderationLogService
.log(updater, 'updateAbuseReportNotificationRecipient', {
recipientId: params.id,
before: beforeEntity,
after: afterEntity,
})
.then();
return afterEntity;
}
/**
* 通知先を削除する.
*/
@bindThis
public async deleteRecipient(
id: MiAbuseReportNotificationRecipient['id'],
updater: MiUser,
) {
const entity = await this.abuseReportNotificationRecipientRepository.findBy({ id });
await this.abuseReportNotificationRecipientRepository.delete(id);
this.moderationLogService
.log(updater, 'deleteAbuseReportNotificationRecipient', {
recipientId: id,
recipient: entity,
})
.then();
}
/**
* モデレータ権限を持たない(*1)通知先ユーザを削除する.
*
* *1: 以下の両方を満たすものの事を言う
* - 通知先にユーザIDが設定されている
* - 付与ロールにモデレータ権限がない or アサインの有効期限が切れている
*
* @param recipients 通知先一覧の配列
* @returns {@lisk recipients}からモデレータ権限を持たない通知先を削除した配列
*/
@bindThis
private async removeUnauthorizedRecipientUsers(recipients: MiAbuseReportNotificationRecipient[]): Promise<MiAbuseReportNotificationRecipient[]> {
const userRecipients = recipients.filter(it => it.userId !== null);
const recipientUserIds = new Set(userRecipients.map(it => it.userId).filter(x => x != null));
if (recipientUserIds.size <= 0) {
// ユーザが通知先として設定されていない場合、この関数での処理を行うべきレコードが無い
return recipients;
}
// モデレータ権限の有無で通知先設定を振り分ける
const authorizedUserIds = await this.roleService.getModeratorIds(true, true);
const authorizedUserRecipients = Array.of<MiAbuseReportNotificationRecipient>();
const unauthorizedUserRecipients = Array.of<MiAbuseReportNotificationRecipient>();
for (const recipient of userRecipients) {
// eslint-disable-next-line
if (authorizedUserIds.includes(recipient.userId!)) {
authorizedUserRecipients.push(recipient);
} else {
unauthorizedUserRecipients.push(recipient);
}
}
// モデレータ権限を持たない通知先をDBから削除する
if (unauthorizedUserRecipients.length > 0) {
await this.abuseReportNotificationRecipientRepository.delete(unauthorizedUserRecipients.map(it => it.id));
}
const nonUserRecipients = recipients.filter(it => it.userId === null);
return [...nonUserRecipients, ...authorizedUserRecipients].sort((a, b) => a.id.localeCompare(b.id));
}
@bindThis
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel !== 'internal') {
return;
}
const { type } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'roleUpdated':
case 'roleDeleted':
case 'userRoleUnassigned': {
// 場合によってはキャッシュ更新よりも先にここが呼ばれてしまう可能性があるのでnextTickで遅延実行
process.nextTick(async () => {
const recipients = await this.abuseReportNotificationRecipientRepository.findBy({
userId: Not(IsNull()),
});
await this.removeUnauthorizedRecipientUsers(recipients);
});
break;
}
default: {
break;
}
}
}
@bindThis
public dispose(): void {
this.redisForSub.off('message', this.onMessage);
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
}

View File

@@ -0,0 +1,128 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { AbuseUserReportsRepository, MiAbuseUserReport, MiUser, UsersRepository } from '@/models/_.js';
import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js';
import { QueueService } from '@/core/QueueService.js';
import { InstanceActorService } from '@/core/InstanceActorService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { IdService } from './IdService.js';
@Injectable()
export class AbuseReportService {
constructor(
@Inject(DI.abuseUserReportsRepository)
private abuseUserReportsRepository: AbuseUserReportsRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private idService: IdService,
private abuseReportNotificationService: AbuseReportNotificationService,
private queueService: QueueService,
private instanceActorService: InstanceActorService,
private apRendererService: ApRendererService,
private moderationLogService: ModerationLogService,
) {
}
/**
* ユーザからの通報をDBに記録し、その内容を下記の手段で管理者各位に通知する.
* - 管理者用Redisイベント
* - EMailモデレータ権限所有者ユーザmetaテーブルに設定されているメールアドレス
* - SystemWebhook
*
* @param params 通報内容. もし複数件の通報に対応した時のために、あらかじめ複数件を処理できる前提で考える
* @see AbuseReportNotificationService.notify
*/
@bindThis
public async report(params: {
targetUserId: MiAbuseUserReport['targetUserId'],
targetUserHost: MiAbuseUserReport['targetUserHost'],
reporterId: MiAbuseUserReport['reporterId'],
reporterHost: MiAbuseUserReport['reporterHost'],
comment: string,
}[]) {
const entities = params.map(param => {
return {
id: this.idService.gen(),
targetUserId: param.targetUserId,
targetUserHost: param.targetUserHost,
reporterId: param.reporterId,
reporterHost: param.reporterHost,
comment: param.comment,
};
});
const reports = Array.of<MiAbuseUserReport>();
for (const entity of entities) {
const report = await this.abuseUserReportsRepository.insertOne(entity);
reports.push(report);
}
return Promise.all([
this.abuseReportNotificationService.notifyAdminStream(reports),
this.abuseReportNotificationService.notifySystemWebhook(reports, 'abuseReport'),
this.abuseReportNotificationService.notifyMail(reports),
]);
}
/**
* 通報を解決し、その内容を下記の手段で管理者各位に通知する.
* - SystemWebhook
*
* @param params 通報内容. もし複数件の通報に対応した時のために、あらかじめ複数件を処理できる前提で考える
* @param operator 通報を処理したユーザ
* @see AbuseReportNotificationService.notify
*/
@bindThis
public async resolve(
params: {
reportId: string;
forward: boolean;
}[],
operator: MiUser,
) {
const paramsMap = new Map(params.map(it => [it.reportId, it]));
const reports = await this.abuseUserReportsRepository.findBy({
id: In(params.map(it => it.reportId)),
});
for (const report of reports) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const ps = paramsMap.get(report.id)!;
await this.abuseUserReportsRepository.update(report.id, {
resolved: true,
assigneeId: operator.id,
forwarded: ps.forward && report.targetUserHost !== null,
});
if (ps.forward && report.targetUserHost != null) {
const actor = await this.instanceActorService.getInstanceActor();
const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId });
// eslint-disable-next-line
const flag = this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment);
const contextAssignedFlag = this.apRendererService.addContext(flag);
this.queueService.deliver(actor, contextAssignedFlag, targetUser.inbox, false);
}
this.moderationLogService
.log(operator, 'resolveAbuseReport', {
reportId: report.id,
report: report,
forwarded: ps.forward && report.targetUserHost !== null,
})
.then();
}
return this.abuseUserReportsRepository.findBy({ id: In(reports.map(it => it.id)) })
.then(reports => this.abuseReportNotificationService.notifySystemWebhook(reports, 'abuseReportResolved'));
}
}

View File

@@ -20,7 +20,6 @@ import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { CacheService } from '@/core/CacheService.js';
import { ProxyAccountService } from '@/core/ProxyAccountService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { MetaService } from '@/core/MetaService.js';
@@ -60,7 +59,6 @@ export class AccountMoveService {
private instanceChart: InstanceChart,
private metaService: MetaService,
private relayService: RelayService,
private cacheService: CacheService,
private queueService: QueueService,
) {
}
@@ -84,7 +82,7 @@ export class AccountMoveService {
Object.assign(src, update);
// Update cache
this.cacheService.uriPersonCache.set(srcUri, src);
this.globalEventService.publishInternalEvent('localUserUpdated', src);
const srcPerson = await this.apRendererService.renderPerson(src);
const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src));
@@ -307,7 +305,7 @@ export class AccountMoveService {
let resultUser: MiLocalUser | MiRemoteUser | null = null;
if (this.userEntityService.isRemoteUser(dst)) {
if ((new Date()).getTime() - (dst.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) {
if (Date.now() - (dst.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) {
await this.apPersonService.updatePerson(dst.uri);
}
dst = await this.apPersonService.fetchPerson(dst.uri) ?? dst;
@@ -323,7 +321,7 @@ export class AccountMoveService {
if (!src) continue; // oldAccountを探してもこのサーバーに存在しない場合はフォロー関係もないということなのでスルー
if (this.userEntityService.isRemoteUser(dst)) {
if ((new Date()).getTime() - (src.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) {
if (Date.now() - (src.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) {
await this.apPersonService.updatePerson(srcUri);
}

View File

@@ -4,13 +4,14 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import { Brackets, EntityNotFoundError } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MiUser } from '@/models/User.js';
import type { AnnouncementReadsRepository, AnnouncementsRepository, MiAnnouncement, MiAnnouncementRead, UsersRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { Packed } from '@/misc/json-schema.js';
import { IdService } from '@/core/IdService.js';
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
@@ -29,6 +30,7 @@ export class AnnouncementService {
private idService: IdService,
private globalEventService: GlobalEventService,
private moderationLogService: ModerationLogService,
private announcementEntityService: AnnouncementEntityService,
) {
}
@@ -65,7 +67,7 @@ export class AnnouncementService {
@bindThis
public async create(values: Partial<MiAnnouncement>, moderator?: MiUser): Promise<{ raw: MiAnnouncement; packed: Packed<'Announcement'> }> {
const announcement = await this.announcementsRepository.insert({
const announcement = await this.announcementsRepository.insertOne({
id: this.idService.gen(),
updatedAt: null,
title: values.title,
@@ -77,9 +79,9 @@ export class AnnouncementService {
silence: values.silence,
needConfirmationToRead: values.needConfirmationToRead,
userId: values.userId,
}).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0]));
});
const packed = (await this.packMany([announcement]))[0];
const packed = await this.announcementEntityService.pack(announcement);
if (values.userId) {
this.globalEventService.publishMainStream(values.userId, 'announcementCreated', {
@@ -177,6 +179,24 @@ export class AnnouncementService {
}
}
@bindThis
public async getAnnouncement(announcementId: MiAnnouncement['id'], me: MiUser | null): Promise<Packed<'Announcement'>> {
const announcement = await this.announcementsRepository.findOneByOrFail({ id: announcementId });
if (me) {
if (announcement.userId && announcement.userId !== me.id) {
throw new EntityNotFoundError(this.announcementsRepository.metadata.target, { id: announcementId });
}
const read = await this.announcementReadsRepository.findOneBy({
announcementId: announcement.id,
userId: me.id,
});
return this.announcementEntityService.pack({ ...announcement, isRead: read !== null }, me);
} else {
return this.announcementEntityService.pack(announcement, null);
}
}
@bindThis
public async read(user: MiUser, announcementId: MiAnnouncement['id']): Promise<void> {
try {
@@ -193,29 +213,4 @@ export class AnnouncementService {
this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements');
}
}
@bindThis
public async packMany(
announcements: MiAnnouncement[],
me?: { id: MiUser['id'] } | null | undefined,
options?: {
reads?: MiAnnouncementRead[];
},
): Promise<Packed<'Announcement'>[]> {
const reads = me ? (options?.reads ?? await this.getReads(me.id)) : [];
return announcements.map(announcement => ({
id: announcement.id,
createdAt: this.idService.parse(announcement.id).date.toISOString(),
updatedAt: announcement.updatedAt?.toISOString() ?? null,
text: announcement.text,
title: announcement.title,
imageUrl: announcement.imageUrl,
icon: announcement.icon,
display: announcement.display,
needConfirmationToRead: announcement.needConfirmationToRead,
silence: announcement.silence,
forYou: announcement.userId === me?.id,
isRead: reads.some(read => read.announcementId === announcement.id),
}));
}
}

View File

@@ -92,7 +92,7 @@ export class AntennaService implements OnApplicationShutdown {
}
@bindThis
public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; }): Promise<void> {
public async addNoteToAntennas(note: MiNote, noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<void> {
const antennas = await this.getAntennas();
const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const)));
const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna);
@@ -110,10 +110,12 @@ export class AntennaService implements OnApplicationShutdown {
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
@bindThis
public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; }): Promise<boolean> {
public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<boolean> {
if (note.visibility === 'specified') return false;
if (note.visibility === 'followers') return false;
if (antenna.excludeBots && noteUser.isBot) return false;
if (antenna.localOnly && noteUser.host != null) return false;
if (!antenna.withReplies && note.replyId != null) return false;

View File

@@ -55,10 +55,10 @@ export class AvatarDecorationService implements OnApplicationShutdown {
@bindThis
public async create(options: Partial<MiAvatarDecoration>, moderator?: MiUser): Promise<MiAvatarDecoration> {
const created = await this.avatarDecorationsRepository.insert({
const created = await this.avatarDecorationsRepository.insertOne({
id: this.idService.gen(),
...options,
}).then(x => this.avatarDecorationsRepository.findOneByOrFail(x.identifiers[0]));
});
this.globalEventService.publishInternalEvent('avatarDecorationCreated', created);

View File

@@ -16,10 +16,10 @@ import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
export class CacheService implements OnApplicationShutdown {
public userByIdCache: MemoryKVCache<MiUser, MiUser | string>;
public localUserByNativeTokenCache: MemoryKVCache<MiLocalUser | null, string | null>;
public userByIdCache: MemoryKVCache<MiUser>;
public localUserByNativeTokenCache: MemoryKVCache<MiLocalUser | null>;
public localUserByIdCache: MemoryKVCache<MiLocalUser>;
public uriPersonCache: MemoryKVCache<MiUser | null, string | null>;
public uriPersonCache: MemoryKVCache<MiUser | null>;
public userProfileCache: RedisKVCache<MiUserProfile>;
public userMutingsCache: RedisKVCache<Set<string>>;
public userBlockingCache: RedisKVCache<Set<string>>;
@@ -56,41 +56,10 @@ export class CacheService implements OnApplicationShutdown {
) {
//this.onMessage = this.onMessage.bind(this);
const localUserByIdCache = new MemoryKVCache<MiLocalUser>(1000 * 60 * 60 * 6 /* 6h */);
this.localUserByIdCache = localUserByIdCache;
// ローカルユーザーならlocalUserByIdCacheにデータを追加し、こちらにはid(文字列)だけを追加する
const userByIdCache = new MemoryKVCache<MiUser, MiUser | string>(1000 * 60 * 60 * 6 /* 6h */, {
toMapConverter: user => {
if (user.host === null) {
localUserByIdCache.set(user.id, user as MiLocalUser);
return user.id;
}
return user;
},
fromMapConverter: userOrId => typeof userOrId === 'string' ? localUserByIdCache.get(userOrId) : userOrId,
});
this.userByIdCache = userByIdCache;
this.localUserByNativeTokenCache = new MemoryKVCache<MiLocalUser | null, string | null>(Infinity, {
toMapConverter: user => {
if (user === null) return null;
localUserByIdCache.set(user.id, user);
return user.id;
},
fromMapConverter: id => id === null ? null : localUserByIdCache.get(id),
});
this.uriPersonCache = new MemoryKVCache<MiUser | null, string | null>(Infinity, {
toMapConverter: user => {
if (user === null) return null;
userByIdCache.set(user.id, user);
return user.id;
},
fromMapConverter: id => id === null ? null : userByIdCache.get(id),
});
this.userByIdCache = new MemoryKVCache<MiUser>(Infinity);
this.localUserByNativeTokenCache = new MemoryKVCache<MiLocalUser | null>(Infinity);
this.localUserByIdCache = new MemoryKVCache<MiLocalUser>(Infinity);
this.uriPersonCache = new MemoryKVCache<MiUser | null>(Infinity);
this.userProfileCache = new RedisKVCache<MiUserProfile>(this.redisClient, 'userProfile', {
lifetime: 1000 * 60 * 30, // 30m
@@ -159,17 +128,29 @@ export class CacheService implements OnApplicationShutdown {
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'userChangeSuspendedState':
case 'remoteUserUpdated': {
const user = await this.usersRepository.findOneByOrFail({ id: body.id });
this.userByIdCache.set(user.id, user);
for (const [k, v] of this.uriPersonCache.cache.entries()) {
if (v.value === user.id) {
this.uriPersonCache.set(k, user);
case 'userChangeDeletedState':
case 'remoteUserUpdated':
case 'localUserUpdated': {
const user = await this.usersRepository.findOneBy({ id: body.id });
if (user == null) {
this.userByIdCache.delete(body.id);
this.localUserByIdCache.delete(body.id);
for (const [k, v] of this.uriPersonCache.cache.entries()) {
if (v.value?.id === body.id) {
this.uriPersonCache.delete(k);
}
}
} else {
this.userByIdCache.set(user.id, user);
for (const [k, v] of this.uriPersonCache.cache.entries()) {
if (v.value?.id === user.id) {
this.uriPersonCache.set(k, user);
}
}
if (this.userEntityService.isLocalUser(user)) {
this.localUserByNativeTokenCache.set(user.token!, user);
this.localUserByIdCache.set(user.id, user);
}
}
if (this.userEntityService.isLocalUser(user)) {
this.localUserByNativeTokenCache.set(user.token!, user);
this.localUserByIdCache.set(user.id, user);
}
break;
}

View File

@@ -1,3 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import Redis from 'ioredis';
import { DI } from '@/di-symbols.js';

View File

@@ -41,17 +41,17 @@ export class ClipService {
const currentCount = await this.clipsRepository.countBy({
userId: me.id,
});
if (currentCount > (await this.roleService.getUserPolicies(me.id)).clipLimit) {
if (currentCount >= (await this.roleService.getUserPolicies(me.id)).clipLimit) {
throw new ClipService.TooManyClipsError();
}
const clip = await this.clipsRepository.insert({
const clip = await this.clipsRepository.insertOne({
id: this.idService.gen(),
userId: me.id,
name: name,
isPublic: isPublic,
description: description,
}).then(x => this.clipsRepository.findOneByOrFail(x.identifiers[0]));
});
return clip;
}
@@ -102,7 +102,7 @@ export class ClipService {
const currentCount = await this.clipNotesRepository.countBy({
clipId: clip.id,
});
if (currentCount > (await this.roleService.getUserPolicies(me.id)).noteEachClipsLimit) {
if (currentCount >= (await this.roleService.getUserPolicies(me.id)).noteEachClipsLimit) {
throw new ClipService.TooManyClipNotesError();
}

View File

@@ -5,6 +5,13 @@
import { Module } from '@nestjs/common';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { AbuseReportService } from '@/core/AbuseReportService.js';
import { SystemWebhookEntityService } from '@/core/entities/SystemWebhookEntityService.js';
import {
AbuseReportNotificationRecipientEntityService,
} from '@/core/entities/AbuseReportNotificationRecipientEntityService.js';
import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js';
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js';
@@ -56,7 +63,7 @@ import { UserMutingService } from './UserMutingService.js';
import { UserSuspendService } from './UserSuspendService.js';
import { UserAuthService } from './UserAuthService.js';
import { VideoProcessingService } from './VideoProcessingService.js';
import { WebhookService } from './WebhookService.js';
import { UserWebhookService } from './UserWebhookService.js';
import { ProxyAccountService } from './ProxyAccountService.js';
import { UtilityService } from './UtilityService.js';
import { FileInfoService } from './FileInfoService.js';
@@ -85,6 +92,7 @@ import ApRequestChart from './chart/charts/ap-request.js';
import { ChartManagementService } from './chart/ChartManagementService.js';
import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js';
import { AnnouncementEntityService } from './entities/AnnouncementEntityService.js';
import { AntennaEntityService } from './entities/AntennaEntityService.js';
import { AppEntityService } from './entities/AppEntityService.js';
import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js';
@@ -117,6 +125,7 @@ import { FlashEntityService } from './entities/FlashEntityService.js';
import { FlashLikeEntityService } from './entities/FlashLikeEntityService.js';
import { RoleEntityService } from './entities/RoleEntityService.js';
import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
import { MetaEntityService } from './entities/MetaEntityService.js';
import { ApAudienceService } from './activitypub/ApAudienceService.js';
import { ApDbResolverService } from './activitypub/ApDbResolverService.js';
@@ -127,7 +136,7 @@ import { ApMfmService } from './activitypub/ApMfmService.js';
import { ApRendererService } from './activitypub/ApRendererService.js';
import { ApRequestService } from './activitypub/ApRequestService.js';
import { ApResolverService } from './activitypub/ApResolverService.js';
import { LdSignatureService } from './activitypub/LdSignatureService.js';
import { JsonLdService } from './activitypub/JsonLdService.js';
import { RemoteLoggerService } from './RemoteLoggerService.js';
import { RemoteUserResolveService } from './RemoteUserResolveService.js';
import { WebfingerService } from './WebfingerService.js';
@@ -143,6 +152,8 @@ import type { Provider } from '@nestjs/common';
//#region 文字列ベースでのinjection用(循環参照対応のため)
const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService };
const $AbuseReportService: Provider = { provide: 'AbuseReportService', useExisting: AbuseReportService };
const $AbuseReportNotificationService: Provider = { provide: 'AbuseReportNotificationService', useExisting: AbuseReportNotificationService };
const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService };
const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService };
const $AiService: Provider = { provide: 'AiService', useExisting: AiService };
@@ -195,7 +206,8 @@ const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService };
const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService };
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService };
const $WebhookService: Provider = { provide: 'WebhookService', useExisting: WebhookService };
const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService };
const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService };
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
@@ -224,6 +236,8 @@ const $ApRequestChart: Provider = { provide: 'ApRequestChart', useExisting: ApRe
const $ChartManagementService: Provider = { provide: 'ChartManagementService', useExisting: ChartManagementService };
const $AbuseUserReportEntityService: Provider = { provide: 'AbuseUserReportEntityService', useExisting: AbuseUserReportEntityService };
const $AnnouncementEntityService: Provider = { provide: 'AnnouncementEntityService', useExisting: AnnouncementEntityService };
const $AbuseReportNotificationRecipientEntityService: Provider = { provide: 'AbuseReportNotificationRecipientEntityService', useExisting: AbuseReportNotificationRecipientEntityService };
const $AntennaEntityService: Provider = { provide: 'AntennaEntityService', useExisting: AntennaEntityService };
const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: AppEntityService };
const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService };
@@ -256,6 +270,8 @@ const $FlashEntityService: Provider = { provide: 'FlashEntityService', useExisti
const $FlashLikeEntityService: Provider = { provide: 'FlashLikeEntityService', useExisting: FlashLikeEntityService };
const $RoleEntityService: Provider = { provide: 'RoleEntityService', useExisting: RoleEntityService };
const $ReversiGameEntityService: Provider = { provide: 'ReversiGameEntityService', useExisting: ReversiGameEntityService };
const $MetaEntityService: Provider = { provide: 'MetaEntityService', useExisting: MetaEntityService };
const $SystemWebhookEntityService: Provider = { provide: 'SystemWebhookEntityService', useExisting: SystemWebhookEntityService };
const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService };
const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService };
@@ -266,7 +282,7 @@ const $ApMfmService: Provider = { provide: 'ApMfmService', useExisting: ApMfmSer
const $ApRendererService: Provider = { provide: 'ApRendererService', useExisting: ApRendererService };
const $ApRequestService: Provider = { provide: 'ApRequestService', useExisting: ApRequestService };
const $ApResolverService: Provider = { provide: 'ApResolverService', useExisting: ApResolverService };
const $LdSignatureService: Provider = { provide: 'LdSignatureService', useExisting: LdSignatureService };
const $JsonLdService: Provider = { provide: 'JsonLdService', useExisting: JsonLdService };
const $RemoteLoggerService: Provider = { provide: 'RemoteLoggerService', useExisting: RemoteLoggerService };
const $RemoteUserResolveService: Provider = { provide: 'RemoteUserResolveService', useExisting: RemoteUserResolveService };
const $WebfingerService: Provider = { provide: 'WebfingerService', useExisting: WebfingerService };
@@ -283,6 +299,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
],
providers: [
LoggerService,
AbuseReportService,
AbuseReportNotificationService,
AccountMoveService,
AccountUpdateService,
AiService,
@@ -335,7 +353,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
UserSuspendService,
UserAuthService,
VideoProcessingService,
WebhookService,
UserWebhookService,
SystemWebhookService,
UtilityService,
FileInfoService,
SearchService,
@@ -364,6 +383,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ChartManagementService,
AbuseUserReportEntityService,
AnnouncementEntityService,
AbuseReportNotificationRecipientEntityService,
AntennaEntityService,
AppEntityService,
AuthSessionEntityService,
@@ -396,6 +417,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FlashLikeEntityService,
RoleEntityService,
ReversiGameEntityService,
MetaEntityService,
SystemWebhookEntityService,
ApAudienceService,
ApDbResolverService,
@@ -406,7 +429,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApRendererService,
ApRequestService,
ApResolverService,
LdSignatureService,
JsonLdService,
RemoteLoggerService,
RemoteUserResolveService,
WebfingerService,
@@ -419,6 +442,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
//#region 文字列ベースでのinjection用(循環参照対応のため)
$LoggerService,
$AbuseReportService,
$AbuseReportNotificationService,
$AccountMoveService,
$AccountUpdateService,
$AiService,
@@ -471,7 +496,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$UserSuspendService,
$UserAuthService,
$VideoProcessingService,
$WebhookService,
$UserWebhookService,
$SystemWebhookService,
$UtilityService,
$FileInfoService,
$SearchService,
@@ -500,6 +526,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ChartManagementService,
$AbuseUserReportEntityService,
$AnnouncementEntityService,
$AbuseReportNotificationRecipientEntityService,
$AntennaEntityService,
$AppEntityService,
$AuthSessionEntityService,
@@ -532,6 +560,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FlashLikeEntityService,
$RoleEntityService,
$ReversiGameEntityService,
$MetaEntityService,
$SystemWebhookEntityService,
$ApAudienceService,
$ApDbResolverService,
@@ -542,7 +572,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ApRendererService,
$ApRequestService,
$ApResolverService,
$LdSignatureService,
$JsonLdService,
$RemoteLoggerService,
$RemoteUserResolveService,
$WebfingerService,
@@ -556,6 +586,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
exports: [
QueueModule,
LoggerService,
AbuseReportService,
AbuseReportNotificationService,
AccountMoveService,
AccountUpdateService,
AiService,
@@ -608,7 +640,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
UserSuspendService,
UserAuthService,
VideoProcessingService,
WebhookService,
UserWebhookService,
SystemWebhookService,
UtilityService,
FileInfoService,
SearchService,
@@ -636,6 +669,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ChartManagementService,
AbuseUserReportEntityService,
AnnouncementEntityService,
AbuseReportNotificationRecipientEntityService,
AntennaEntityService,
AppEntityService,
AuthSessionEntityService,
@@ -668,6 +703,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FlashLikeEntityService,
RoleEntityService,
ReversiGameEntityService,
MetaEntityService,
SystemWebhookEntityService,
ApAudienceService,
ApDbResolverService,
@@ -678,7 +715,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApRendererService,
ApRequestService,
ApResolverService,
LdSignatureService,
JsonLdService,
RemoteLoggerService,
RemoteUserResolveService,
WebfingerService,
@@ -691,6 +728,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
//#region 文字列ベースでのinjection用(循環参照対応のため)
$LoggerService,
$AbuseReportService,
$AbuseReportNotificationService,
$AccountMoveService,
$AccountUpdateService,
$AiService,
@@ -743,7 +782,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$UserSuspendService,
$UserAuthService,
$VideoProcessingService,
$WebhookService,
$UserWebhookService,
$SystemWebhookService,
$UtilityService,
$FileInfoService,
$SearchService,
@@ -771,6 +811,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ChartManagementService,
$AbuseUserReportEntityService,
$AnnouncementEntityService,
$AbuseReportNotificationRecipientEntityService,
$AntennaEntityService,
$AppEntityService,
$AuthSessionEntityService,
@@ -803,6 +845,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FlashLikeEntityService,
$RoleEntityService,
$ReversiGameEntityService,
$MetaEntityService,
$SystemWebhookEntityService,
$ApAudienceService,
$ApDbResolverService,
@@ -813,7 +857,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ApRendererService,
$ApRequestService,
$ApResolverService,
$LdSignatureService,
$JsonLdService,
$RemoteLoggerService,
$RemoteUserResolveService,
$WebfingerService,

View File

@@ -20,7 +20,7 @@ import { query } from '@/misc/prelude/url.js';
import type { Serialized } from '@/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/;
const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/;
@Injectable()
export class CustomEmojiService implements OnApplicationShutdown {
@@ -68,7 +68,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
localOnly: boolean;
roleIdsThatCanBeUsedThisEmojiAsReaction: MiRole['id'][];
}, moderator?: MiUser): Promise<MiEmoji> {
const emoji = await this.emojisRepository.insert({
const emoji = await this.emojisRepository.insertOne({
id: this.idService.gen(),
updatedAt: new Date(),
name: data.name,
@@ -82,7 +82,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
isSensitive: data.isSensitive,
localOnly: data.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction,
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
});
if (data.host == null) {
this.localEmojisCache.refresh();
@@ -346,10 +346,11 @@ export class CustomEmojiService implements OnApplicationShutdown {
@bindThis
public async populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<Record<string, string>> {
const emojis = await Promise.all(emojiNames.map(x => this.populateEmoji(x, noteUserHost)));
const res = {} as any;
const res = {} as Record<string, string>;
for (let i = 0; i < emojiNames.length; i++) {
if (emojis[i] != null) {
res[emojiNames[i]] = emojis[i];
const resolvedEmoji = emojis[i];
if (resolvedEmoji != null) {
res[emojiNames[i]] = resolvedEmoji;
}
}
return res;
@@ -393,6 +394,11 @@ export class CustomEmojiService implements OnApplicationShutdown {
return this.emojisRepository.findOneBy({ id });
}
@bindThis
public getEmojiByName(name: string): Promise<MiEmoji | null> {
return this.emojisRepository.findOneBy({ name, host: IsNull() });
}
@bindThis
public dispose(): void {
this.cache.dispose();

View File

@@ -9,6 +9,7 @@ import { QueueService } from '@/core/QueueService.js';
import { UserSuspendService } from '@/core/UserSuspendService.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
@Injectable()
export class DeleteAccountService {
@@ -18,6 +19,7 @@ export class DeleteAccountService {
private userSuspendService: UserSuspendService,
private queueService: QueueService,
private globalEventService: GlobalEventService,
) {
}
@@ -39,5 +41,7 @@ export class DeleteAccountService {
await this.usersRepository.update(user.id, {
isDeleted: true,
});
this.globalEventService.publishInternalEvent('userChangeDeletedState', { id: user.id, isDeleted: true });
}
}

View File

@@ -220,7 +220,7 @@ export class DriveService {
file.size = size;
file.storedInternal = false;
return await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0]));
return await this.driveFilesRepository.insertOne(file);
} else { // use internal storage
const accessKey = randomUUID();
const thumbnailAccessKey = 'thumbnail-' + randomUUID();
@@ -254,7 +254,7 @@ export class DriveService {
file.md5 = hash;
file.size = size;
return await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0]));
return await this.driveFilesRepository.insertOne(file);
}
}
@@ -497,14 +497,20 @@ export class DriveService {
if (user && !force) {
// Check if there is a file with the same hash
const much = await this.driveFilesRepository.findOneBy({
const matched = await this.driveFilesRepository.findOneBy({
md5: info.md5,
userId: user.id,
});
if (much) {
this.registerLogger.info(`file with same hash is found: ${much.id}`);
return much;
if (matched) {
this.registerLogger.info(`file with same hash is found: ${matched.id}`);
if (sensitive && !matched.isSensitive) {
// The file is federated as sensitive for this time, but was federated as non-sensitive before.
// Therefore, update the file to sensitive.
await this.driveFilesRepository.update({ id: matched.id }, { isSensitive: true });
matched.isSensitive = true;
}
return matched;
}
}
@@ -609,7 +615,7 @@ export class DriveService {
file.type = info.type.mime;
file.storedInternal = false;
file = await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0]));
file = await this.driveFilesRepository.insertOne(file);
} catch (err) {
// duplicate key error (when already registered)
if (isDuplicateKeyValueError(err)) {

View File

@@ -16,6 +16,7 @@ import type { UserProfilesRepository } from '@/models/_.js';
import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { QueueService } from '@/core/QueueService.js';
@Injectable()
export class EmailService {
@@ -32,6 +33,7 @@ export class EmailService {
private loggerService: LoggerService,
private utilityService: UtilityService,
private httpRequestService: HttpRequestService,
private queueService: QueueService,
) {
this.logger = this.loggerService.getLogger('email');
}

View File

@@ -13,7 +13,7 @@ import type { NotesRepository } from '@/models/_.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { FanoutTimelineName, FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { isPureRenote } from '@/misc/is-pure-renote.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
import { CacheService } from '@/core/CacheService.js';
import { isReply } from '@/misc/is-reply.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
@@ -55,24 +55,20 @@ export class FanoutTimelineEndpointService {
@bindThis
private async getMiNotes(ps: TimelineOptions): Promise<MiNote[]> {
let noteIds: string[];
let shouldFallbackToDb = false;
// 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える
if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]);
const shouldPrepend = ps.sinceId && !ps.untilId;
const idCompare: (a: string, b: string) => number = shouldPrepend ? (a, b) => a < b ? -1 : 1 : (a, b) => a > b ? -1 : 1;
const ascending = ps.sinceId && !ps.untilId;
const idCompare: (a: string, b: string) => number = ascending ? (a, b) => a < b ? -1 : 1 : (a, b) => a > b ? -1 : 1;
const redisResult = await this.fanoutTimelineService.getMulti(ps.redisTimelines, ps.untilId, ps.sinceId);
// TODO: いい感じにgetMulti内でソート済だからuniqするときにredisResultが全てソート済なのを利用して再ソートを避けたい
const redisResultIds = Array.from(new Set(redisResult.flat(1)));
const redisResultIds = Array.from(new Set(redisResult.flat(1))).sort(idCompare);
redisResultIds.sort(idCompare);
noteIds = redisResultIds.slice(0, ps.limit);
shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0);
let noteIds = redisResultIds.slice(0, ps.limit);
const oldestNoteId = ascending ? redisResultIds[0] : redisResultIds[redisResultIds.length - 1];
const shouldFallbackToDb = noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId;
if (!shouldFallbackToDb) {
let filter = ps.noteFilter ?? (_note => true);
@@ -95,7 +91,7 @@ export class FanoutTimelineEndpointService {
if (ps.excludePureRenotes) {
const parentFilter = filter;
filter = (note) => !isPureRenote(note) && parentFilter(note);
filter = (note) => (!isRenote(note) || isQuote(note)) && parentFilter(note);
}
if (ps.me) {
@@ -116,7 +112,7 @@ export class FanoutTimelineEndpointService {
filter = (note) => {
if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false;
if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false;
if (isPureRenote(note) && isUserRelated(note, userIdsWhoMeMutingRenotes, ps.ignoreAuthorFromMute)) return false;
if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false;
if (isInstanceMuted(note, userMutedInstances)) return false;
return parentFilter(note);
@@ -142,9 +138,7 @@ export class FanoutTimelineEndpointService {
if (ps.allowPartial ? redisTimeline.length !== 0 : redisTimeline.length >= ps.limit) {
// 十分Redisからとれた
const result = redisTimeline.slice(0, ps.limit);
if (shouldPrepend) result.reverse();
return result;
return redisTimeline.slice(0, ps.limit);
}
}
@@ -152,8 +146,7 @@ export class FanoutTimelineEndpointService {
const remainingToRead = ps.limit - redisTimeline.length;
let dbUntil: string | null;
let dbSince: string | null;
if (shouldPrepend) {
redisTimeline.reverse();
if (ascending) {
dbUntil = ps.untilId;
dbSince = noteIds[noteIds.length - 1];
} else {
@@ -161,7 +154,7 @@ export class FanoutTimelineEndpointService {
dbSince = ps.sinceId;
}
const gotFromDb = await ps.dbFallback(dbUntil, dbSince, remainingToRead);
return shouldPrepend ? [...gotFromDb, ...redisTimeline] : [...redisTimeline, ...gotFromDb];
return [...redisTimeline, ...gotFromDb];
}
return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit);

View File

@@ -55,11 +55,11 @@ export class FederatedInstanceService implements OnApplicationShutdown {
const index = await this.instancesRepository.findOneBy({ host });
if (index == null) {
const i = await this.instancesRepository.insert({
const i = await this.instancesRepository.insertOne({
id: this.idService.gen(),
host,
firstRetrievedAt: new Date(),
}).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0]));
});
this.federatedInstanceCache.set(host, i);
return i;

View File

@@ -51,21 +51,35 @@ export class FetchInstanceMetadataService {
}
@bindThis
public async tryLock(host: string): Promise<boolean> {
const mutex = await this.redisClient.set(`fetchInstanceMetadata:mutex:${host}`, '1', 'GET');
return mutex !== '1';
// public for test
public async tryLock(host: string): Promise<string | null> {
// TODO: マイグレーションなのであとで消す (2024.3.1)
this.redisClient.del(`fetchInstanceMetadata:mutex:${host}`);
return await this.redisClient.set(
`fetchInstanceMetadata:mutex:v2:${host}`, '1',
'EX', 30, // 30秒したら自動でロック解除 https://github.com/misskey-dev/misskey/issues/13506#issuecomment-1975375395
'GET' // 古い値を返すなかったらnull
);
}
@bindThis
public unlock(host: string): Promise<'OK'> {
return this.redisClient.set(`fetchInstanceMetadata:mutex:${host}`, '0');
// public for test
public unlock(host: string): Promise<number> {
return this.redisClient.del(`fetchInstanceMetadata:mutex:v2:${host}`);
}
@bindThis
public async fetchInstanceMetadata(instance: MiInstance, force = false): Promise<void> {
const host = instance.host;
// Acquire mutex to ensure no parallel runs
if (!await this.tryLock(host)) return;
// finallyでunlockされてしまうのでtry内でロックチェックをしない
// returnであってもfinallyは実行される
if (!force && await this.tryLock(host) === '1') {
// 1が返ってきていたらロックされているという意味なので、何もしない
return;
}
try {
if (!force) {
const _instance = await this.federatedInstanceService.fetch(host);
@@ -140,7 +154,7 @@ export class FetchInstanceMetadataService {
throw new Error('No wellknown links');
}
const links = wellknown.links as any[];
const links = wellknown.links as ({ rel: string, href: string; })[];
const link1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0');
const link2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0');

View File

@@ -14,10 +14,12 @@ import FFmpeg from 'fluent-ffmpeg';
import isSvg from 'is-svg';
import probeImageSize from 'probe-image-size';
import { type predictionType } from 'nsfwjs';
import sharp from 'sharp';
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
import { encode } from 'blurhash';
import { createTempDir } from '@/misc/create-temp.js';
import { AiService } from '@/core/AiService.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
export type FileInfo = {
@@ -48,9 +50,13 @@ const TYPE_SVG = {
@Injectable()
export class FileInfoService {
private logger: Logger;
constructor(
private aiService: AiService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('file-info');
}
/**
@@ -122,7 +128,7 @@ export class FileInfoService {
'image/avif',
'image/svg+xml',
].includes(type.mime)) {
blurhash = await this.getBlurhash(path).catch(e => {
blurhash = await this.getBlurhash(path, type.mime).catch(e => {
warnings.push(`getBlurhash failed: ${e}`);
return undefined;
});
@@ -316,6 +322,34 @@ export class FileInfoService {
return mime;
}
/**
* ビデオファイルにビデオトラックがあるかどうかチェック
* ない場合m4a, webmなど
*
* @param path ファイルパス
* @returns ビデオトラックがあるかどうか(エラー発生時は常に`true`を返す)
*/
@bindThis
private hasVideoTrackOnVideoFile(path: string): Promise<boolean> {
const sublogger = this.logger.createSubLogger('ffprobe');
sublogger.info(`Checking the video file. File path: ${path}`);
return new Promise((resolve) => {
try {
FFmpeg.ffprobe(path, (err, metadata) => {
if (err) {
sublogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err);
resolve(true);
return;
}
resolve(metadata.streams.some((stream) => stream.codec_type === 'video'));
});
} catch (err) {
sublogger.warn(`Could not check the video file. Returns true. File path: ${path}`, err as Error);
resolve(true);
}
});
}
/**
* Detect MIME Type and extension
*/
@@ -338,6 +372,20 @@ export class FileInfoService {
return TYPE_SVG;
}
if ((type.mime.startsWith('video') || type.mime === 'application/ogg') && !(await this.hasVideoTrackOnVideoFile(path))) {
const newMime = `audio/${type.mime.split('/')[1]}`;
if (newMime === 'audio/mp4') {
return {
mime: 'audio/mp4',
ext: 'm4a',
};
}
return {
mime: newMime,
ext: type.ext,
};
}
return {
mime: this.fixMime(type.mime),
ext: type.ext,
@@ -407,9 +455,9 @@ export class FileInfoService {
* Calculate average color of image
*/
@bindThis
private getBlurhash(path: string): Promise<string> {
return new Promise((resolve, reject) => {
sharp(path)
private getBlurhash(path: string, type: string): Promise<string> {
return new Promise(async (resolve, reject) => {
(await sharpBmp(path, type))
.raw()
.ensureAlpha()
.resize(64, 64, { fit: 'inside' })

View File

@@ -19,6 +19,7 @@ import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
import type { MiSignin } from '@/models/Signin.js';
import type { MiPage } from '@/models/Page.js';
import type { MiWebhook } from '@/models/Webhook.js';
import type { MiSystemWebhook } from '@/models/SystemWebhook.js';
import type { MiMeta } from '@/models/Meta.js';
import { MiAvatarDecoration, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
@@ -70,6 +71,7 @@ export interface MainEventTypes {
file: Packed<'DriveFile'>;
};
readAllNotifications: undefined;
notificationFlushed: undefined;
unreadNotification: Packed<'Notification'>;
unreadMention: MiNote['id'];
readAllUnreadMentions: undefined;
@@ -256,8 +258,10 @@ type SerializedAll<T> = {
export interface InternalEventTypes {
userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; };
userChangeDeletedState: { id: MiUser['id']; isDeleted: MiUser['isDeleted']; };
userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; };
remoteUserUpdated: { id: MiUser['id']; };
localUserUpdated: { id: MiUser['id']; };
follow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
unfollow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
blockingCreated: { blockerId: MiUser['id']; blockeeId: MiUser['id']; };
@@ -271,6 +275,9 @@ export interface InternalEventTypes {
webhookCreated: MiWebhook;
webhookDeleted: MiWebhook;
webhookUpdated: MiWebhook;
systemWebhookCreated: MiSystemWebhook;
systemWebhookDeleted: MiSystemWebhook;
systemWebhookUpdated: MiSystemWebhook;
antennaCreated: MiAntenna;
antennaDeleted: MiAntenna;
antennaUpdated: MiAntenna;

View File

@@ -14,9 +14,16 @@ import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { StatusError } from '@/misc/status-error.js';
import { bindThis } from '@/decorators.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import type { IObject } from '@/core/activitypub/type.js';
import type { Response } from 'node-fetch';
import type { URL } from 'node:url';
export type HttpRequestSendOptions = {
throwErrorWhenResponseNotOk: boolean;
validators?: ((res: Response) => void)[];
};
@Injectable()
export class HttpRequestService {
/**
@@ -104,6 +111,23 @@ export class HttpRequestService {
}
}
@bindThis
public async getActivityJson(url: string): Promise<IObject> {
const res = await this.send(url, {
method: 'GET',
headers: {
Accept: 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
},
timeout: 5000,
size: 1024 * 256,
}, {
throwErrorWhenResponseNotOk: true,
validators: [validateContentTypeSetAsActivityPub],
});
return await res.json() as IObject;
}
@bindThis
public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>): Promise<T> {
const res = await this.send(url, {
@@ -132,17 +156,20 @@ export class HttpRequestService {
}
@bindThis
public async send(url: string, args: {
method?: string,
body?: string,
headers?: Record<string, string>,
timeout?: number,
size?: number,
} = {}, extra: {
throwErrorWhenResponseNotOk: boolean;
} = {
throwErrorWhenResponseNotOk: true,
}): Promise<Response> {
public async send(
url: string,
args: {
method?: string,
body?: string,
headers?: Record<string, string>,
timeout?: number,
size?: number,
} = {},
extra: HttpRequestSendOptions = {
throwErrorWhenResponseNotOk: true,
validators: [],
},
): Promise<Response> {
const timeout = args.timeout ?? 5000;
const controller = new AbortController();
@@ -166,6 +193,12 @@ export class HttpRequestService {
throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText);
}
if (res.ok) {
for (const validator of (extra.validators ?? [])) {
validator(res);
}
}
return res;
}
}

View File

@@ -15,7 +15,7 @@ export class LoggerService {
}
@bindThis
public getLogger(domain: string, color?: KEYWORD | undefined, store?: boolean) {
return new Logger(domain, color, store);
public getLogger(domain: string, color?: KEYWORD | undefined) {
return new Logger(domain, color);
}
}

View File

@@ -6,10 +6,11 @@
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import * as parse5 from 'parse5';
import { Window } from 'happy-dom';
import { Window, XMLSerializer } from 'happy-dom';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { intersperse } from '@/misc/prelude/array.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { bindThis } from '@/decorators.js';
import * as TreeAdapter from '../../node_modules/parse5/dist/tree-adapters/default.js';
@@ -33,6 +34,8 @@ export class MfmService {
// some AP servers like Pixelfed use br tags as well as newlines
html = html.replace(/<br\s?\/?>\r?\n/gi, '\n');
const normalizedHashtagNames = hashtagNames == null ? undefined : new Set<string>(hashtagNames.map(x => normalizeForSearch(x)));
const dom = parse5.parseFragment(html);
let text = '';
@@ -85,7 +88,7 @@ export class MfmService {
const href = node.attrs.find(x => x.name === 'href');
// ハッシュタグ
if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) {
if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) {
text += txt;
// メンション
} else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) {
@@ -244,6 +247,8 @@ export class MfmService {
const doc = window.document;
const body = doc.createElement('p');
function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
if (children) {
for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child);
@@ -454,8 +459,8 @@ export class MfmService {
},
};
appendChildren(nodes, doc.body);
appendChildren(nodes, body);
return `<p>${doc.body.innerHTML}</p>`;
return new XMLSerializer().serializeToString(body);
}
}

View File

@@ -38,7 +38,7 @@ import InstanceChart from '@/core/chart/charts/instance.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { WebhookService } from '@/core/WebhookService.js';
import { UserWebhookService } from '@/core/UserWebhookService.js';
import { HashtagService } from '@/core/HashtagService.js';
import { AntennaService } from '@/core/AntennaService.js';
import { QueueService } from '@/core/QueueService.js';
@@ -59,6 +59,7 @@ import { UtilityService } from '@/core/UtilityService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
import { isReply } from '@/misc/is-reply.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@@ -151,8 +152,6 @@ type Option = {
export class NoteCreateService implements OnApplicationShutdown {
#shutdownController = new AbortController();
public static ContainsProhibitedWordsError = class extends Error {};
constructor(
@Inject(DI.config)
private config: Config,
@@ -205,7 +204,7 @@ export class NoteCreateService implements OnApplicationShutdown {
private federatedInstanceService: FederatedInstanceService,
private hashtagService: HashtagService,
private antennaService: AntennaService,
private webhookService: WebhookService,
private webhookService: UserWebhookService,
private featuredService: FeaturedService,
private remoteUserResolveService: RemoteUserResolveService,
private apDeliverManagerService: ApDeliverManagerService,
@@ -263,8 +262,14 @@ export class NoteCreateService implements OnApplicationShutdown {
}
}
if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', meta.prohibitedWords)) {
throw new NoteCreateService.ContainsProhibitedWordsError();
const hasProhibitedWords = await this.checkProhibitedWordsContain({
cw: data.cw,
text: data.text,
pollChoices: data.poll?.choices,
}, meta.prohibitedWords);
if (hasProhibitedWords) {
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words');
}
const inSilencedInstance = this.utilityService.isSilencedHost(meta.silencedHosts, user.host);
@@ -300,7 +305,7 @@ export class NoteCreateService implements OnApplicationShutdown {
}
// Check blocking
if (data.renote && !this.isQuote(data)) {
if (this.isRenote(data) && !this.isQuote(data)) {
if (data.renote.userHost === null) {
if (data.renote.userId !== user.id) {
const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id);
@@ -379,6 +384,10 @@ export class NoteCreateService implements OnApplicationShutdown {
}
}
if (mentionedUsers.length > 0 && mentionedUsers.length > (await this.roleService.getUserPolicies(user.id)).mentionLimit) {
throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', 'Note contains too many mentions');
}
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
setImmediate('post created', { signal: this.#shutdownController.signal }).then(
@@ -463,6 +472,7 @@ export class NoteCreateService implements OnApplicationShutdown {
noteVisibility: insert.visibility,
userId: user.id,
userHost: user.host,
channelId: insert.channelId,
});
await transactionalEntityManager.insert(MiPoll, poll);
@@ -595,7 +605,7 @@ export class NoteCreateService implements OnApplicationShutdown {
this.webhookService.getActiveWebhooks().then(webhooks => {
webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note'));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'note', {
this.queueService.userWebhookDeliver(webhook, 'note', {
note: noteObj,
});
}
@@ -622,7 +632,7 @@ export class NoteCreateService implements OnApplicationShutdown {
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply'));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'reply', {
this.queueService.userWebhookDeliver(webhook, 'reply', {
note: noteObj,
});
}
@@ -631,7 +641,7 @@ export class NoteCreateService implements OnApplicationShutdown {
}
// If it is renote
if (data.renote) {
if (this.isRenote(data)) {
const type = this.isQuote(data) ? 'quote' : 'renote';
// Notify
@@ -645,7 +655,7 @@ export class NoteCreateService implements OnApplicationShutdown {
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote'));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'renote', {
this.queueService.userWebhookDeliver(webhook, 'renote', {
note: noteObj,
});
}
@@ -715,9 +725,20 @@ export class NoteCreateService implements OnApplicationShutdown {
}
@bindThis
private isQuote(note: Option): note is Option & { renote: MiNote } {
// sync with misc/is-quote.ts
return !!note.renote && (!!note.text || !!note.cw || (!!note.files && !!note.files.length) || !!note.poll);
private isRenote(note: Option): note is Option & { renote: MiNote } {
return note.renote != null;
}
@bindThis
private isQuote(note: Option & { renote: MiNote }): note is Option & { renote: MiNote } & (
{ text: string } | { cw: string } | { reply: MiNote } | { poll: IPoll } | { files: MiDriveFile[] }
) {
// NOTE: SYNC WITH misc/is-quote.ts
return note.text != null ||
note.reply != null ||
note.cw != null ||
note.poll != null ||
(note.files != null && note.files.length > 0);
}
@bindThis
@@ -766,7 +787,7 @@ export class NoteCreateService implements OnApplicationShutdown {
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention'));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'mention', {
this.queueService.userWebhookDeliver(webhook, 'mention', {
note: detailPackedNote,
});
}
@@ -785,7 +806,7 @@ export class NoteCreateService implements OnApplicationShutdown {
private async renderNoteOrRenoteActivity(data: Option, note: MiNote) {
if (data.localOnly) return null;
const content = data.renote && !this.isQuote(data)
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, false), note);
@@ -817,7 +838,7 @@ export class NoteCreateService implements OnApplicationShutdown {
const mentions = extractMentions(tokens);
let mentionedUsers = (await Promise.all(mentions.map(m =>
this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null),
))).filter(x => x != null) as MiUser[];
))).filter(x => x != null);
// Drop duplicate users
mentionedUsers = mentionedUsers.filter((u, i, self) =>
@@ -991,6 +1012,23 @@ export class NoteCreateService implements OnApplicationShutdown {
}
}
public async checkProhibitedWordsContain(content: Parameters<UtilityService['concatNoteContentsForKeyWordCheck']>[0], prohibitedWords?: string[]) {
if (prohibitedWords == null) {
prohibitedWords = (await this.metaService.fetch()).prohibitedWords;
}
if (
this.utilityService.isKeyWordIncluded(
this.utilityService.concatNoteContentsForKeyWordCheck(content),
prohibitedWords,
)
) {
return true;
}
return false;
}
@bindThis
public dispose(): void {
this.#shutdownController.abort();

View File

@@ -24,7 +24,7 @@ import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { isPureRenote } from '@/misc/is-pure-renote.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
@Injectable()
export class NoteDeleteService {
@@ -79,7 +79,7 @@ export class NoteDeleteService {
let renote: MiNote | null = null;
// if deleted note is renote
if (isPureRenote(note)) {
if (isRenote(note) && !isQuote(note)) {
renote = await this.notesRepository.findOneBy({
id: note.renoteId,
});

View File

@@ -88,46 +88,47 @@ export class NoteReadService implements OnApplicationShutdown {
userId: MiUser['id'],
notes: (MiNote | Packed<'Note'>)[],
): Promise<void> {
const readMentions: (MiNote | Packed<'Note'>)[] = [];
const readSpecifiedNotes: (MiNote | Packed<'Note'>)[] = [];
if (notes.length === 0) return;
const noteIds = new Set<MiNote['id']>();
for (const note of notes) {
if (note.mentions && note.mentions.includes(userId)) {
readMentions.push(note);
noteIds.add(note.id);
} else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) {
readSpecifiedNotes.push(note);
noteIds.add(note.id);
}
}
if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0)) {
// Remove the record
await this.noteUnreadsRepository.delete({
userId: userId,
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]),
});
if (noteIds.size === 0) return;
// TODO: ↓まとめてクエリしたい
// Remove the record
await this.noteUnreadsRepository.delete({
userId: userId,
noteId: In(Array.from(noteIds)),
});
trackPromise(this.noteUnreadsRepository.countBy({
userId: userId,
isMentioned: true,
}).then(mentionsCount => {
if (mentionsCount === 0) {
// 全て既読になったイベントを発行
this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions');
}
}));
// TODO: ↓まとめてクエリしたい
trackPromise(this.noteUnreadsRepository.countBy({
userId: userId,
isSpecified: true,
}).then(specifiedCount => {
if (specifiedCount === 0) {
// 全て既読になったイベントを発行
this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
}
}));
}
trackPromise(this.noteUnreadsRepository.countBy({
userId: userId,
isMentioned: true,
}).then(mentionsCount => {
if (mentionsCount === 0) {
// 全て既読になったイベントを発行
this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions');
}
}));
trackPromise(this.noteUnreadsRepository.countBy({
userId: userId,
isSpecified: true,
}).then(specifiedCount => {
if (specifiedCount === 0) {
// 全て既読になったイベントを発行
this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
}
}));
}
@bindThis

View File

@@ -122,6 +122,14 @@ export class NotificationService implements OnApplicationShutdown {
return null;
}
} else if (recieveConfig?.type === 'mutualFollow') {
const [isFollowing, isFollower] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
]);
if (!(isFollowing && isFollower)) {
return null;
}
} else if (recieveConfig?.type === 'followingOrFollower') {
const [isFollowing, isFollower] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => Object.hasOwn(followings, notifierId)),
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => Object.hasOwn(followings, notifieeId)),
@@ -155,6 +163,8 @@ export class NotificationService implements OnApplicationShutdown {
const packed = await this.notificationEntityService.pack(notification, notifieeId, {});
if (packed == null) return null;
// Publish notification event
this.globalEventService.publishMainStream(notifieeId, 'notification', packed);
@@ -204,6 +214,15 @@ export class NotificationService implements OnApplicationShutdown {
*/
}
@bindThis
public async flushAllNotifications(userId: MiUser['id']) {
await Promise.all([
this.redisClient.del(`notificationTimeline:${userId}`),
this.redisClient.del(`latestReadNotification:${userId}`),
]);
this.globalEventService.publishMainStream(userId, 'notificationFlushed');
}
@bindThis
public dispose(): void {
this.#shutdownController.abort();

View File

@@ -101,7 +101,7 @@ export class PushNotificationService implements OnApplicationShutdown {
type,
body: (type === 'notification' || type === 'unreadAntennaNote') ? truncateBody(type, body) : body,
userId,
dateTime: (new Date()).getTime(),
dateTime: Date.now(),
}), {
proxy: this.config.proxy,
}).catch((err: any) => {
@@ -115,12 +115,19 @@ export class PushNotificationService implements OnApplicationShutdown {
endpoint: subscription.endpoint,
auth: subscription.auth,
publickey: subscription.publickey,
}).then(() => {
this.refreshCache(userId);
});
}
});
}
}
@bindThis
public refreshCache(userId: string): void {
this.subscriptionsCache.refresh(userId);
}
@bindThis
public dispose(): void {
this.subscriptionsCache.dispose();

View File

@@ -7,10 +7,17 @@ import { Inject, Module, OnApplicationShutdown } from '@nestjs/common';
import * as Bull from 'bullmq';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { QUEUE, baseQueueOptions } from '@/queue/const.js';
import { baseQueueOptions, QUEUE } from '@/queue/const.js';
import { allSettled } from '@/misc/promise-tracker.js';
import {
DeliverJobData,
EndedPollNotificationJobData,
InboxJobData,
RelationshipJobData,
UserWebhookDeliverJobData,
SystemWebhookDeliverJobData,
} from '../queue/types.js';
import type { Provider } from '@nestjs/common';
import type { DeliverJobData, InboxJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData } from '../queue/types.js';
export type SystemQueue = Bull.Queue<Record<string, unknown>>;
export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
@@ -19,7 +26,8 @@ export type InboxQueue = Bull.Queue<InboxJobData>;
export type DbQueue = Bull.Queue;
export type RelationshipQueue = Bull.Queue<RelationshipJobData>;
export type ObjectStorageQueue = Bull.Queue;
export type WebhookDeliverQueue = Bull.Queue<WebhookDeliverJobData>;
export type UserWebhookDeliverQueue = Bull.Queue<UserWebhookDeliverJobData>;
export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>;
const $system: Provider = {
provide: 'queue:system',
@@ -63,9 +71,15 @@ const $objectStorage: Provider = {
inject: [DI.config],
};
const $webhookDeliver: Provider = {
provide: 'queue:webhookDeliver',
useFactory: (config: Config) => new Bull.Queue(QUEUE.WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.WEBHOOK_DELIVER)),
const $userWebhookDeliver: Provider = {
provide: 'queue:userWebhookDeliver',
useFactory: (config: Config) => new Bull.Queue(QUEUE.USER_WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.USER_WEBHOOK_DELIVER)),
inject: [DI.config],
};
const $systemWebhookDeliver: Provider = {
provide: 'queue:systemWebhookDeliver',
useFactory: (config: Config) => new Bull.Queue(QUEUE.SYSTEM_WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.SYSTEM_WEBHOOK_DELIVER)),
inject: [DI.config],
};
@@ -80,7 +94,8 @@ const $webhookDeliver: Provider = {
$db,
$relationship,
$objectStorage,
$webhookDeliver,
$userWebhookDeliver,
$systemWebhookDeliver,
],
exports: [
$system,
@@ -90,7 +105,8 @@ const $webhookDeliver: Provider = {
$db,
$relationship,
$objectStorage,
$webhookDeliver,
$userWebhookDeliver,
$systemWebhookDeliver,
],
})
export class QueueModule implements OnApplicationShutdown {
@@ -102,7 +118,8 @@ export class QueueModule implements OnApplicationShutdown {
@Inject('queue:db') public dbQueue: DbQueue,
@Inject('queue:relationship') public relationshipQueue: RelationshipQueue,
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
@Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue,
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
) {}
public async dispose(): Promise<void> {
@@ -117,7 +134,8 @@ export class QueueModule implements OnApplicationShutdown {
this.dbQueue.close(),
this.relationshipQueue.close(),
this.objectStorageQueue.close(),
this.webhookDeliverQueue.close(),
this.userWebhookDeliverQueue.close(),
this.systemWebhookDeliverQueue.close(),
]);
}

View File

@@ -8,15 +8,33 @@ import { Inject, Injectable } from '@nestjs/common';
import type { IActivity } from '@/core/activitypub/type.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiWebhook, webhookEventTypes } from '@/models/Webhook.js';
import type { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js';
import type { DbJobData, DeliverJobData, RelationshipJobData, ThinUser } from '../queue/types.js';
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
import type {
DbJobData,
DeliverJobData,
RelationshipJobData,
SystemWebhookDeliverJobData,
ThinUser,
UserWebhookDeliverJobData,
} from '../queue/types.js';
import type {
DbQueue,
DeliverQueue,
EndedPollNotificationQueue,
InboxQueue,
ObjectStorageQueue,
RelationshipQueue,
SystemQueue,
UserWebhookDeliverQueue,
SystemWebhookDeliverQueue,
} from './QueueModule.js';
import type httpSignature from '@peertube/http-signature';
import type * as Bull from 'bullmq';
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
@Injectable()
export class QueueService {
@@ -31,7 +49,8 @@ export class QueueService {
@Inject('queue:db') public dbQueue: DbQueue,
@Inject('queue:relationship') public relationshipQueue: RelationshipQueue,
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
@Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue,
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
) {
this.systemQueue.add('tickCharts', {
}, {
@@ -431,9 +450,13 @@ export class QueueService {
});
}
/**
* @see UserWebhookDeliverJobData
* @see WebhookDeliverProcessorService
*/
@bindThis
public webhookDeliver(webhook: MiWebhook, type: typeof webhookEventTypes[number], content: unknown) {
const data = {
public userWebhookDeliver(webhook: MiWebhook, type: typeof webhookEventTypes[number], content: unknown) {
const data: UserWebhookDeliverJobData = {
type,
content,
webhookId: webhook.id,
@@ -444,7 +467,33 @@ export class QueueService {
eventId: randomUUID(),
};
return this.webhookDeliverQueue.add(webhook.id, data, {
return this.userWebhookDeliverQueue.add(webhook.id, data, {
attempts: 4,
backoff: {
type: 'custom',
},
removeOnComplete: true,
removeOnFail: true,
});
}
/**
* @see SystemWebhookDeliverJobData
* @see WebhookDeliverProcessorService
*/
@bindThis
public systemWebhookDeliver(webhook: MiSystemWebhook, type: SystemWebhookEventType, content: unknown) {
const data: SystemWebhookDeliverJobData = {
type,
content,
webhookId: webhook.id,
to: webhook.url,
secret: webhook.secret,
createdAt: Date.now(),
eventId: randomUUID(),
};
return this.systemWebhookDeliverQueue.add(webhook.id, data, {
attempts: 4,
backoff: {
type: 'custom',

View File

@@ -29,13 +29,14 @@ import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { RoleService } from '@/core/RoleService.js';
import { FeaturedService } from '@/core/FeaturedService.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
const FALLBACK = '';
const FALLBACK = '\u2764';
const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16;
const legacies: Record<string, string> = {
'like': '👍',
'love': '', // ここに記述する場合は異体字セレクタを入れない
'love': '\u2764', // ハート、異体字セレクタを入れない
'laugh': '😆',
'hmm': '🤔',
'surprise': '😮',
@@ -117,11 +118,16 @@ export class ReactionService {
throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.');
}
// Check if note is Renote
if (isRenote(note) && !isQuote(note)) {
throw new IdentifiableError('12c35529-3c79-4327-b1cc-e2cf63a71925', 'You cannot react to Renote.');
}
let reaction = _reaction ?? FALLBACK;
if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && (user.host != null))) {
reaction = '❤️';
} else if (_reaction) {
reaction = '\u2764';
} else if (_reaction != null) {
const custom = reaction.match(isCustomEmojiRegexp);
if (custom) {
const reacterHost = this.utilityService.toPunyNullable(user.host);
@@ -322,35 +328,36 @@ export class ReactionService {
//#endregion
}
/**
* 文字列タイプのレガシーな形式のリアクションを現在の形式に変換しつつ、
* データベース上には存在する「0個のリアクションがついている」という情報を削除する。
*/
@bindThis
public convertLegacyReactions(reactions: Record<string, number>) {
const _reactions = {} as Record<string, number>;
public convertLegacyReactions(reactions: MiNote['reactions']): MiNote['reactions'] {
return Object.entries(reactions)
.filter(([, count]) => {
// `ReactionService.prototype.delete`ではリアクション削除時に、
// `MiNote['reactions']`のエントリの値をデクリメントしているが、
// デクリメントしているだけなのでエントリ自体は0を値として持つ形で残り続ける。
// そのため、この処理がなければ、「0個のリアクションがついている」ということになってしまう。
return count > 0;
})
.map(([reaction, count]) => {
// unchecked indexed access
const convertedReaction = legacies[reaction] as string | undefined;
for (const reaction of Object.keys(reactions)) {
if (reactions[reaction] <= 0) continue;
const key = this.decodeReaction(convertedReaction ?? reaction).reaction;
if (Object.keys(legacies).includes(reaction)) {
if (_reactions[legacies[reaction]]) {
_reactions[legacies[reaction]] += reactions[reaction];
} else {
_reactions[legacies[reaction]] = reactions[reaction];
}
} else {
if (_reactions[reaction]) {
_reactions[reaction] += reactions[reaction];
} else {
_reactions[reaction] = reactions[reaction];
}
}
}
return [key, count] as const;
})
.reduce<MiNote['reactions']>((acc, [key, count]) => {
// unchecked indexed access
const prevCount = acc[key] as number | undefined;
const _reactions2 = {} as Record<string, number>;
acc[key] = (prevCount ?? 0) + count;
for (const reaction of Object.keys(_reactions)) {
_reactions2[this.decodeReaction(reaction).reaction] = _reactions[reaction];
}
return _reactions2;
return acc;
}, {});
}
@bindThis

View File

@@ -53,11 +53,11 @@ export class RelayService {
@bindThis
public async addRelay(inbox: string): Promise<MiRelay> {
const relay = await this.relaysRepository.insert({
const relay = await this.relaysRepository.insertOne({
id: this.idService.gen(),
inbox,
status: 'requesting',
}).then(x => this.relaysRepository.findOneByOrFail(x.identifiers[0]));
});
const relayActor = await this.getRelayActor();
const follow = await this.apRendererService.renderFollowRelay(relay, relayActor);

View File

@@ -281,7 +281,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
@bindThis
private async matched(parentId: MiUser['id'], childId: MiUser['id'], options: { noIrregularRules: boolean; }): Promise<MiReversiGame> {
const game = await this.reversiGamesRepository.insert({
const game = await this.reversiGamesRepository.insertOne({
id: this.idService.gen(),
user1Id: parentId,
user2Id: childId,
@@ -294,10 +294,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
bw: 'random',
isLlotheo: false,
noIrregularRules: options.noIrregularRules,
}).then(x => this.reversiGamesRepository.findOneOrFail({
where: { id: x.identifiers[0].id },
relations: ['user1', 'user2'],
}));
}, { relations: ['user1', 'user2'] });
this.cacheGame(game);
const packed = await this.reversiGameEntityService.packDetail(game);

View File

@@ -35,6 +35,7 @@ export type RolePolicies = {
gtlAvailable: boolean;
ltlAvailable: boolean;
canPublicNote: boolean;
mentionLimit: number;
canInvite: boolean;
inviteLimit: number;
inviteLimitCycle: number;
@@ -62,6 +63,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
gtlAvailable: true,
ltlAvailable: true,
canPublicNote: true,
mentionLimit: 20,
canInvite: false,
inviteLimit: 0,
inviteLimitCycle: 60 * 24 * 7,
@@ -200,45 +202,82 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
}
@bindThis
private evalCond(user: MiUser, value: RoleCondFormulaValue): boolean {
private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue): boolean {
try {
switch (value.type) {
// ~かつ~
case 'and': {
return value.values.every(v => this.evalCond(user, v));
return value.values.every(v => this.evalCond(user, roles, v));
}
// ~または~
case 'or': {
return value.values.some(v => this.evalCond(user, v));
return value.values.some(v => this.evalCond(user, roles, v));
}
// ~ではない
case 'not': {
return !this.evalCond(user, value.value);
return !this.evalCond(user, roles, value.value);
}
// マニュアルロールがアサインされている
case 'roleAssignedTo': {
return roles.some(r => r.id === value.roleId);
}
// ローカルユーザのみ
case 'isLocal': {
return this.userEntityService.isLocalUser(user);
}
// リモートユーザのみ
case 'isRemote': {
return this.userEntityService.isRemoteUser(user);
}
// サスペンド済みユーザである
case 'isSuspended': {
return user.isSuspended;
}
// 鍵アカウントユーザである
case 'isLocked': {
return user.isLocked;
}
// botユーザである
case 'isBot': {
return user.isBot;
}
// 猫である
case 'isCat': {
return user.isCat;
}
// 「ユーザを見つけやすくする」が有効なアカウント
case 'isExplorable': {
return user.isExplorable;
}
// ユーザが作成されてから指定期間経過した
case 'createdLessThan': {
return this.idService.parse(user.id).date.getTime() > (Date.now() - (value.sec * 1000));
}
// ユーザが作成されてから指定期間経っていない
case 'createdMoreThan': {
return this.idService.parse(user.id).date.getTime() < (Date.now() - (value.sec * 1000));
}
// フォロワー数が指定値以下
case 'followersLessThanOrEq': {
return user.followersCount <= value.value;
}
// フォロワー数が指定値以上
case 'followersMoreThanOrEq': {
return user.followersCount >= value.value;
}
// フォロー数が指定値以下
case 'followingLessThanOrEq': {
return user.followingCount <= value.value;
}
// フォロー数が指定値以上
case 'followingMoreThanOrEq': {
return user.followingCount >= value.value;
}
// ノート数が指定値以下
case 'notesLessThanOrEq': {
return user.notesCount <= value.value;
}
// ノート数が指定値以上
case 'notesMoreThanOrEq': {
return user.notesCount >= value.value;
}
@@ -272,7 +311,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
const assigns = await this.getUserAssigns(userId);
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula));
const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, assignedRoles, r.condFormula));
return [...assignedRoles, ...matchedCondRoles];
}
@@ -285,13 +324,13 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
let assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId }));
// 期限切れのロールを除外
assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now));
const assignedRoleIds = assigns.map(x => x.roleId);
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id));
const assignedRoles = roles.filter(r => assigns.map(x => x.roleId).includes(r.id));
const assignedBadgeRoles = assignedRoles.filter(r => r.asBadge);
const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional'));
if (badgeCondRoles.length > 0) {
const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null;
const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, r.condFormula));
const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, assignedRoles, r.condFormula));
return [...assignedBadgeRoles, ...matchedBadgeCondRoles];
} else {
return assignedBadgeRoles;
@@ -325,6 +364,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)),
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
mentionLimit: calc('mentionLimit', vs => Math.max(...vs)),
canInvite: calc('canInvite', vs => vs.some(v => v === true)),
inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),
inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),
@@ -370,14 +410,32 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
}
@bindThis
public async getModeratorIds(includeAdmins = true): Promise<MiUser['id'][]> {
public async getModeratorIds(includeAdmins = true, excludeExpire = false): Promise<MiUser['id'][]> {
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
const moderatorRoles = includeAdmins ? roles.filter(r => r.isModerator || r.isAdministrator) : roles.filter(r => r.isModerator);
const assigns = moderatorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({
roleId: In(moderatorRoles.map(r => r.id)),
}) : [];
const moderatorRoles = includeAdmins
? roles.filter(r => r.isModerator || r.isAdministrator)
: roles.filter(r => r.isModerator);
// TODO: isRootなアカウントも含める
return assigns.map(a => a.userId);
const assigns = moderatorRoles.length > 0
? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)) })
: [];
const now = Date.now();
const result = [
// Setを経由して重複を除去ユーザIDは重複する可能性があるので
...new Set(
assigns
.filter(it =>
(excludeExpire)
? (it.expiresAt == null || it.expiresAt.getTime() > now)
: true,
)
.map(a => a.userId),
),
];
return result.sort((x, y) => x.localeCompare(y));
}
@bindThis
@@ -431,12 +489,12 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
}
}
const created = await this.roleAssignmentsRepository.insert({
const created = await this.roleAssignmentsRepository.insertOne({
id: this.idService.gen(now),
expiresAt: expiresAt,
roleId: roleId,
userId: userId,
}).then(x => this.roleAssignmentsRepository.findOneByOrFail(x.identifiers[0]));
});
this.rolesRepository.update(roleId, {
lastUsedAt: new Date(),
@@ -518,7 +576,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
@bindThis
public async create(values: Partial<MiRole>, moderator?: MiUser): Promise<MiRole> {
const date = new Date();
const created = await this.rolesRepository.insert({
const created = await this.rolesRepository.insertOne({
id: this.idService.gen(date.getTime()),
updatedAt: date,
lastUsedAt: date,
@@ -536,7 +594,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
canEditMembersByModerator: values.canEditMembersByModerator,
displayOrder: values.displayOrder,
policies: values.policies,
}).then(x => this.rolesRepository.findOneByOrFail(x.identifiers[0]));
});
this.globalEventService.publishInternalEvent('roleCreated', created);

View File

@@ -0,0 +1,233 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import type { MiUser, SystemWebhooksRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
import { MiSystemWebhook, type SystemWebhookEventType } from '@/models/SystemWebhook.js';
import { IdService } from '@/core/IdService.js';
import { QueueService } from '@/core/QueueService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { LoggerService } from '@/core/LoggerService.js';
import Logger from '@/logger.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
export class SystemWebhookService implements OnApplicationShutdown {
private logger: Logger;
private activeSystemWebhooksFetched = false;
private activeSystemWebhooks: MiSystemWebhook[] = [];
constructor(
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@Inject(DI.systemWebhooksRepository)
private systemWebhooksRepository: SystemWebhooksRepository,
private idService: IdService,
private queueService: QueueService,
private moderationLogService: ModerationLogService,
private loggerService: LoggerService,
private globalEventService: GlobalEventService,
) {
this.redisForSub.on('message', this.onMessage);
this.logger = this.loggerService.getLogger('webhook');
}
@bindThis
public async fetchActiveSystemWebhooks() {
if (!this.activeSystemWebhooksFetched) {
this.activeSystemWebhooks = await this.systemWebhooksRepository.findBy({
isActive: true,
});
this.activeSystemWebhooksFetched = true;
}
return this.activeSystemWebhooks;
}
/**
* SystemWebhook の一覧を取得する.
*/
@bindThis
public async fetchSystemWebhooks(params?: {
ids?: MiSystemWebhook['id'][];
isActive?: MiSystemWebhook['isActive'];
on?: MiSystemWebhook['on'];
}): Promise<MiSystemWebhook[]> {
const query = this.systemWebhooksRepository.createQueryBuilder('systemWebhook');
if (params) {
if (params.ids && params.ids.length > 0) {
query.andWhere('systemWebhook.id IN (:...ids)', { ids: params.ids });
}
if (params.isActive !== undefined) {
query.andWhere('systemWebhook.isActive = :isActive', { isActive: params.isActive });
}
if (params.on && params.on.length > 0) {
query.andWhere(':on <@ systemWebhook.on', { on: params.on });
}
}
return query.getMany();
}
/**
* SystemWebhook を作成する.
*/
@bindThis
public async createSystemWebhook(
params: {
isActive: MiSystemWebhook['isActive'];
name: MiSystemWebhook['name'];
on: MiSystemWebhook['on'];
url: MiSystemWebhook['url'];
secret: MiSystemWebhook['secret'];
},
updater: MiUser,
): Promise<MiSystemWebhook> {
const id = this.idService.gen();
await this.systemWebhooksRepository.insert({
...params,
id,
});
const webhook = await this.systemWebhooksRepository.findOneByOrFail({ id });
this.globalEventService.publishInternalEvent('systemWebhookCreated', webhook);
this.moderationLogService
.log(updater, 'createSystemWebhook', {
systemWebhookId: webhook.id,
webhook: webhook,
})
.then();
return webhook;
}
/**
* SystemWebhook を更新する.
*/
@bindThis
public async updateSystemWebhook(
params: {
id: MiSystemWebhook['id'];
isActive: MiSystemWebhook['isActive'];
name: MiSystemWebhook['name'];
on: MiSystemWebhook['on'];
url: MiSystemWebhook['url'];
secret: MiSystemWebhook['secret'];
},
updater: MiUser,
): Promise<MiSystemWebhook> {
const beforeEntity = await this.systemWebhooksRepository.findOneByOrFail({ id: params.id });
await this.systemWebhooksRepository.update(beforeEntity.id, {
updatedAt: new Date(),
isActive: params.isActive,
name: params.name,
on: params.on,
url: params.url,
secret: params.secret,
});
const afterEntity = await this.systemWebhooksRepository.findOneByOrFail({ id: beforeEntity.id });
this.globalEventService.publishInternalEvent('systemWebhookUpdated', afterEntity);
this.moderationLogService
.log(updater, 'updateSystemWebhook', {
systemWebhookId: beforeEntity.id,
before: beforeEntity,
after: afterEntity,
})
.then();
return afterEntity;
}
/**
* SystemWebhook を削除する.
*/
@bindThis
public async deleteSystemWebhook(id: MiSystemWebhook['id'], updater: MiUser) {
const webhook = await this.systemWebhooksRepository.findOneByOrFail({ id });
await this.systemWebhooksRepository.delete(id);
this.globalEventService.publishInternalEvent('systemWebhookDeleted', webhook);
this.moderationLogService
.log(updater, 'deleteSystemWebhook', {
systemWebhookId: webhook.id,
webhook,
})
.then();
}
/**
* SystemWebhook をWebhook配送キューに追加する
* @see QueueService.systemWebhookDeliver
*/
@bindThis
public async enqueueSystemWebhook(webhook: MiSystemWebhook | MiSystemWebhook['id'], type: SystemWebhookEventType, content: unknown) {
const webhookEntity = typeof webhook === 'string'
? (await this.fetchActiveSystemWebhooks()).find(a => a.id === webhook)
: webhook;
if (!webhookEntity || !webhookEntity.isActive) {
this.logger.info(`Webhook is not active or not found : ${webhook}`);
return;
}
if (!webhookEntity.on.includes(type)) {
this.logger.info(`Webhook ${webhookEntity.id} is not listening to ${type}`);
return;
}
return this.queueService.systemWebhookDeliver(webhookEntity, type, content);
}
@bindThis
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel !== 'internal') {
return;
}
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'systemWebhookCreated': {
if (body.isActive) {
this.activeSystemWebhooks.push(MiSystemWebhook.deserialize(body));
}
break;
}
case 'systemWebhookUpdated': {
if (body.isActive) {
const i = this.activeSystemWebhooks.findIndex(a => a.id === body.id);
if (i > -1) {
this.activeSystemWebhooks[i] = MiSystemWebhook.deserialize(body);
} else {
this.activeSystemWebhooks.push(MiSystemWebhook.deserialize(body));
}
} else {
this.activeSystemWebhooks = this.activeSystemWebhooks.filter(a => a.id !== body.id);
}
break;
}
case 'systemWebhookDeleted': {
this.activeSystemWebhooks = this.activeSystemWebhooks.filter(a => a.id !== body.id);
break;
}
default:
break;
}
}
@bindThis
public dispose(): void {
this.redisForSub.off('message', this.onMessage);
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
}

View File

@@ -16,7 +16,7 @@ import Logger from '@/logger.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { WebhookService } from '@/core/WebhookService.js';
import { UserWebhookService } from '@/core/UserWebhookService.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
@@ -46,7 +46,7 @@ export class UserBlockingService implements OnModuleInit {
private idService: IdService,
private queueService: QueueService,
private globalEventService: GlobalEventService,
private webhookService: WebhookService,
private webhookService: UserWebhookService,
private apRendererService: ApRendererService,
private loggerService: LoggerService,
) {
@@ -121,7 +121,7 @@ export class UserBlockingService implements OnModuleInit {
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'unfollow', {
this.queueService.userWebhookDeliver(webhook, 'unfollow', {
user: packed,
});
}

View File

@@ -16,7 +16,7 @@ import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js
import type { Packed } from '@/misc/json-schema.js';
import InstanceChart from '@/core/chart/charts/instance.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { WebhookService } from '@/core/WebhookService.js';
import { UserWebhookService } from '@/core/UserWebhookService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { DI } from '@/di-symbols.js';
import type { FollowingsRepository, FollowRequestsRepository, InstancesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
@@ -30,6 +30,7 @@ import type { Config } from '@/config.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import type { ThinUser } from '@/queue/types.js';
import Logger from '../logger.js';
const logger = new Logger('following/create');
@@ -81,7 +82,7 @@ export class UserFollowingService implements OnModuleInit {
private metaService: MetaService,
private notificationService: NotificationService,
private federatedInstanceService: FederatedInstanceService,
private webhookService: WebhookService,
private webhookService: UserWebhookService,
private apRendererService: ApRendererService,
private accountMoveService: AccountMoveService,
private fanoutTimelineService: FanoutTimelineService,
@@ -94,21 +95,35 @@ export class UserFollowingService implements OnModuleInit {
this.userBlockingService = this.moduleRef.get('UserBlockingService');
}
@bindThis
public async deliverAccept(follower: MiRemoteUser, followee: MiPartialLocalUser, requestId?: string) {
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee));
this.queueService.deliver(followee, content, follower.inbox, false);
}
@bindThis
public async follow(
_follower: { id: MiUser['id'] },
_followee: { id: MiUser['id'] },
_follower: ThinUser,
_followee: ThinUser,
{ requestId, silent = false, withReplies }: {
requestId?: string,
silent?: boolean,
withReplies?: boolean,
} = {},
): Promise<void> {
/**
* 必ず最新のユーザー情報を取得する
*/
const [follower, followee] = await Promise.all([
this.usersRepository.findOneByOrFail({ id: _follower.id }),
this.usersRepository.findOneByOrFail({ id: _followee.id }),
]) as [MiLocalUser | MiRemoteUser, MiLocalUser | MiRemoteUser];
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isRemoteUser(followee)) {
// What?
throw new Error('Remote user cannot follow remote user.');
}
// check blocking
const [blocking, blocked] = await Promise.all([
this.userBlockingService.checkBlocked(follower.id, followee.id),
@@ -129,6 +144,24 @@ export class UserFollowingService implements OnModuleInit {
if (blocked) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked');
}
if (await this.followingsRepository.exists({
where: {
followerId: follower.id,
followeeId: followee.id,
},
})) {
// すでにフォロー関係が存在している場合
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
// リモート → ローカル: acceptを送り返しておしまい
this.deliverAccept(follower, followee, requestId);
return;
}
if (this.userEntityService.isLocalUser(follower)) {
// ローカル → リモート/ローカル: 例外
throw new IdentifiableError('ec3f65c0-a9d1-47d9-8791-b2e7b9dcdced', 'already following');
}
}
const followeeProfile = await this.userProfilesRepository.findOneByOrFail({ userId: followee.id });
// フォロー対象が鍵アカウントである or
// フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or
@@ -189,8 +222,7 @@ export class UserFollowingService implements OnModuleInit {
await this.insertFollowingDoc(followee, follower, silent, withReplies);
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee));
this.queueService.deliver(followee, content, follower.inbox, false);
this.deliverAccept(follower, followee, requestId);
}
}
@@ -299,7 +331,7 @@ export class UserFollowingService implements OnModuleInit {
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow'));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'follow', {
this.queueService.userWebhookDeliver(webhook, 'follow', {
user: packed,
});
}
@@ -313,7 +345,7 @@ export class UserFollowingService implements OnModuleInit {
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed'));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'followed', {
this.queueService.userWebhookDeliver(webhook, 'followed', {
user: packed,
});
}
@@ -366,7 +398,7 @@ export class UserFollowingService implements OnModuleInit {
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'unfollow', {
this.queueService.userWebhookDeliver(webhook, 'unfollow', {
user: packed,
});
}
@@ -479,7 +511,13 @@ export class UserFollowingService implements OnModuleInit {
if (blocking) throw new Error('blocking');
if (blocked) throw new Error('blocked');
const followRequest = await this.followRequestsRepository.insert({
// Remove old follow requests before creating a new one.
await this.followRequestsRepository.delete({
followeeId: followee.id,
followerId: follower.id,
});
const followRequest = await this.followRequestsRepository.insertOne({
id: this.idService.gen(),
followerId: follower.id,
followeeId: followee.id,
@@ -493,7 +531,7 @@ export class UserFollowingService implements OnModuleInit {
followeeHost: followee.host,
followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : undefined,
followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : undefined,
}).then(x => this.followRequestsRepository.findOneByOrFail(x.identifiers[0]));
});
// Publish receiveRequest event
if (this.userEntityService.isLocalUser(followee)) {
@@ -571,8 +609,7 @@ export class UserFollowingService implements OnModuleInit {
await this.insertFollowingDoc(followee, follower, false, request.withReplies);
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee as MiPartialLocalUser, request.requestId!), followee));
this.queueService.deliver(followee, content, follower.inbox, false);
this.deliverAccept(follower, followee as MiPartialLocalUser, request.requestId ?? undefined);
}
this.userEntityService.pack(followee.id, followee, {
@@ -703,7 +740,7 @@ export class UserFollowingService implements OnModuleInit {
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
for (const webhook of webhooks) {
this.queueService.webhookDeliver(webhook, 'unfollow', {
this.queueService.userWebhookDeliver(webhook, 'unfollow', {
user: packedFollowee,
});
}

View File

@@ -95,7 +95,7 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit {
const currentCount = await this.userListMembershipsRepository.countBy({
userListId: list.id,
});
if (currentCount > (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) {
if (currentCount >= (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) {
throw new UserListService.TooManyUsersError();
}

View File

@@ -0,0 +1,99 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import type { WebhooksRepository } from '@/models/_.js';
import type { MiWebhook } from '@/models/Webhook.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { GlobalEvents } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
export class UserWebhookService implements OnApplicationShutdown {
private activeWebhooksFetched = false;
private activeWebhooks: MiWebhook[] = [];
constructor(
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@Inject(DI.webhooksRepository)
private webhooksRepository: WebhooksRepository,
) {
this.redisForSub.on('message', this.onMessage);
}
@bindThis
public async getActiveWebhooks() {
if (!this.activeWebhooksFetched) {
this.activeWebhooks = await this.webhooksRepository.findBy({
active: true,
});
this.activeWebhooksFetched = true;
}
return this.activeWebhooks;
}
@bindThis
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel !== 'internal') {
return;
}
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'webhookCreated': {
if (body.active) {
this.activeWebhooks.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null,
user: null, // joinなカラムは通常取ってこないので
});
}
break;
}
case 'webhookUpdated': {
if (body.active) {
const i = this.activeWebhooks.findIndex(a => a.id === body.id);
if (i > -1) {
this.activeWebhooks[i] = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null,
user: null, // joinなカラムは通常取ってこないので
};
} else {
this.activeWebhooks.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null,
user: null, // joinなカラムは通常取ってこないので
});
}
} else {
this.activeWebhooks = this.activeWebhooks.filter(a => a.id !== body.id);
}
break;
}
case 'webhookDeleted': {
this.activeWebhooks = this.activeWebhooks.filter(a => a.id !== body.id);
break;
}
default:
break;
}
}
@bindThis
public dispose(): void {
this.redisForSub.off('message', this.onMessage);
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
}

View File

@@ -42,6 +42,20 @@ export class UtilityService {
return silencedHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`));
}
@bindThis
public concatNoteContentsForKeyWordCheck(content: {
cw?: string | null;
text?: string | null;
pollChoices?: string[] | null;
others?: string[] | null;
}): string {
/**
* ノートの内容を結合してキーワードチェック用の文字列を生成する
* cwとtextは内容が繋がっているかもしれないので間に何も入れずにチェックする
*/
return `${content.cw ?? ''}${content.text ?? ''}\n${(content.pollChoices ?? []).join('\n')}\n${(content.others ?? []).join('\n')}`;
}
@bindThis
public isKeyWordIncluded(text: string, keyWords: string[]): boolean {
if (keyWords.length === 0) return false;

View File

@@ -10,7 +10,7 @@ import {
generateRegistrationOptions, verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import { AttestationFormat, isoCBOR } from '@simplewebauthn/server/helpers';
import { AttestationFormat, isoCBOR, isoUint8Array } from '@simplewebauthn/server/helpers';
import { DI } from '@/di-symbols.js';
import type { UserSecurityKeysRepository } from '@/models/_.js';
import type { Config } from '@/config.js';
@@ -26,7 +26,7 @@ import type {
PublicKeyCredentialDescriptorFuture,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/typescript-types';
} from '@simplewebauthn/types';
@Injectable()
export class WebAuthnService {
@@ -49,7 +49,7 @@ export class WebAuthnService {
const instance = await this.metaService.fetch();
return {
origin: this.config.url,
rpId: this.config.host,
rpId: this.config.hostname,
rpName: instance.name ?? this.config.host,
rpIcon: instance.iconUrl ?? undefined,
};
@@ -65,13 +65,12 @@ export class WebAuthnService {
const registrationOptions = await generateRegistrationOptions({
rpName: relyingParty.rpName,
rpID: relyingParty.rpId,
userID: userId,
userID: isoUint8Array.fromUTF8String(userId),
userName: userName,
userDisplayName: userDisplayName,
attestationType: 'indirect',
excludeCredentials: keys.map(key => (<PublicKeyCredentialDescriptorFuture>{
id: Buffer.from(key.id, 'base64url'),
type: 'public-key',
excludeCredentials: keys.map(key => (<{ id: string; transports?: AuthenticatorTransportFuture[]; }>{
id: key.id,
transports: key.transports ?? undefined,
})),
authenticatorSelection: {
@@ -87,7 +86,7 @@ export class WebAuthnService {
@bindThis
public async verifyRegistration(userId: MiUser['id'], response: RegistrationResponseJSON): Promise<{
credentialID: Uint8Array;
credentialID: string;
credentialPublicKey: Uint8Array;
attestationObject: Uint8Array;
fmt: AttestationFormat;
@@ -144,6 +143,7 @@ export class WebAuthnService {
@bindThis
public async initiateAuthentication(userId: MiUser['id']): Promise<PublicKeyCredentialRequestOptionsJSON> {
const relyingParty = await this.getRelyingParty();
const keys = await this.userSecurityKeysRepository.findBy({
userId: userId,
});
@@ -153,9 +153,9 @@ export class WebAuthnService {
}
const authenticationOptions = await generateAuthenticationOptions({
allowCredentials: keys.map(key => (<PublicKeyCredentialDescriptorFuture>{
id: Buffer.from(key.id, 'base64url'),
type: 'public-key',
rpID: relyingParty.rpId,
allowCredentials: keys.map(key => (<{ id: string; transports?: AuthenticatorTransportFuture[]; }>{
id: key.id,
transports: key.transports ?? undefined,
})),
userVerification: 'preferred',
@@ -191,7 +191,7 @@ export class WebAuthnService {
if (cert[0] === 0x04) { // 前の実装ではいつも 0x04 で始まっていた
const halfLength = (cert.length - 1) / 2;
const cborMap = new Map<number, number | ArrayBufferLike>();
const cborMap = new Map<number, number | Uint8Array>();
cborMap.set(1, 2); // kty, EC2
cborMap.set(3, -7); // alg, ES256
cborMap.set(-1, 1); // crv, P256
@@ -219,7 +219,7 @@ export class WebAuthnService {
expectedOrigin: relyingParty.origin,
expectedRPID: relyingParty.rpId,
authenticator: {
credentialID: Buffer.from(key.id, 'base64url'),
credentialID: key.id,
credentialPublicKey: Buffer.from(key.publicKey, 'base64url'),
counter: key.counter,
transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined,

View File

@@ -1,97 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import type { WebhooksRepository } from '@/models/_.js';
import type { MiWebhook } from '@/models/Webhook.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
export class WebhookService implements OnApplicationShutdown {
private webhooksFetched = false;
private webhooks: MiWebhook[] = [];
constructor(
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@Inject(DI.webhooksRepository)
private webhooksRepository: WebhooksRepository,
) {
//this.onMessage = this.onMessage.bind(this);
this.redisForSub.on('message', this.onMessage);
}
@bindThis
public async getActiveWebhooks() {
if (!this.webhooksFetched) {
this.webhooks = await this.webhooksRepository.findBy({
active: true,
});
this.webhooksFetched = true;
}
return this.webhooks;
}
@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'];
switch (type) {
case 'webhookCreated':
if (body.active) {
this.webhooks.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null,
user: null, // joinなカラムは通常取ってこないので
});
}
break;
case 'webhookUpdated':
if (body.active) {
const i = this.webhooks.findIndex(a => a.id === body.id);
if (i > -1) {
this.webhooks[i] = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null,
user: null, // joinなカラムは通常取ってこないので
};
} else {
this.webhooks.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい
...body,
latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null,
user: null, // joinなカラムは通常取ってこないので
});
}
} else {
this.webhooks = this.webhooks.filter(a => a.id !== body.id);
}
break;
case 'webhookDeleted':
this.webhooks = this.webhooks.filter(a => a.id !== body.id);
break;
default:
break;
}
}
}
@bindThis
public dispose(): void {
this.redisForSub.off('message', this.onMessage);
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
}

View File

@@ -40,7 +40,7 @@ export class ApAudienceService {
const limit = promiseLimit<MiUser | null>(2);
const mentionedUsers = (await Promise.all(
others.map(id => limit(() => this.apPersonService.resolvePerson(id, resolver).catch(() => null))),
)).filter((x): x is MiUser => x != null);
)).filter(x => x != null);
if (toGroups.public.length > 0) {
return {

View File

@@ -106,12 +106,12 @@ export class ApDbResolverService implements OnApplicationShutdown {
return await this.cacheService.userByIdCache.fetchMaybe(
parsed.id,
() => this.usersRepository.findOneBy({ id: parsed.id }).then(x => x ?? undefined),
() => this.usersRepository.findOneBy({ id: parsed.id, isDeleted: false }).then(x => x ?? undefined),
) as MiLocalUser | undefined ?? null;
} else {
return await this.cacheService.uriPersonCache.fetch(
parsed.uri,
() => this.usersRepository.findOneBy({ uri: parsed.uri }),
() => this.usersRepository.findOneBy({ uri: parsed.uri, isDeleted: false }),
) as MiRemoteUser | null;
}
}
@@ -136,8 +136,12 @@ export class ApDbResolverService implements OnApplicationShutdown {
if (key == null) return null;
const user = await this.cacheService.findUserById(key.userId).catch(() => null) as MiRemoteUser | null;
if (user == null) return null;
if (user.isDeleted) return null;
return {
user: await this.cacheService.findUserById(key.userId) as MiRemoteUser,
user,
key,
};
}
@@ -151,6 +155,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
key: MiUserPublickey | null;
} | null> {
const user = await this.apPersonService.resolvePerson(uri) as MiRemoteUser;
if (user.isDeleted) return null;
const key = await this.publicKeyByUserIdCache.fetch(
user.id,

View File

@@ -27,6 +27,8 @@ import { QueueService } from '@/core/QueueService.js';
import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import type { MiRemoteUser } from '@/models/User.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { AbuseReportService } from '@/core/AbuseReportService.js';
import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js';
import { ApNoteService } from './models/ApNoteService.js';
import { ApLoggerService } from './ApLoggerService.js';
@@ -36,7 +38,7 @@ import { ApAudienceService } from './ApAudienceService.js';
import { ApPersonService } from './models/ApPersonService.js';
import { ApQuestionService } from './models/ApQuestionService.js';
import type { Resolver } from './ApResolverService.js';
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove } from './type.js';
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove, IPost } from './type.js';
@Injectable()
export class ApInboxService {
@@ -55,9 +57,6 @@ export class ApInboxService {
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
@Inject(DI.abuseUserReportsRepository)
private abuseUserReportsRepository: AbuseUserReportsRepository,
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
@@ -66,6 +65,7 @@ export class ApInboxService {
private utilityService: UtilityService,
private idService: IdService,
private metaService: MetaService,
private abuseReportService: AbuseReportService,
private userFollowingService: UserFollowingService,
private apAudienceService: ApAudienceService,
private reactionService: ReactionService,
@@ -82,18 +82,21 @@ export class ApInboxService {
private apPersonService: ApPersonService,
private apQuestionService: ApQuestionService,
private queueService: QueueService,
private globalEventService: GlobalEventService,
) {
this.logger = this.apLoggerService.logger;
}
@bindThis
public async performActivity(actor: MiRemoteUser, activity: IObject): Promise<void> {
public async performActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> {
let result = undefined as string | void;
if (isCollectionOrOrderedCollection(activity)) {
const results = [] as [string, string | void][];
const resolver = this.apResolverService.createResolver();
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) {
const act = await resolver.resolve(item);
try {
await this.performOneActivity(actor, act);
results.push([getApId(item), await this.performOneActivity(actor, act)]);
} catch (err) {
if (err instanceof Error || typeof err === 'string') {
this.logger.error(err);
@@ -102,8 +105,13 @@ export class ApInboxService {
}
}
}
const hasReason = results.some(([, reason]) => (reason != null && !reason.startsWith('ok')));
if (hasReason) {
result = results.map(([id, reason]) => `${id}: ${reason}`).join('\n');
}
} else {
await this.performOneActivity(actor, activity);
result = await this.performOneActivity(actor, activity);
}
// ついでにリモートユーザーの情報が古かったら更新しておく
@@ -114,42 +122,43 @@ export class ApInboxService {
});
}
}
return result;
}
@bindThis
public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise<void> {
public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> {
if (actor.isSuspended) return;
if (isCreate(activity)) {
await this.create(actor, activity);
return await this.create(actor, activity);
} else if (isDelete(activity)) {
await this.delete(actor, activity);
return await this.delete(actor, activity);
} else if (isUpdate(activity)) {
await this.update(actor, activity);
return await this.update(actor, activity);
} else if (isFollow(activity)) {
await this.follow(actor, activity);
return await this.follow(actor, activity);
} else if (isAccept(activity)) {
await this.accept(actor, activity);
return await this.accept(actor, activity);
} else if (isReject(activity)) {
await this.reject(actor, activity);
return await this.reject(actor, activity);
} else if (isAdd(activity)) {
await this.add(actor, activity).catch(err => this.logger.error(err));
return await this.add(actor, activity);
} else if (isRemove(activity)) {
await this.remove(actor, activity).catch(err => this.logger.error(err));
return await this.remove(actor, activity);
} else if (isAnnounce(activity)) {
await this.announce(actor, activity);
return await this.announce(actor, activity);
} else if (isLike(activity)) {
await this.like(actor, activity);
return await this.like(actor, activity);
} else if (isUndo(activity)) {
await this.undo(actor, activity);
return await this.undo(actor, activity);
} else if (isBlock(activity)) {
await this.block(actor, activity);
return await this.block(actor, activity);
} else if (isFlag(activity)) {
await this.flag(actor, activity);
return await this.flag(actor, activity);
} else if (isMove(activity)) {
await this.move(actor, activity);
return await this.move(actor, activity);
} else {
this.logger.warn(`unrecognized activity type: ${activity.type}`);
return `unrecognized activity type: ${activity.type}`;
}
}
@@ -231,38 +240,49 @@ export class ApInboxService {
}
@bindThis
private async add(actor: MiRemoteUser, activity: IAdd): Promise<void> {
private async add(actor: MiRemoteUser, activity: IAdd): Promise<string | void> {
if (actor.uri !== activity.actor) {
throw new Error('invalid actor');
return 'invalid actor';
}
if (activity.target == null) {
throw new Error('target is null');
return 'target is null';
}
if (activity.target === actor.featured) {
const note = await this.apNoteService.resolveNote(activity.object);
if (note == null) throw new Error('note not found');
if (note == null) return 'note not found';
await this.notePiningService.addPinned(actor, note.id);
return;
}
throw new Error(`unknown target: ${activity.target}`);
return `unknown target: ${activity.target}`;
}
@bindThis
private async announce(actor: MiRemoteUser, activity: IAnnounce): Promise<void> {
private async announce(actor: MiRemoteUser, activity: IAnnounce): Promise<string | void> {
const uri = getApId(activity);
this.logger.info(`Announce: ${uri}`);
const targetUri = getApId(activity.object);
const resolver = this.apResolverService.createResolver();
await this.announceNote(actor, activity, targetUri);
if (!activity.object) return 'skip: activity has no object property';
const targetUri = getApId(activity.object);
if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.';
const target = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
return e;
});
if (isPost(target)) return await this.announceNote(actor, activity, target);
return `skip: unknown object type ${getApType(target)}`;
}
@bindThis
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, targetUri: string): Promise<void> {
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost): Promise<string | void> {
const uri = getApId(activity);
if (actor.isSuspended) {
@@ -285,24 +305,21 @@ export class ApInboxService {
// Announce対象をresolve
let renote;
try {
renote = await this.apNoteService.resolveNote(targetUri);
if (renote == null) throw new Error('announce target is null');
renote = await this.apNoteService.resolveNote(target);
if (renote == null) return 'announce target is null';
} catch (err) {
// 対象が4xxならスキップ
if (err instanceof StatusError) {
if (!err.isRetryable) {
this.logger.warn(`Ignored announce target ${targetUri} - ${err.statusCode}`);
return;
return `Ignored announce target ${target.id} - ${err.statusCode}`;
}
this.logger.warn(`Error in announce target ${targetUri} - ${err.statusCode}`);
return `Error in announce target ${target.id} - ${err.statusCode}`;
}
throw err;
}
if (!await this.noteEntityService.isVisibleForMe(renote, actor.id)) {
this.logger.warn('skip: invalid actor for this activity');
return;
return 'skip: invalid actor for this activity';
}
this.logger.info(`Creating the (Re)Note: ${uri}`);
@@ -311,8 +328,7 @@ export class ApInboxService {
const createdAt = activity.published ? new Date(activity.published) : null;
if (createdAt && createdAt < this.idService.parse(renote.id).date) {
this.logger.warn('skip: malformed createdAt');
return;
return 'skip: malformed createdAt';
}
await this.noteCreateService.create(actor, {
@@ -346,11 +362,15 @@ export class ApInboxService {
}
@bindThis
private async create(actor: MiRemoteUser, activity: ICreate): Promise<void> {
private async create(actor: MiRemoteUser, activity: ICreate): Promise<string | void> {
const uri = getApId(activity);
this.logger.info(`Create: ${uri}`);
if (!activity.object) return 'skip: activity has no object property';
const targetUri = getApId(activity.object);
if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.';
// copy audiences between activity <=> object.
if (typeof activity.object === 'object') {
const to = unique(concat([toArray(activity.to), toArray(activity.object.to)]));
@@ -377,7 +397,7 @@ export class ApInboxService {
if (isPost(object)) {
await this.createNote(resolver, actor, object, false, activity);
} else {
this.logger.warn(`Unknown type: ${getApType(object)}`);
return `Unknown type: ${getApType(object)}`;
}
}
@@ -419,7 +439,7 @@ export class ApInboxService {
@bindThis
private async delete(actor: MiRemoteUser, activity: IDelete): Promise<string> {
if (actor.uri !== activity.actor) {
throw new Error('invalid actor');
return 'invalid actor';
}
// 削除対象objectのtype
@@ -479,6 +499,8 @@ export class ApInboxService {
isDeleted: true,
});
this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: actor.id });
return `ok: queued ${job.name} ${job.id}`;
}
@@ -515,20 +537,19 @@ export class ApInboxService {
const userIds = uris
.filter(uri => uri.startsWith(this.config.url + '/users/'))
.map(uri => uri.split('/').at(-1))
.filter((userId): userId is string => userId !== undefined);
.filter(x => x != null);
const users = await this.usersRepository.findBy({
id: In(userIds),
});
if (users.length < 1) return 'skip';
await this.abuseUserReportsRepository.insert({
id: this.idService.gen(),
await this.abuseReportService.report([{
targetUserId: users[0].id,
targetUserHost: users[0].host,
reporterId: actor.id,
reporterHost: actor.host,
comment: `${activity.content}\n${JSON.stringify(uris, null, 2)}`,
});
}]);
return 'ok';
}
@@ -576,29 +597,29 @@ export class ApInboxService {
}
@bindThis
private async remove(actor: MiRemoteUser, activity: IRemove): Promise<void> {
private async remove(actor: MiRemoteUser, activity: IRemove): Promise<string | void> {
if (actor.uri !== activity.actor) {
throw new Error('invalid actor');
return 'invalid actor';
}
if (activity.target == null) {
throw new Error('target is null');
return 'target is null';
}
if (activity.target === actor.featured) {
const note = await this.apNoteService.resolveNote(activity.object);
if (note == null) throw new Error('note not found');
if (note == null) return 'note not found';
await this.notePiningService.removePinned(actor, note.id);
return;
}
throw new Error(`unknown target: ${activity.target}`);
return `unknown target: ${activity.target}`;
}
@bindThis
private async undo(actor: MiRemoteUser, activity: IUndo): Promise<string> {
if (actor.uri !== activity.actor) {
throw new Error('invalid actor');
return 'invalid actor';
}
const uri = activity.id ?? activity;
@@ -609,7 +630,7 @@ export class ApInboxService {
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
throw e;
return e;
});
// don't queue because the sender may attempt again when timeout

View File

@@ -26,10 +26,10 @@ import type { MiUserKeypair } from '@/models/UserKeypair.js';
import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, PollsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { isNotNull } from '@/misc/is-not-null.js';
import { IdService } from '@/core/IdService.js';
import { LdSignatureService } from './LdSignatureService.js';
import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.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()
@@ -56,7 +56,7 @@ export class ApRendererService {
private customEmojiService: CustomEmojiService,
private userEntityService: UserEntityService,
private driveFileEntityService: DriveFileEntityService,
private ldSignatureService: LdSignatureService,
private jsonLdService: JsonLdService,
private userKeypairService: UserKeypairService,
private apMfmService: ApMfmService,
private mfmService: MfmService,
@@ -166,6 +166,7 @@ export class ApRendererService {
mediaType: file.webpublicType ?? file.type,
url: this.driveFileEntityService.getPublicUrl(file),
name: file.comment,
sensitive: file.isSensitive,
};
}
@@ -315,7 +316,7 @@ export class ApRendererService {
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);
return ids.map(id => items.find(item => item.id === id)).filter(x => x != null);
};
let inReplyTo;
@@ -617,48 +618,16 @@ export class ApRendererService {
x.id = `${this.config.url}/${randomUUID()}`;
}
return Object.assign({
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{
Key: 'sec:Key',
// as non-standards
manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
sensitive: 'as:sensitive',
Hashtag: 'as:Hashtag',
quoteUrl: 'as:quoteUrl',
// Mastodon
toot: 'http://joinmastodon.org/ns#',
Emoji: 'toot:Emoji',
featured: 'toot:featured',
discoverable: 'toot:discoverable',
// schema
schema: 'http://schema.org#',
PropertyValue: 'schema:PropertyValue',
value: 'schema:value',
// Misskey
misskey: 'https://misskey-hub.net/ns#',
'_misskey_content': 'misskey:_misskey_content',
'_misskey_quote': 'misskey:_misskey_quote',
'_misskey_reaction': 'misskey:_misskey_reaction',
'_misskey_votes': 'misskey:_misskey_votes',
'_misskey_summary': 'misskey:_misskey_summary',
'isCat': 'misskey:isCat',
// vcard
vcard: 'http://www.w3.org/2006/vcard/ns#',
},
],
}, x as T & { id: string });
return Object.assign({ '@context': CONTEXT }, x as T & { id: string });
}
@bindThis
public async attachLdSignature(activity: any, user: { id: MiUser['id']; host: null; }): Promise<IActivity> {
const keypair = await this.userKeypairService.getUserKeypair(user.id);
const ldSignature = this.ldSignatureService.use();
ldSignature.debug = false;
activity = await ldSignature.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`);
const jsonLd = this.jsonLdService.use();
jsonLd.debug = false;
activity = await jsonLd.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`);
return activity;
}
@@ -716,7 +685,7 @@ export class ApRendererService {
if (names.length === 0) return [];
const allEmojis = await this.customEmojiService.localEmojisCache.fetch();
const emojis = names.map(name => allEmojis.get(name)).filter(isNotNull);
const emojis = names.map(name => allEmojis.get(name)).filter(x => x != null);
return emojis;
}

View File

@@ -14,6 +14,7 @@ import { HttpRequestService } from '@/core/HttpRequestService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js';
import type Logger from '@/logger.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
type Request = {
url: string;
@@ -70,7 +71,7 @@ export class ApRequestCreator {
url: u.href,
method: 'GET',
headers: this.#objectAssignWithLcKey({
'Accept': 'application/activity+json, application/ld+json',
'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'Date': new Date().toUTCString(),
'Host': new URL(args.url).host,
}, args.additionalHeaders),
@@ -195,6 +196,9 @@ export class ApRequestService {
const res = await this.httpRequestService.send(url, {
method: req.request.method,
headers: req.request.headers,
}, {
throwErrorWhenResponseNotOk: true,
validators: [validateContentTypeSetAsActivityPub],
});
return await res.json();

View File

@@ -105,7 +105,7 @@ export class Resolver {
const object = (this.user
? await this.apRequestService.signedGet(value, this.user) as IObject
: await this.httpRequestService.getJson(value, 'application/activity+json, application/ld+json')) as IObject;
: await this.httpRequestService.getActivityJson(value)) as IObject;
if (
Array.isArray(object['@context']) ?

View File

@@ -7,13 +7,14 @@ import * as crypto from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js';
import { CONTEXTS } from './misc/contexts.js';
import { CONTEXT, PRELOADED_CONTEXTS } from './misc/contexts.js';
import { validateContentTypeSetAsJsonLD } from './misc/validator.js';
import type { JsonLdDocument } from 'jsonld';
import type { JsonLd, RemoteDocument } from 'jsonld/jsonld-spec.js';
import type { JsonLd as JsonLdObject, RemoteDocument } from 'jsonld/jsonld-spec.js';
// RsaSignature2017 based from https://github.com/transmute-industries/RsaSignature2017
// RsaSignature2017 implementation is based on https://github.com/transmute-industries/RsaSignature2017
class LdSignature {
class JsonLd {
public debug = false;
public preLoad = true;
public loderTimeout = 5000;
@@ -88,10 +89,18 @@ class LdSignature {
}
@bindThis
public async normalize(data: JsonLdDocument): Promise<string> {
public async compact(data: any, context: any = CONTEXT): Promise<JsonLdDocument> {
const customLoader = this.getLoader();
// XXX: Importing jsonld dynamically since Jest frequently fails to import it statically
// https://github.com/misskey-dev/misskey/pull/9894#discussion_r1103753595
return (await import('jsonld')).default.compact(data, context, {
documentLoader: customLoader,
});
}
@bindThis
public async normalize(data: JsonLdDocument): Promise<string> {
const customLoader = this.getLoader();
return (await import('jsonld')).default.normalize(data, {
documentLoader: customLoader,
});
@@ -103,11 +112,11 @@ class LdSignature {
if (!/^https?:\/\//.test(url)) throw new Error(`Invalid URL ${url}`);
if (this.preLoad) {
if (url in CONTEXTS) {
if (url in PRELOADED_CONTEXTS) {
if (this.debug) console.debug(`HIT: ${url}`);
return {
contextUrl: undefined,
document: CONTEXTS[url],
document: PRELOADED_CONTEXTS[url],
documentUrl: url,
};
}
@@ -124,7 +133,7 @@ class LdSignature {
}
@bindThis
private async fetchDocument(url: string): Promise<JsonLd> {
private async fetchDocument(url: string): Promise<JsonLdObject> {
const json = await this.httpRequestService.send(
url,
{
@@ -133,7 +142,10 @@ class LdSignature {
},
timeout: this.loderTimeout,
},
{ throwErrorWhenResponseNotOk: false },
{
throwErrorWhenResponseNotOk: false,
validators: [validateContentTypeSetAsJsonLD],
},
).then(res => {
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText}`);
@@ -142,7 +154,7 @@ class LdSignature {
}
});
return json as JsonLd;
return json as JsonLdObject;
}
@bindThis
@@ -154,14 +166,14 @@ class LdSignature {
}
@Injectable()
export class LdSignatureService {
export class JsonLdService {
constructor(
private httpRequestService: HttpRequestService,
) {
}
@bindThis
public use(): LdSignature {
return new LdSignature(this.httpRequestService);
public use(): JsonLd {
return new JsonLd(this.httpRequestService);
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { JsonLd } from 'jsonld/jsonld-spec.js';
import type { Context, JsonLd } from 'jsonld/jsonld-spec.js';
/* eslint:disable:quotemark indent */
const id_v1 = {
@@ -526,7 +526,42 @@ const activitystreams = {
},
} satisfies JsonLd;
export const CONTEXTS: Record<string, JsonLd> = {
const context_iris = [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
];
const extension_context_definition = {
Key: 'sec:Key',
// as non-standards
manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
sensitive: 'as:sensitive',
Hashtag: 'as:Hashtag',
quoteUrl: 'as:quoteUrl',
// Mastodon
toot: 'http://joinmastodon.org/ns#',
Emoji: 'toot:Emoji',
featured: 'toot:featured',
discoverable: 'toot:discoverable',
// schema
schema: 'http://schema.org#',
PropertyValue: 'schema:PropertyValue',
value: 'schema:value',
// Misskey
misskey: 'https://misskey-hub.net/ns#',
'_misskey_content': 'misskey:_misskey_content',
'_misskey_quote': 'misskey:_misskey_quote',
'_misskey_reaction': 'misskey:_misskey_reaction',
'_misskey_votes': 'misskey:_misskey_votes',
'_misskey_summary': 'misskey:_misskey_summary',
'isCat': 'misskey:isCat',
// vcard
vcard: 'http://www.w3.org/2006/vcard/ns#',
} satisfies Context;
export const CONTEXT: (string | Context)[] = [...context_iris, extension_context_definition];
export const PRELOADED_CONTEXTS: Record<string, JsonLd> = {
'https://w3id.org/identity/v1': id_v1,
'https://w3id.org/security/v1': security_v1,
'https://www.w3.org/ns/activitystreams': activitystreams,

View File

@@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Response } from 'node-fetch';
export function validateContentTypeSetAsActivityPub(response: Response): void {
const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
if (contentType === '') {
throw new Error('Validate content type of AP response: No content-type header');
}
if (
contentType.startsWith('application/activity+json') ||
(contentType.startsWith('application/ld+json;') && contentType.includes('https://www.w3.org/ns/activitystreams'))
) {
return;
}
throw new Error('Validate content type of AP response: Content type is not application/activity+json or application/ld+json');
}
const plusJsonSuffixRegex = /^\s*(application|text)\/[a-zA-Z0-9\.\-\+]+\+json\s*(;|$)/;
export function validateContentTypeSetAsJsonLD(response: Response): void {
const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
if (contentType === '') {
throw new Error('Validate content type of JSON LD: No content-type header');
}
if (
contentType.startsWith('application/ld+json') ||
contentType.startsWith('application/json') ||
plusJsonSuffixRegex.test(contentType)
) {
return;
}
throw new Error('Validate content type of JSON LD: Content type is not application/ld+json or application/json');
}

View File

@@ -17,7 +17,7 @@ import { bindThis } from '@/decorators.js';
import { checkHttps } from '@/misc/check-https.js';
import { ApResolverService } from '../ApResolverService.js';
import { ApLoggerService } from '../ApLoggerService.js';
import type { IObject } from '../type.js';
import { isDocument, type IObject } from '../type.js';
@Injectable()
export class ApImageService {
@@ -39,7 +39,7 @@ export class ApImageService {
* Imageを作成します。
*/
@bindThis
public async createImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile> {
public async createImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile | null> {
// 投稿者が凍結されていたらスキップ
if (actor.isSuspended) {
throw new Error('actor has been suspended');
@@ -47,16 +47,18 @@ export class ApImageService {
const image = await this.apResolverService.createResolver().resolve(value);
if (!isDocument(image)) return null;
if (image.url == null) {
throw new Error('invalid image: url not provided');
return null;
}
if (typeof image.url !== 'string') {
throw new Error('invalid image: unexpected type of url: ' + JSON.stringify(image.url, null, 2));
return null;
}
if (!checkHttps(image.url)) {
throw new Error('invalid image: unexpected schema of url: ' + image.url);
return null;
}
this.logger.info(`Creating the Image: ${image.url}`);
@@ -86,12 +88,11 @@ export class ApImageService {
/**
* Imageを解決します。
*
* Misskeyに対象のImageが登録されていればそれを返し、そうでなければ
* リモートサーバーからフェッチしてMisskeyに登録しそれを返します。
* ImageをリモートサーバーからフェッチしてMisskeyに登録しそれを返します。
*/
@bindThis
public async resolveImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile> {
// TODO
public async resolveImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile | null> {
// TODO: Misskeyに対象のImageが登録されていればそれを返す
// リモートサーバーからフェッチしてきて登録
return await this.createImage(actor, value);

View File

@@ -27,7 +27,7 @@ export class ApMentionService {
const limit = promiseLimit<MiUser | null>(2);
const mentionedUsers = (await Promise.all(
hrefs.map(x => limit(() => this.apPersonService.resolvePerson(x, resolver).catch(() => null))),
)).filter((x): x is MiUser => x != null);
)).filter(x => x != null);
return mentionedUsers;
}

View File

@@ -4,7 +4,6 @@
*/
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import promiseLimit from 'promise-limit';
import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { PollsRepository, EmojisRepository } from '@/models/_.js';
@@ -24,6 +23,7 @@ import { StatusError } from '@/misc/status-error.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { checkHttps } from '@/misc/check-https.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js';
import { ApLoggerService } from '../ApLoggerService.js';
import { ApMfmService } from '../ApMfmService.js';
@@ -80,20 +80,20 @@ export class ApNoteService {
const expectHost = this.utilityService.extractDbHost(uri);
if (!validPost.includes(getApType(object))) {
return new Error(`invalid Note: invalid object type ${getApType(object)}`);
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: invalid object type ${getApType(object)}`);
}
if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) {
return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`);
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`);
}
const actualHost = object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo));
if (object.attributedTo && actualHost !== expectHost) {
return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`);
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`);
}
if (object.published && !this.idService.isSafeT(new Date(object.published).valueOf())) {
return new Error('invalid Note: published timestamp is malformed');
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed');
}
return null;
@@ -127,7 +127,7 @@ export class ApNoteService {
value,
object,
});
throw new Error('invalid note');
throw err;
}
const note = object as IPost;
@@ -151,11 +151,47 @@ export class ApNoteService {
throw new Error('invalid note.attributedTo: ' + note.attributedTo);
}
const actor = await this.apPersonService.resolvePerson(getOneApId(note.attributedTo), resolver) as MiRemoteUser;
const uri = getOneApId(note.attributedTo);
// 投稿者が凍結されていたらスキップ
// ローカルで投稿者を検索し、もし凍結されていたらスキップ
const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser;
if (cachedActor && cachedActor.isSuspended) {
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
}
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
const apHashtags = extractApHashtags(note.tag);
const cw = note.summary === '' ? null : note.summary;
// テキストのパース
let text: string | null = null;
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
text = note.source.content;
} else if (typeof note._misskey_content !== 'undefined') {
text = note._misskey_content;
} else if (typeof note.content === 'string') {
text = this.apMfmService.htmlToMfm(note.content, note.tag);
}
const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined);
//#region Contents Check
// 添付ファイルとユーザーをこのサーバーで登録する前に内容をチェックする
/**
* 禁止ワードチェック
*/
const hasProhibitedWords = await this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices });
if (hasProhibitedWords) {
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words');
}
//#endregion
const actor = cachedActor ?? await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
// 解決した投稿者が凍結されていたらスキップ
if (actor.isSuspended) {
throw new Error('actor has been suspended');
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
}
const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
@@ -170,19 +206,14 @@ export class ApNoteService {
}
}
const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
const apHashtags = extractApHashtags(note.tag);
// 添付ファイル
// TODO: attachmentは必ずしもImageではない
// TODO: attachmentは必ずしも配列ではない
const limit = promiseLimit<MiDriveFile>(2);
const files = (await Promise.all(toArray(note.attachment).map(attach => (
limit(() => this.apImageService.resolveImage(actor, {
...attach,
sensitive: note.sensitive, // Noteがsensitiveなら添付もsensitiveにする
}))
))));
const files: MiDriveFile[] = [];
for (const attach of toArray(note.attachment)) {
attach.sensitive ??= note.sensitive;
const file = await this.apImageService.resolveImage(actor, attach);
if (file) files.push(file);
}
// リプライ
const reply: MiNote | null = note.inReplyTo
@@ -221,7 +252,7 @@ export class ApNoteService {
}
};
const uris = unique([note._misskey_quote, note.quoteUrl].filter((x): x is string => typeof x === 'string'));
const uris = unique([note._misskey_quote, note.quoteUrl].filter(x => x != null));
const results = await Promise.all(uris.map(tryResolveNote));
quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0);
@@ -232,18 +263,6 @@ export class ApNoteService {
}
}
const cw = note.summary === '' ? null : note.summary;
// テキストのパース
let text: string | null = null;
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
text = note.source.content;
} else if (typeof note._misskey_content !== 'undefined') {
text = note._misskey_content;
} else if (typeof note.content === 'string') {
text = this.apMfmService.htmlToMfm(note.content, note.tag);
}
// vote
if (reply && reply.hasPoll) {
const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id });
@@ -273,8 +292,6 @@ export class ApNoteService {
const apEmojis = emojis.map(emoji => emoji.name);
const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined);
try {
return await this.noteCreateService.create(actor, {
createdAt: note.published ? new Date(note.published) : null,
@@ -389,7 +406,7 @@ export class ApNoteService {
this.logger.info(`register emoji host=${host}, name=${name}`);
return await this.emojisRepository.insert({
return await this.emojisRepository.insertOne({
id: this.idService.gen(),
host,
name,
@@ -398,7 +415,7 @@ export class ApNoteService {
publicUrl: tag.icon.url,
updatedAt: new Date(),
aliases: [],
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
});
}));
}
}

View File

@@ -636,7 +636,7 @@ export class ApPersonService implements OnModuleInit {
// とりあえずidを別の時間で生成して順番を維持
let td = 0;
for (const note of featuredNotes.filter((note): note is MiNote => note != null)) {
for (const note of featuredNotes.filter(x => x != null)) {
td -= 1000;
transactionalEntityManager.insert(MiUserNotePining, {
id: this.idService.gen(Date.now() + td),

View File

@@ -51,7 +51,7 @@ export class ApQuestionService {
const choices = question[multiple ? 'anyOf' : 'oneOf']
?.map((x) => x.name)
.filter((x): x is string => typeof x === 'string')
.filter(x => x != null)
?? [];
const votes = question[multiple ? 'anyOf' : 'oneOf']?.map((x) => x.replies?.totalItems ?? x._misskey_votes ?? 0);

View File

@@ -15,7 +15,7 @@ export function extractApHashtags(tags: IObject | IObject[] | null | undefined):
return hashtags.map(tag => {
const m = tag.name.match(/^#(.+)/);
return m ? m[1] : null;
}).filter((x): x is string => x != null);
}).filter(x => x != null);
}
export function extractApHashtagObjects(tags: IObject | IObject[] | null | undefined): IApHashtag[] {

View File

@@ -25,6 +25,7 @@ export interface IObject {
endTime?: Date;
icon?: any;
image?: any;
mediaType?: string;
url?: ApObject | string;
href?: string;
tag?: IObject | IObject[];
@@ -240,14 +241,14 @@ export interface IKey extends IObject {
}
export interface IApDocument extends IObject {
type: 'Document';
name: string | null;
mediaType: string;
type: 'Audio' | 'Document' | 'Image' | 'Page' | 'Video';
}
export interface IApImage extends IObject {
export const isDocument = (object: IObject): object is IApDocument =>
['Audio', 'Document', 'Image', 'Page', 'Video'].includes(getApType(object));
export interface IApImage extends IApDocument {
type: 'Image';
name: string | null;
}
export interface ICreate extends IActivity {
@@ -327,3 +328,4 @@ export const isAnnounce = (object: IObject): object is IAnnounce => getApType(ob
export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block';
export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag';
export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move';
export const isNote = (object: IObject): object is IPost => getApType(object) === 'Note';

View File

@@ -14,6 +14,6 @@ export class ChartLoggerService {
constructor(
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('chart', 'white', process.env.NODE_ENV !== 'test');
this.logger = this.loggerService.getLogger('chart', 'white');
}
}

View File

@@ -47,7 +47,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
const suspendedInstancesQuery = this.instancesRepository.createQueryBuilder('instance')
.select('instance.host')
.where('instance.isSuspended = true');
.where('instance.suspensionState != \'none\'');
const pubsubSubQuery = this.followingsRepository.createQueryBuilder('f')
.select('f.followerHost')
@@ -89,7 +89,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
.select('COUNT(instance.id)')
.where(`instance.host IN (${ subInstancesQuery.getQuery() })`)
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
.andWhere('instance.isSuspended = false')
.andWhere('instance.suspensionState = \'none\'')
.andWhere('instance.isNotResponding = false')
.getRawOne()
.then(x => parseInt(x.count, 10)),
@@ -97,7 +97,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
.select('COUNT(instance.id)')
.where(`instance.host IN (${ pubInstancesQuery.getQuery() })`)
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) })
.andWhere('instance.isSuspended = false')
.andWhere('instance.suspensionState = \'none\'')
.andWhere('instance.isNotResponding = false')
.getRawOne()
.then(x => parseInt(x.count, 10)),

Some files were not shown because too many files have changed in this diff Show More